create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,281 @@
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.
5
+ */
6
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
7
+
8
+ const mockRefresh = jest.fn();
9
+ jest.mock("next/navigation", () => ({
10
+ __esModule: true,
11
+ useRouter: () => ({ refresh: mockRefresh }),
12
+ }));
13
+
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", () => ({
17
+ __esModule: true,
18
+ acceptCarriedAgreementsAction: (...args: unknown[]) => mockAcceptCarried(...args),
19
+ }));
20
+
21
+ const mockSetConsent = jest.fn();
22
+ // 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", () => ({
24
+ __esModule: true,
25
+ setConsentAction: (...args: unknown[]) => mockSetConsent(...args),
26
+ }));
27
+
28
+ import { AgreementsPrompt, type MissingAgreement } from "../agreements-prompt";
29
+
30
+ const LABELS = {
31
+ titleNew: "Please accept our terms",
32
+ titleUpdated: "We've updated our terms",
33
+ accept: "Accept",
34
+ error: "We couldn't save that. Try again.",
35
+ policyLink: "Read our privacy policy",
36
+ };
37
+
38
+ const AGREEMENT: MissingAgreement = {
39
+ id: "account_terms",
40
+ statement: "You agree to our Terms and Privacy Policy.",
41
+ policyHref: "/terms",
42
+ version: 1,
43
+ hadPriorGrant: false,
44
+ };
45
+
46
+ beforeEach(() => {
47
+ mockRefresh.mockReset();
48
+ mockAcceptCarried.mockReset();
49
+ mockSetConsent.mockReset();
50
+ });
51
+
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
+ });
58
+ });
59
+
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);
66
+ });
67
+
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();
73
+ });
74
+
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",
84
+ );
85
+ });
86
+
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();
91
+ });
92
+
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();
97
+ });
98
+ });
99
+
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
+ }
106
+
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));
113
+ });
114
+
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();
124
+ });
125
+ });
126
+
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`.
131
+
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", () => ({
134
+ __esModule: true,
135
+ readSession: jest.fn(),
136
+ }));
137
+
138
+ const mockMemberSetConsent = jest.fn();
139
+ jest.mock("@working-theory/api-client", () => ({
140
+ __esModule: true,
141
+ member: { setConsent: (...args: unknown[]) => mockMemberSetConsent(...args) },
142
+ }));
143
+
144
+ const mockCookieGet = jest.fn();
145
+ const mockCookieDelete = jest.fn();
146
+ jest.mock("next/headers", () => ({
147
+ __esModule: true,
148
+ cookies: jest.fn().mockResolvedValue({
149
+ get: (...args: unknown[]) => mockCookieGet(...args),
150
+ delete: (...args: unknown[]) => mockCookieDelete(...args),
151
+ toString: () => "",
152
+ }),
153
+ headers: jest.fn().mockResolvedValue(new Headers()),
154
+ }));
155
+
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(
197
+ "../agreements-actions",
198
+ ) as typeof import("../agreements-actions");
199
+ return acceptCarriedAgreementsAction(missingPurposeIds);
200
+ }
201
+
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
+ });
217
+
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 },
246
+ });
247
+
248
+ const result = await accept(["account_terms"]);
249
+ expect(result).toEqual({ accepted: false });
250
+ });
251
+
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 },
257
+ });
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();
273
+ });
274
+
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();
280
+ });
281
+ });
@@ -131,8 +131,22 @@ describe("the frame (D7)", () => {
131
131
  });
132
132
  });
133
133
 
134
+ describe("the standalone shape (#8430)", () => {
135
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
136
+ await renderPage();
137
+ const settings = screen.getByTestId("account-settings");
138
+ expect(settings).toHaveClass("min-h-screen");
139
+ const column = within(settings).getByRole("complementary");
140
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
141
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
142
+ expect(within(settings).getByRole("main")).toContainElement(
143
+ screen.getByRole("heading", { level: 1 }),
144
+ );
145
+ });
146
+ });
147
+
134
148
  describe("AC-5 — Billing as the current-plan card (D8)", () => {
135
- it("(a) shows the plan, the status, the card and Manage in Stripe under the Current plan heading", async () => {
149
+ it("(a) shows the plan, the status, the card and Manage billing under the Current plan heading", async () => {
136
150
  await renderPage();
137
151
  expect(screen.getByText("Current plan")).toBeInTheDocument();
138
152
  const card = screen.getByTestId("account-billing-card");
@@ -141,7 +155,7 @@ describe("AC-5 — Billing as the current-plan card (D8)", () => {
141
155
  expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
142
156
  "visa ending in 4242, expires 04/2027",
143
157
  );
144
- expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
158
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
145
159
  expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
146
160
  expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
147
161
  });
@@ -262,7 +276,7 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
262
276
  });
263
277
  });
264
278
 
265
- describe("#8217 AC-1 — the single site's Manage in Stripe (D10, D11)", () => {
279
+ describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
266
280
  const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
267
281
 
268
282
  it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
@@ -71,6 +71,8 @@ jest.mock("../../../../../auth", () => ({
71
71
  const mockPieces = jest.fn();
72
72
  const mockUpdateProfile = jest.fn();
73
73
  const mockRequestDeletion = jest.fn();
74
+ const mockContact = jest.fn();
75
+ const mockUpdateContact = jest.fn();
74
76
  const mockHubMe = jest.fn();
75
77
  const mockLeadsSubmit = jest.fn();
76
78
  jest.mock("@working-theory/api-client", () => ({
@@ -83,6 +85,8 @@ jest.mock("@working-theory/api-client", () => ({
83
85
  pieces: (...args: unknown[]) => mockPieces(...args),
84
86
  updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
85
87
  requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
88
+ contact: (...args: unknown[]) => mockContact(...args),
89
+ updateContact: (...args: unknown[]) => mockUpdateContact(...args),
86
90
  },
87
91
  hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
88
92
  visitorForwardHeaders: () => null,
@@ -120,8 +124,9 @@ import type { AccountSettingsSection } from "@working-theory/templates/account";
120
124
  import { accountSettingsSections } from "../../../../../lib/account-config";
121
125
  import { seal } from "../../../../../lib/hub-session/seal";
122
126
  import { BillingAnchor } from "../general/billing-anchor";
127
+ import { saveContact } from "../general/contact-actions";
123
128
  import Page from "../general/page";
124
- import { requestAccountDeletion, saveProfile } from "../profile-actions";
129
+ import { saveProfile } from "../profile-actions";
125
130
 
126
131
  const PHOTO = "https://cdn.example.com/ada.png";
127
132
  const SESSION = {
@@ -179,6 +184,8 @@ beforeEach(() => {
179
184
  mockPieces.mockReset().mockResolvedValue(ok([]));
180
185
  mockUpdateProfile.mockReset().mockResolvedValue(ok({ name: "Ada King", image: PHOTO }));
181
186
  mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
187
+ mockContact.mockReset().mockResolvedValue(ok({ contact: null }));
188
+ mockUpdateContact.mockReset().mockResolvedValue(ok({ contact: null }));
182
189
  mockHubMe.mockReset();
183
190
  mockLeadsSubmit.mockReset();
184
191
  mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
@@ -204,7 +211,7 @@ async function clickSave(): Promise<void> {
204
211
  // ── 1. The frame (AC-1, AC-4) ──────────────────────────────────────────────────
205
212
 
206
213
  describe("AC-1 — the frame (D2, D7)", () => {
207
- it("renders in the settings frame: Back to the Inbox, and the four tabs with General current", async () => {
214
+ it("renders in the settings frame: Back to the Inbox, and the five tabs with General current (#8438 D14 adds Privacy)", async () => {
208
215
  await renderPage();
209
216
  expect(screen.getByTestId("account-settings")).toContainElement(
210
217
  screen.getByRole("heading", { level: 1, name: "General" }),
@@ -217,6 +224,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
217
224
  ["General", "/account/general"],
218
225
  ["Security", "/account/security"],
219
226
  ["Notifications", "/account/notifications"],
227
+ ["Privacy", "/account/privacy"],
220
228
  ["Billing", "/account/billing"],
221
229
  ]);
222
230
  expect(links[0]).toHaveAttribute("aria-current", "page");
@@ -232,6 +240,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
232
240
  "href",
233
241
  "/fr/account/notifications",
234
242
  );
243
+ expect(screen.getByTestId("account-settings-rail-privacy")).toHaveAttribute("href", "/fr/account/privacy");
235
244
  expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("href", "/fr/account/billing");
236
245
  });
237
246
 
@@ -245,19 +254,19 @@ describe("AC-1 — the frame (D2, D7)", () => {
245
254
  }
246
255
  });
247
256
 
248
- it("the pane holds the email read-only, the name and photo inputs, in order before the deletion block", async () => {
257
+ it("the pane holds the email read-only, the name and photo inputs", async () => {
249
258
  await renderPage();
250
259
  const pane = screen.getByTestId("account-settings-general");
251
260
  expect(within(pane).getByTestId("account-general-email")).toHaveTextContent("ada@example.com");
252
261
  expect(within(pane).queryByRole("textbox", { name: "Email" })).not.toBeInTheDocument();
253
262
  expect(within(pane).getByLabelText("Name")).toHaveValue("Ada Lovelace");
254
263
  expect(within(pane).getByLabelText("Photo")).toHaveValue(PHOTO);
255
- const form = within(pane).getByTestId("account-general-form");
256
- const deletion = within(pane).getByTestId("account-deletion");
257
- expect(form.compareDocumentPosition(deletion) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
258
- expect(within(deletion).getByTestId("account-deletion-request")).toHaveTextContent(
259
- "Request account deletion",
260
- );
264
+ });
265
+
266
+ it("renders no deletion block — Request account deletion moved to Privacy (#8438 D16)", async () => {
267
+ await renderPage();
268
+ expect(screen.queryByTestId("account-deletion")).not.toBeInTheDocument();
269
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
261
270
  });
262
271
 
263
272
  it("the photo is never rendered as an image", async () => {
@@ -277,7 +286,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
277
286
  expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual(["Light", "Auto", "Dark"]);
278
287
  });
279
288
 
280
- it("a contributed settings section renders after the deletion block (C8)", async () => {
289
+ it("a contributed settings section renders after the general form (C8)", async () => {
281
290
  const section: AccountSettingsSection = {
282
291
  id: "extra",
283
292
  title: "settings.general.title",
@@ -286,9 +295,9 @@ describe("AC-1 — the frame (D2, D7)", () => {
286
295
  accountSettingsSections.push(section);
287
296
  try {
288
297
  await renderPage();
289
- const deletion = screen.getByTestId("account-deletion");
298
+ const form = screen.getByTestId("account-general-form");
290
299
  const extra = screen.getByTestId("account-settings-extra");
291
- expect(deletion.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
300
+ expect(form.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
292
301
  expect(screen.getByTestId("extra-body")).toBeInTheDocument();
293
302
  } finally {
294
303
  accountSettingsSections.splice(accountSettingsSections.indexOf(section), 1);
@@ -296,6 +305,20 @@ describe("AC-1 — the frame (D2, D7)", () => {
296
305
  });
297
306
  });
298
307
 
308
+ describe("the standalone shape (#8430)", () => {
309
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
310
+ await renderPage();
311
+ const settings = screen.getByTestId("account-settings");
312
+ expect(settings).toHaveClass("min-h-screen");
313
+ const column = within(settings).getByRole("complementary");
314
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
315
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
316
+ expect(within(settings).getByRole("main")).toContainElement(
317
+ screen.getByRole("heading", { level: 1 }),
318
+ );
319
+ });
320
+ });
321
+
299
322
  // ── 2. The profile form on the member path (AC-3) ─────────────────────────────
300
323
 
301
324
  describe("AC-3 — name and photo through the member API (D4, D8)", () => {
@@ -366,7 +389,6 @@ describe("AC-3 — name and photo through the member API (D4, D8)", () => {
366
389
  mockPieces.mockResolvedValue(refusal(503, "tenant_unresolved"));
367
390
  await renderPage();
368
391
  expect(screen.getByLabelText("Photo")).toBeInTheDocument();
369
- expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
370
392
  });
371
393
  });
372
394
 
@@ -473,67 +495,146 @@ describe("AC-3 — a single site with no member API (a 404 from the pieces read)
473
495
  );
474
496
  expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
475
497
  });
498
+ });
499
+
500
+ // ── #8435 D13 (AC-6) — the contact details on this site ───────────────────────
501
+
502
+ describe("AC-6 — the contact details (#8435 D13)", () => {
503
+ const CONTACT = {
504
+ givenName: "Ada",
505
+ familyName: "Lovelace",
506
+ telephone: "+442071234567",
507
+ address: {
508
+ addressLine1: "1 Analytical Way",
509
+ addressLine2: null,
510
+ city: "London",
511
+ region: "LDN",
512
+ postalCode: "N1 1AA",
513
+ country: "GB",
514
+ },
515
+ companyName: "Engines Ltd",
516
+ jobTitle: "Countess",
517
+ };
476
518
 
477
- it("points the deletion request at Support, with no Request button (C2, D5)", async () => {
519
+ it("with the member API, renders the contact section seeded from member.contact", async () => {
520
+ mockContact.mockResolvedValue(ok({ contact: CONTACT }));
478
521
  await renderPage();
479
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
480
- expect(screen.getByTestId("account-deletion-support")).toHaveAttribute("href", "/account/support");
481
- expect(screen.getByText("To ask for your account to be deleted, contact support.")).toBeInTheDocument();
522
+ const section = screen.getByTestId("account-contact-section");
523
+ expect(mockContact).toHaveBeenCalledWith(COOKIE_INIT);
524
+ expect(within(section).getByTestId("contact-given-name")).toHaveValue("Ada");
525
+ expect(within(section).getByTestId("contact-company")).toHaveValue("Engines Ltd");
526
+ expect(within(section).getByTestId("field-addressLine1")).toHaveValue("1 Analytical Way");
527
+ // The profile form's single "Save changes" button stays unique.
528
+ expect(screen.getAllByRole("button", { name: "Save changes" })).toHaveLength(1);
529
+ expect(within(section).getByTestId("contact-details-save")).toHaveTextContent("Save contact details");
482
530
  });
483
- });
484
531
 
485
- // ── 4. The deletion request (AC-4) ────────────────────────────────────────────
532
+ it("with no confirmed contact yet, the form starts blank", async () => {
533
+ await renderPage();
534
+ expect(screen.getByTestId("contact-given-name")).toHaveValue("");
535
+ });
486
536
 
487
- describe("AC-4 — Request account deletion through the member API (D5)", () => {
488
- async function requestDeletion(): Promise<void> {
489
- await act(async () => {
490
- fireEvent.click(screen.getByTestId("account-deletion-request"));
491
- });
492
- }
537
+ it("a failed read renders the section's unavailable line, not the form", async () => {
538
+ mockContact.mockResolvedValue(refusal(503, "tenant_unresolved"));
539
+ await renderPage();
540
+ expect(screen.queryByTestId("contact-details-form")).not.toBeInTheDocument();
541
+ expect(screen.getByTestId("account-contact-unavailable")).toHaveTextContent(
542
+ "Contact details aren't available on this site right now.",
543
+ );
544
+ });
493
545
 
494
- it.each([
495
- ["site-operator", "Sent to the site operator."],
496
- ["hub-operator", "Sent to the hub operator."],
497
- ])("an answer naming %s shows %s — the answer picks the line", async (sentTo, line) => {
498
- mockRequestDeletion.mockResolvedValue(ok({ sentTo }));
546
+ it("a read that throws is the same unavailable line", async () => {
547
+ mockContact.mockRejectedValue(new Error("down"));
499
548
  await renderPage();
500
- await requestDeletion();
501
- expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
502
- await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent(line));
503
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
504
- expect(mockLeadsSubmit).not.toHaveBeenCalled();
549
+ expect(screen.getByTestId("account-contact-unavailable")).toBeInTheDocument();
550
+ });
551
+
552
+ it("with no member API (a 404 from the pieces read) the section does not render", async () => {
553
+ mockPieces.mockResolvedValue(refusal(404, "not_found"));
554
+ await renderPage();
555
+ expect(screen.queryByTestId("account-contact-section")).not.toBeInTheDocument();
556
+ expect(mockContact).not.toHaveBeenCalled();
505
557
  });
506
558
 
507
- it("a refusal shows its line and keeps the button", async () => {
508
- mockRequestDeletion.mockResolvedValue(refusal(403, "email_unverified"));
559
+ it("saving posts all six keys through saveContact and answers Saved", async () => {
560
+ mockContact.mockResolvedValue(ok({ contact: CONTACT }));
509
561
  await renderPage();
510
- await requestDeletion();
511
- await waitFor(() =>
512
- expect(screen.getByRole("alert")).toHaveTextContent("Confirm your email address first, then ask again."),
562
+ fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
563
+ await act(async () => {
564
+ fireEvent.submit(screen.getByTestId("contact-details-form"));
565
+ });
566
+ await waitFor(() => expect(mockUpdateContact).toHaveBeenCalledTimes(1));
567
+ expect(mockUpdateContact).toHaveBeenCalledWith(
568
+ {
569
+ givenName: "Ada",
570
+ familyName: "Lovelace",
571
+ telephone: "+442071234567",
572
+ address: CONTACT.address,
573
+ companyName: "Acme",
574
+ jobTitle: "Countess",
575
+ },
576
+ COOKIE_INIT,
513
577
  );
514
- expect(screen.getByTestId("account-deletion-request")).toBeEnabled();
578
+ expect(await screen.findByTestId("contact-details-message")).toHaveTextContent("Saved.");
579
+ });
580
+
581
+ it("a refused telephone shows its own line and keeps the fields as typed", async () => {
582
+ mockUpdateContact.mockResolvedValue(refusal(400, "telephone_invalid"));
583
+ await renderPage();
584
+ fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
585
+ await act(async () => {
586
+ fireEvent.submit(screen.getByTestId("contact-details-form"));
587
+ });
588
+ expect(await screen.findByTestId("contact-details-message")).toHaveTextContent(
589
+ "Enter the phone number with its country code.",
590
+ );
591
+ expect(screen.getByTestId("contact-company")).toHaveValue("Acme");
592
+ });
593
+ });
594
+
595
+ describe("AC-6 — saveContact (#8435 D13)", () => {
596
+ const BODY = {
597
+ givenName: "Ada",
598
+ familyName: null,
599
+ telephone: null,
600
+ address: null,
601
+ companyName: null,
602
+ jobTitle: null,
603
+ };
604
+
605
+ it("calls member.updateContact with forwardedCookieInit()'s init", async () => {
606
+ mockHeaders.mockResolvedValue(new Headers({ origin: "https://site.example.com" }));
607
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: true });
608
+ expect(mockUpdateContact).toHaveBeenCalledWith(BODY, {
609
+ headers: { cookie: "session=abc", origin: "https://site.example.com" },
610
+ });
515
611
  });
516
612
 
517
613
  it.each([
614
+ [refusal(400, "telephone_invalid"), "telephone"],
615
+ [refusal(400, "invalid_input"), "invalid"],
518
616
  [refusal(403, "email_unverified"), "unverified"],
519
617
  [refusal(404, "not_found"), "unavailable"],
520
- [refusal(503, "not_configured"), "unavailable"],
521
- [refusal(503, "delivery_failed"), "unavailable"],
522
- [refusal(500, "internal"), "failed"],
523
- ])("requestAccountDeletion maps %j to %s", async (answer, reason) => {
524
- mockRequestDeletion.mockResolvedValue(answer);
525
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason });
526
- expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
618
+ [refusal(409, "contact_unavailable"), "unavailable"],
619
+ [refusal(503, "tenant_unresolved"), "unavailable"],
620
+ [refusal(500, "internal_error"), "failed"],
621
+ ])("maps %j to %s", async (res, reason) => {
622
+ mockUpdateContact.mockResolvedValue(res);
623
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason });
527
624
  });
528
625
 
529
- it("requestAccountDeletion answers who it went to, and a throw is failed", async () => {
530
- mockRequestDeletion.mockResolvedValue(ok({ sentTo: "hub-operator" }));
531
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: true, sentTo: "hub-operator" });
532
- mockRequestDeletion.mockRejectedValue(new TypeError("fetch failed"));
533
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason: "failed" });
626
+ it("a throw is failed, and a non-object body makes no call", async () => {
627
+ mockUpdateContact.mockRejectedValue(new Error("down"));
628
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason: "failed" });
629
+ mockUpdateContact.mockClear();
630
+ await expect(saveContact(null as never)).resolves.toEqual({ ok: false, reason: "invalid" });
631
+ expect(mockUpdateContact).not.toHaveBeenCalled();
534
632
  });
535
633
  });
536
634
 
635
+ // The deletion-request cases moved to privacy.test.tsx — Request account
636
+ // deletion now renders on Privacy, not General (#8438 D16).
637
+
537
638
  // ── 5. #billing (AC-6) ────────────────────────────────────────────────────────
538
639
 
539
640
  describe("AC-6 — BillingAnchor forwards #billing to Billing (D8)", () => {