create-ailk 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/component-catalog.json +37 -3
  2. package/component-catalog.md +6 -5
  3. package/dist/add-module.js +9 -5
  4. package/dist/derive-architecture.d.ts +8 -1
  5. package/dist/derive-architecture.js +38 -3
  6. package/dist/design-package.js +7 -2
  7. package/dist/lib/env-catalog.js +5 -1
  8. package/dist/module-architecture.js +4 -2
  9. package/dist/programmatic.js +19 -5
  10. package/dist/site-chrome.d.ts +8 -0
  11. package/dist/site-chrome.js +17 -1
  12. package/dist/spoke-checkout.d.ts +39 -18
  13. package/dist/spoke-checkout.js +177 -236
  14. package/dist/surfaces.js +19 -14
  15. package/dist/sync-routes.js +3 -2
  16. package/dist/template-record.d.ts +4 -3
  17. package/dist/template-record.js +6 -3
  18. package/dist/topology.js +25 -1
  19. package/dist/update.js +5 -0
  20. package/package.json +3 -3
  21. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  22. package/templates/apps/api/CLAUDE.md +15 -6
  23. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  24. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  25. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  26. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  27. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  28. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  29. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  30. package/templates/apps/api/src/middleware/auth.ts +13 -6
  31. package/templates/apps/api/src/openapi/spec.ts +30 -3
  32. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  33. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  34. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  35. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  36. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  37. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  38. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  39. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  40. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  41. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  42. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  43. package/templates/apps/api/src/server.ts +28 -0
  44. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  45. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  46. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  47. package/templates/apps/api/src/services/consent.ts +95 -0
  48. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  49. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  50. package/templates/apps/api/src/services/person.ts +40 -8
  51. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  52. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  53. package/templates/apps/web/.env.example +14 -0
  54. package/templates/apps/web/CLAUDE.md +120 -9
  55. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  56. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  57. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  58. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  59. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  60. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  61. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  62. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  63. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  64. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  65. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  67. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  68. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  69. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  70. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  71. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  72. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  78. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  80. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  81. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  82. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  83. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  85. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  86. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  87. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  88. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  89. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  90. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  91. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  92. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  93. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  94. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  95. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  96. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  97. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  98. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  99. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  100. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  101. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  102. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  103. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
  104. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
  105. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  106. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
  107. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  108. package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  112. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  113. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  114. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  115. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  116. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  119. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  120. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  122. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  124. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  125. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  126. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  127. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  128. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  129. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  130. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  131. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  132. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  133. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  134. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  135. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  136. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  137. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  138. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  139. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  140. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  141. package/templates/apps/web/auth.ts +28 -2
  142. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  143. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  144. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  145. package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
  146. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
  147. package/templates/apps/web/lib/account-invite.ts +37 -0
  148. package/templates/apps/web/lib/checkout-config.ts +27 -0
  149. package/templates/apps/web/lib/checkout.ts +93 -0
  150. package/templates/apps/web/lib/consent-policies.ts +22 -0
  151. package/templates/apps/web/lib/deck-page.ts +6 -0
  152. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  153. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  154. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  155. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  156. package/templates/apps/web/lib/site-chrome.ts +2 -0
  157. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  158. package/templates/apps/web/messages/en.json +24 -7
  159. package/templates/apps/web/package.json +1 -0
  160. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  161. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  162. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  163. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  164. package/templates/database/CHANGELOG.md +38 -0
  165. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  166. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  167. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  168. package/templates/database/__tests__/seed.test.ts +34 -1
  169. package/templates/database/inbox/schema.prisma +17 -2
  170. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  171. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/database/seed.ts +34 -3
  174. package/versions.json +34 -34
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -1,281 +1,301 @@
1
1
  /**
2
- * Tests for AgreementsPrompt (#8438 D13) and the carried-agreement cookie
3
- * `acceptCarriedAgreementsAction` consumes — exercised here because this
4
- * prompt is its one caller.
2
+ * Tests for AgreementsInterstitial (#8765 D7, AC-2, AC-4, AC-5) — the
3
+ * full-page sign-up-agreement ask the account layout renders in place of the
4
+ * account, and its Server Action `acceptAccountAgreementsAction`.
5
5
  */
6
- import { fireEvent, render, screen, waitFor } from "@testing-library/react";
7
-
6
+ import { consentStatementText } from "@working-theory/ui/blocks";
7
+ import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
8
+ import {
9
+ fireEvent,
10
+ render,
11
+ screen,
12
+ waitFor,
13
+ within,
14
+ } from "@testing-library/react";
15
+
16
+ const mockPush = jest.fn();
8
17
  const mockRefresh = jest.fn();
9
18
  jest.mock("next/navigation", () => ({
10
19
  __esModule: true,
11
- useRouter: () => ({ refresh: mockRefresh }),
20
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
21
+ usePathname: () => "/account",
22
+ useSearchParams: () => new URLSearchParams(""),
12
23
  }));
13
24
 
14
- const mockAcceptCarried = jest.fn();
15
- // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
16
- jest.mock("../agreements-actions", () => ({
25
+ const mockSignOut = jest.fn();
26
+ jest.mock("better-auth/react", () => ({
17
27
  __esModule: true,
18
- acceptCarriedAgreementsAction: (...args: unknown[]) => mockAcceptCarried(...args),
28
+ createAuthClient: () => ({
29
+ signOut: (...args: unknown[]) => mockSignOut(...args),
30
+ }),
19
31
  }));
20
32
 
21
- const mockSetConsent = jest.fn();
33
+ const mockAcceptAction = jest.fn();
22
34
  // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
23
- jest.mock("../privacy/actions", () => ({
35
+ jest.mock("../agreements-actions", () => ({
24
36
  __esModule: true,
25
- setConsentAction: (...args: unknown[]) => mockSetConsent(...args),
37
+ acceptAccountAgreementsAction: (...args: unknown[]) =>
38
+ mockAcceptAction(...args),
26
39
  }));
27
40
 
28
- import { AgreementsPrompt, type MissingAgreement } from "../agreements-prompt";
41
+ import { consentPoliciesFor } from "../../../../../lib/consent-policies";
42
+ import {
43
+ AgreementsInterstitial,
44
+ type AgreementsInterstitialAgreement,
45
+ } from "../agreements-prompt";
29
46
 
30
47
  const LABELS = {
31
48
  titleNew: "Please accept our terms",
32
49
  titleUpdated: "We've updated our terms",
50
+ lead: "To keep using your account, accept the agreements below.",
33
51
  accept: "Accept",
52
+ signOut: "Sign out",
34
53
  error: "We couldn't save that. Try again.",
35
- policyLink: "Read our privacy policy",
54
+ signOutFailed: "We couldn't sign you out. Try again.",
36
55
  };
37
56
 
38
- const AGREEMENT: MissingAgreement = {
57
+ const AGREEMENT: AgreementsInterstitialAgreement = {
39
58
  id: "account_terms",
40
- statement: "You agree to our Terms and Privacy Policy.",
41
- policyHref: "/terms",
42
- version: 1,
43
- hadPriorGrant: false,
59
+ statement: CONTACT_FORM_TERMS_STATEMENT,
60
+ acceptedVersion: null,
44
61
  };
45
62
 
63
+ function renderInterstitial(
64
+ agreements: AgreementsInterstitialAgreement[] = [AGREEMENT],
65
+ locale = "en",
66
+ extra: { signOutEndpoint?: string; signInHref?: string } = {},
67
+ ): void {
68
+ render(
69
+ <AgreementsInterstitial
70
+ locale={locale}
71
+ agreements={agreements}
72
+ labels={LABELS}
73
+ {...extra}
74
+ />,
75
+ );
76
+ }
77
+
78
+ const fetchMock = jest.fn();
79
+ const ORIGINAL_FETCH = globalThis.fetch;
80
+
46
81
  beforeEach(() => {
82
+ mockPush.mockReset();
47
83
  mockRefresh.mockReset();
48
- mockAcceptCarried.mockReset();
49
- mockSetConsent.mockReset();
84
+ mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
85
+ mockAcceptAction.mockReset();
86
+ fetchMock.mockReset();
87
+ globalThis.fetch = fetchMock;
50
88
  });
51
89
 
52
- describe("AgreementsPrompt — with nothing missing", () => {
53
- it("renders nothing and calls nothing", () => {
54
- render(<AgreementsPrompt missing={[]} labels={LABELS} />);
55
- expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
56
- expect(mockAcceptCarried).not.toHaveBeenCalled();
57
- });
90
+ afterAll(() => {
91
+ globalThis.fetch = ORIGINAL_FETCH;
58
92
  });
59
93
 
60
- describe("AgreementsPrompt — the carried-cookie fast path (D13)", () => {
61
- it("tries it once on mount, with every missing id", async () => {
62
- mockAcceptCarried.mockResolvedValue({ accepted: false });
63
- render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
64
- await waitFor(() => expect(mockAcceptCarried).toHaveBeenCalledWith(["account_terms"]));
65
- expect(mockAcceptCarried).toHaveBeenCalledTimes(1);
94
+ describe("AgreementsInterstitial — the page (D7)", () => {
95
+ it("renders one h1 — 'Please accept our terms' when nothing was accepted before — and the lead", () => {
96
+ renderInterstitial();
97
+ const page = screen.getByTestId("account-agreements-interstitial");
98
+ const headings = within(page).getAllByRole("heading", { level: 1 });
99
+ expect(headings).toHaveLength(1);
100
+ expect(headings[0]).toHaveTextContent("Please accept our terms");
101
+ expect(page).toHaveTextContent(
102
+ "To keep using your account, accept the agreements below.",
103
+ );
66
104
  });
67
105
 
68
- it("on accept, hides and refreshes — the card never shows", async () => {
69
- mockAcceptCarried.mockResolvedValue({ accepted: true });
70
- render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
71
- await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
72
- expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
106
+ it("reads 'We've updated our terms' when any entry has an accepted version (AC-4)", () => {
107
+ renderInterstitial([{ ...AGREEMENT, acceptedVersion: 1 }]);
108
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
109
+ "We've updated our terms",
110
+ );
73
111
  });
74
112
 
75
- it("on a failed check, shows the card with the statement and policy link", async () => {
76
- mockAcceptCarried.mockResolvedValue({ accepted: false });
77
- render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
78
- expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
79
- expect(screen.getByText("Please accept our terms")).toBeInTheDocument();
80
- expect(screen.getByText("You agree to our Terms and Privacy Policy.")).toBeInTheDocument();
81
- expect(screen.getByTestId("account-agreements-prompt-account_terms-policy")).toHaveAttribute(
82
- "href",
83
- "/terms",
113
+ it("draws no element of the retired Inbox card", () => {
114
+ renderInterstitial();
115
+ expect(
116
+ screen.queryByTestId("account-agreements-prompt"),
117
+ ).not.toBeInTheDocument();
118
+ expect(
119
+ document.querySelector(
120
+ '[data-testid^="account-agreements-prompt-"][data-testid$="-policy"]',
121
+ ),
122
+ ).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("AgreementsInterstitial — the statement renders with its links (AC-5, #8761)", () => {
127
+ it.each(["en", "fr"])(
128
+ "%s: the text is the Contact sentence with no markdown, and three labelled links to consentPoliciesFor(locale)",
129
+ (locale) => {
130
+ renderInterstitial([AGREEMENT], locale);
131
+ const statement = screen.getByTestId(
132
+ "account-agreements-statement-account_terms",
133
+ );
134
+ expect(statement.textContent).toBe(
135
+ consentStatementText(CONTACT_FORM_TERMS_STATEMENT),
136
+ );
137
+ expect(statement.textContent).not.toContain("[");
138
+ expect(statement.textContent).not.toContain("](");
139
+
140
+ const policies = consentPoliciesFor(locale);
141
+ const links = (
142
+ [
143
+ ["terms", "Terms"],
144
+ ["privacy", "Privacy Policy"],
145
+ ["working_agreement", "Working Agreement"],
146
+ ] as const
147
+ ).map(([key, label]) => {
148
+ const link = within(statement).getByTestId(
149
+ `consent-policy-link-${key}`,
150
+ );
151
+ return [
152
+ link.textContent,
153
+ link.getAttribute("href"),
154
+ label,
155
+ policies[key],
156
+ ];
157
+ });
158
+ for (const [text, href, label, expected] of links) {
159
+ expect(text).toBe(label);
160
+ expect(href).toBe(expected);
161
+ }
162
+ },
163
+ );
164
+ });
165
+
166
+ describe("AgreementsInterstitial — Accept (AC-2)", () => {
167
+ it("is a 44px button that calls the action once with every id, then refreshes", async () => {
168
+ mockAcceptAction.mockResolvedValue({ ok: true });
169
+ renderInterstitial();
170
+ const accept = screen.getByTestId("account-agreements-accept");
171
+ expect(accept).toHaveTextContent("Accept");
172
+ expect(accept.className).toContain("min-h-(--control-height-big)");
173
+ fireEvent.click(accept);
174
+ await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
175
+ expect(mockAcceptAction).toHaveBeenCalledTimes(1);
176
+ expect(mockAcceptAction).toHaveBeenCalledWith(["account_terms"]);
177
+ expect(mockAcceptAction.mock.invocationCallOrder[0]).toBeLessThan(
178
+ mockRefresh.mock.invocationCallOrder[0]!,
84
179
  );
85
180
  });
86
181
 
87
- it("shows the 'updated' title when any missing agreement had a prior grant", async () => {
88
- mockAcceptCarried.mockResolvedValue({ accepted: false });
89
- render(<AgreementsPrompt missing={[{ ...AGREEMENT, hadPriorGrant: true }]} labels={LABELS} />);
90
- expect(await screen.findByText("We've updated our terms")).toBeInTheDocument();
182
+ it("a refused write shows the error line and does not refresh", async () => {
183
+ mockAcceptAction.mockResolvedValue({ ok: false });
184
+ renderInterstitial();
185
+ fireEvent.click(screen.getByTestId("account-agreements-accept"));
186
+ expect(
187
+ await screen.findByTestId("account-agreements-error"),
188
+ ).toHaveTextContent("We couldn't save that. Try again.");
189
+ expect(mockRefresh).not.toHaveBeenCalled();
190
+ expect(screen.getByTestId("account-agreements-accept")).not.toBeDisabled();
91
191
  });
92
192
 
93
- it("a rejected check still shows the card, rather than throwing", async () => {
94
- mockAcceptCarried.mockRejectedValue(new Error("down"));
95
- render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
96
- expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
193
+ it("a thrown write shows the error line too", async () => {
194
+ mockAcceptAction.mockRejectedValue(new Error("down"));
195
+ renderInterstitial();
196
+ fireEvent.click(screen.getByTestId("account-agreements-accept"));
197
+ expect(
198
+ await screen.findByTestId("account-agreements-error"),
199
+ ).toBeInTheDocument();
200
+ expect(mockRefresh).not.toHaveBeenCalled();
97
201
  });
98
202
  });
99
203
 
100
- describe("AgreementsPrompt — the explicit Accept button", () => {
101
- async function showCard(): Promise<void> {
102
- mockAcceptCarried.mockResolvedValue({ accepted: false });
103
- render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
104
- await screen.findByTestId("account-agreements-prompt");
105
- }
204
+ describe("AgreementsInterstitial — Sign out (D7, the frame's own logic)", () => {
205
+ it("is a ghost button; without an endpoint it signs out through Better Auth and goes home", async () => {
206
+ renderInterstitial();
207
+ const signOut = screen.getByTestId("account-agreements-sign-out");
208
+ expect(signOut).toHaveTextContent("Sign out");
209
+ fireEvent.click(signOut);
210
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
211
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
212
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
213
+ expect(mockAcceptAction).not.toHaveBeenCalled();
214
+ });
106
215
 
107
- it("posts every missing id through setConsentAction, then refreshes", async () => {
108
- mockSetConsent.mockResolvedValue({ ok: true, dateLine: "Given Oct 12, 2026" });
109
- await showCard();
110
- fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
111
- await waitFor(() => expect(mockSetConsent).toHaveBeenCalledWith("account_terms", true));
112
- await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
216
+ it("with a hub endpoint it POSTs it and lands on the sign-in page", async () => {
217
+ fetchMock.mockResolvedValue({ ok: true });
218
+ renderInterstitial([AGREEMENT], "fr", {
219
+ signOutEndpoint: "/api/hub-session/sign-out",
220
+ signInHref: "/fr/account",
221
+ });
222
+ fireEvent.click(screen.getByTestId("account-agreements-sign-out"));
223
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/fr/account"));
224
+ expect(fetchMock).toHaveBeenCalledWith("/api/hub-session/sign-out", {
225
+ method: "POST",
226
+ credentials: "same-origin",
227
+ });
228
+ expect(mockSignOut).not.toHaveBeenCalled();
113
229
  });
114
230
 
115
- it("a failed answer shows the error and keeps the card", async () => {
116
- mockSetConsent.mockResolvedValue({ ok: false, message: "nope" });
117
- await showCard();
118
- fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
119
- expect(await screen.findByTestId("account-agreements-prompt-error")).toHaveTextContent(
120
- "We couldn't save that. Try again.",
121
- );
122
- expect(screen.getByTestId("account-agreements-prompt")).toBeInTheDocument();
123
- expect(mockRefresh).not.toHaveBeenCalled();
231
+ it("a refused hub sign-out stays and says so", async () => {
232
+ fetchMock.mockResolvedValue({ ok: false, status: 403 });
233
+ renderInterstitial([AGREEMENT], "en", {
234
+ signOutEndpoint: "/api/hub-session/sign-out",
235
+ });
236
+ fireEvent.click(screen.getByTestId("account-agreements-sign-out"));
237
+ expect(
238
+ await screen.findByTestId("account-sign-out-error"),
239
+ ).toHaveTextContent("We couldn't sign you out. Try again.");
240
+ expect(mockPush).not.toHaveBeenCalled();
124
241
  });
125
242
  });
126
243
 
127
- // ── acceptCarriedAgreementsAction's own cookie checks (D13 C6) ────────────────
128
- //
129
- // The real action, loaded past the mock above — exercising the HMAC, the
130
- // one-hour window and the email-hash binding with real `node:crypto`.
244
+ // ── acceptAccountAgreementsAction — the real module ───────────────────────────
131
245
 
132
- // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
133
- jest.mock("../../../../../lib/topology.generated", () => ({
246
+ const mockAcceptAccountAgreements = jest.fn();
247
+ jest.mock("@working-theory/api-client", () => ({
134
248
  __esModule: true,
135
- readSession: jest.fn(),
249
+ consent: {
250
+ acceptAccountAgreements: (...args: unknown[]) =>
251
+ mockAcceptAccountAgreements(...args),
252
+ },
136
253
  }));
137
254
 
138
- const mockMemberSetConsent = jest.fn();
139
- jest.mock("@working-theory/api-client", () => ({
255
+ jest.mock("next-intl/server", () => ({
140
256
  __esModule: true,
141
- member: { setConsent: (...args: unknown[]) => mockMemberSetConsent(...args) },
257
+ getLocale: jest.fn().mockResolvedValue("fr"),
142
258
  }));
143
259
 
144
- const mockCookieGet = jest.fn();
145
- const mockCookieDelete = jest.fn();
146
260
  jest.mock("next/headers", () => ({
147
261
  __esModule: true,
148
- cookies: jest.fn().mockResolvedValue({
149
- get: (...args: unknown[]) => mockCookieGet(...args),
150
- delete: (...args: unknown[]) => mockCookieDelete(...args),
151
- toString: () => "",
152
- }),
262
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
153
263
  headers: jest.fn().mockResolvedValue(new Headers()),
154
264
  }));
155
265
 
156
- describe("acceptCarriedAgreementsAction (the real module, D13 C6)", () => {
157
- const COOKIE_NAME = "ailk-signup-agreements"; // NODE_ENV !== "production" in jest
158
- const EMAIL = "ada@example.com";
159
-
160
- async function remember(email: string, purposeIds: string[]): Promise<string | undefined> {
161
- const { rememberSignUpAgreementsAction } = jest.requireActual(
162
- "../../../(auth)/sign-up/sign-up.actions",
163
- ) as typeof import("../../../(auth)/sign-up/sign-up.actions");
164
- await rememberSignUpAgreementsAction(email, purposeIds);
165
- expect(mockCookieSet).toHaveBeenCalledTimes(1);
166
- return mockCookieSet.mock.calls[0]?.[1] as string | undefined;
167
- }
168
-
169
- const mockCookieSet = jest.fn();
170
-
171
- beforeEach(() => {
172
- mockCookieGet.mockReset();
173
- mockCookieDelete.mockReset();
174
- mockCookieSet.mockReset();
175
- mockMemberSetConsent.mockReset().mockResolvedValue({ ok: true, body: { purpose: {} } });
176
- (jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockReset();
177
- });
178
-
179
- // Minting goes through `cookies().set`, which the outer `cookies()` mock
180
- // does not define — reassign it per test so `remember()` can capture the
181
- // value, without disturbing the delete-tracking mock the "real action"
182
- // suite shares.
183
- beforeEach(() => {
184
- const headersModule = jest.requireMock("next/headers") as {
185
- cookies: jest.Mock;
186
- };
187
- headersModule.cookies.mockResolvedValue({
188
- get: (...args: unknown[]) => mockCookieGet(...args),
189
- set: (...args: unknown[]) => mockCookieSet(...args),
190
- delete: (...args: unknown[]) => mockCookieDelete(...args),
191
- toString: () => "",
192
- });
193
- });
194
-
195
- async function accept(missingPurposeIds: string[]) {
196
- const { acceptCarriedAgreementsAction } = jest.requireActual(
266
+ describe("acceptAccountAgreementsAction (the real module, D7)", () => {
267
+ async function accept(purposeIds: string[]): Promise<{ ok: boolean }> {
268
+ const { acceptAccountAgreementsAction } = jest.requireActual(
197
269
  "../agreements-actions",
198
270
  ) as typeof import("../agreements-actions");
199
- return acceptCarriedAgreementsAction(missingPurposeIds);
271
+ return acceptAccountAgreementsAction(purposeIds);
200
272
  }
201
273
 
202
- it("a valid, fresh cookie whose email matches writes every missing purpose with via sign_up, then deletes the cookie", async () => {
203
- const value = await remember(EMAIL, ["account_terms"]);
204
- mockCookieGet.mockReturnValue({ value });
205
- (jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
206
- user: { email: EMAIL, emailVerified: true },
207
- });
208
-
209
- const result = await accept(["account_terms"]);
210
- expect(result).toEqual({ accepted: true });
211
- expect(mockMemberSetConsent).toHaveBeenCalledWith(
212
- { purposeId: "account_terms", granted: true, via: "sign_up" },
213
- expect.anything(),
214
- );
215
- expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
216
- });
274
+ beforeEach(() => mockAcceptAccountAgreements.mockReset());
217
275
 
218
- it("a flipped character in the cookie fails the HMAC — posts nothing, deletes it, not accepted", async () => {
219
- const value = await remember(EMAIL, ["account_terms"]);
220
- const tampered = `${value?.slice(0, -1)}${value?.slice(-1) === "0" ? "1" : "0"}`;
221
- mockCookieGet.mockReturnValue({ value: tampered });
222
-
223
- const result = await accept(["account_terms"]);
224
- expect(result).toEqual({ accepted: false });
225
- expect(mockMemberSetConsent).not.toHaveBeenCalled();
226
- expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
227
- });
228
-
229
- it("another email's hash is refused, even with a verified session", async () => {
230
- const value = await remember("someone-else@example.com", ["account_terms"]);
231
- mockCookieGet.mockReturnValue({ value });
232
- (jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
233
- user: { email: EMAIL, emailVerified: true },
234
- });
235
-
236
- const result = await accept(["account_terms"]);
237
- expect(result).toEqual({ accepted: false });
238
- expect(mockMemberSetConsent).not.toHaveBeenCalled();
239
- });
240
-
241
- it("an unverified session email is refused even when the hash would match", async () => {
242
- const value = await remember(EMAIL, ["account_terms"]);
243
- mockCookieGet.mockReturnValue({ value });
244
- (jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
245
- user: { email: EMAIL, emailVerified: false },
276
+ it("posts the ids and the request's locale with the forwarded cookie, and answers ok", async () => {
277
+ mockAcceptAccountAgreements.mockResolvedValue({
278
+ ok: true,
279
+ body: { agreements: [], complete: true },
246
280
  });
247
-
248
- const result = await accept(["account_terms"]);
249
- expect(result).toEqual({ accepted: false });
281
+ await expect(accept(["account_terms"])).resolves.toEqual({ ok: true });
282
+ expect(mockAcceptAccountAgreements).toHaveBeenCalledWith(
283
+ { purposeIds: ["account_terms"], locale: "fr" },
284
+ { headers: { cookie: "session=abc" } },
285
+ );
250
286
  });
251
287
 
252
- it("a cookie that does not cover every missing id is refused", async () => {
253
- const value = await remember(EMAIL, ["account_terms"]);
254
- mockCookieGet.mockReturnValue({ value });
255
- (jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
256
- user: { email: EMAIL, emailVerified: true },
288
+ it("a refusal answers ok: false", async () => {
289
+ mockAcceptAccountAgreements.mockResolvedValue({
290
+ ok: false,
291
+ status: 404,
292
+ body: null,
257
293
  });
258
-
259
- const result = await accept(["account_terms", "another_agreement"]);
260
- expect(result).toEqual({ accepted: false });
261
- expect(mockMemberSetConsent).not.toHaveBeenCalled();
262
- });
263
-
264
- it("an issuedAt more than an hour old is refused", async () => {
265
- jest.useFakeTimers().setSystemTime(new Date("2026-10-12T00:00:00.000Z"));
266
- const value = await remember(EMAIL, ["account_terms"]);
267
- jest.setSystemTime(new Date("2026-10-12T01:00:01.000Z"));
268
- mockCookieGet.mockReturnValue({ value });
269
-
270
- const result = await accept(["account_terms"]);
271
- expect(result).toEqual({ accepted: false });
272
- jest.useRealTimers();
294
+ await expect(accept(["account_terms"])).resolves.toEqual({ ok: false });
273
295
  });
274
296
 
275
- it("no cookie present is refused, with no delete call", async () => {
276
- mockCookieGet.mockReturnValue(undefined);
277
- const result = await accept(["account_terms"]);
278
- expect(result).toEqual({ accepted: false });
279
- expect(mockCookieDelete).not.toHaveBeenCalled();
297
+ it("a thrown post answers ok: false, never a throw", async () => {
298
+ mockAcceptAccountAgreements.mockRejectedValue(new Error("network"));
299
+ await expect(accept(["account_terms"])).resolves.toEqual({ ok: false });
280
300
  });
281
301
  });
@@ -36,7 +36,12 @@ jest.mock("../../../../../lib/topology.generated", () => ({
36
36
  jest.mock("../account-data", () => ({
37
37
  __esModule: true,
38
38
  accountSession: async () => ({
39
- ctx: { user: { name: "Ada", email: "ada@example.com" }, t: {} },
39
+ ctx: {
40
+ locale: "en",
41
+ user: { name: "Ada", email: "ada@example.com" },
42
+ t: {},
43
+ init: { headers: {} },
44
+ },
40
45
  }),
41
46
  loadForYou: async () => ({ items: [], now: "2026-10-12T00:00:00.000Z" }),
42
47
  }));
@@ -52,6 +57,17 @@ jest.mock("../../../../../lib/site-logo", () => ({
52
57
  siteLogo: () => null,
53
58
  }));
54
59
 
60
+ // #8765 D7 — the layout's agreements read: complete, so the frame renders.
61
+ jest.mock("@working-theory/api-client", () => ({
62
+ __esModule: true,
63
+ consent: {
64
+ accountAgreements: async () => ({
65
+ ok: true,
66
+ body: { agreements: [], complete: true },
67
+ }),
68
+ },
69
+ }));
70
+
55
71
  const mockFrame = jest.fn();
56
72
  // eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
57
73
  jest.mock("../account-frame", () => ({