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
@@ -19,9 +19,16 @@
19
19
  * tab to the page (PhoneTabProvider).
20
20
  * 5. The For-you items are the aside; none → no aside.
21
21
  * 6. No site chrome (Nav, Footer, ThemeToggle) inside the frame.
22
+ * 7. The four Settings pages render alone, inside the shell marker only —
23
+ * no rail, no Inbox, no AppShell frame (#8430): the page draws its own
24
+ * standalone settings frame, as the Admin's does. Membership is an
25
+ * exact, locale-aware match on settings-frame.tsx's `SETTINGS_ROUTES`
26
+ * (#8441): a sub-route of a settings path stays inside the shell.
27
+ * 8. One main landmark (#8441): every settings route and the Inbox, each
28
+ * rendered through this layout with its real page, draw exactly one.
22
29
  */
23
30
  import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
24
- import type { ReactElement } from "react";
31
+ import type { ReactElement, ReactNode } from "react";
25
32
 
26
33
  jest.mock("next-intl/server", () => {
27
34
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -65,13 +72,20 @@ const mockMemberNotFound = jest.fn();
65
72
  jest.mock("@working-theory/api-client", () => ({
66
73
  __esModule: true,
67
74
  waitlistSignups: { me: jest.fn() },
68
- subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
75
+ flowCheckouts: { me: jest.fn() },
76
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args), portalSession: jest.fn() },
77
+ leads: { submit: jest.fn() },
69
78
  member: {
70
79
  pieces: (...args: unknown[]) => mockMemberNotFound(...args),
71
80
  messages: (...args: unknown[]) => mockMemberNotFound(...args),
72
81
  purchases: (...args: unknown[]) => mockMemberNotFound(...args),
73
82
  appointments: (...args: unknown[]) => mockMemberNotFound(...args),
83
+ // #8441 — the Inbox page's New-menu availability reads, for § one main landmark.
84
+ meetingTypes: (...args: unknown[]) => mockMemberNotFound(...args),
85
+ requestForm: (...args: unknown[]) => mockMemberNotFound(...args),
74
86
  },
87
+ hubSignIn: { me: jest.fn() },
88
+ visitorForwardHeaders: () => null,
75
89
  }));
76
90
 
77
91
  const mockSignOut = jest.fn();
@@ -86,7 +100,8 @@ let mockPathname = "/account";
86
100
  let mockSearch = "";
87
101
  jest.mock("next/navigation", () => ({
88
102
  __esModule: true,
89
- useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
103
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
104
+ redirect: jest.fn(),
90
105
  usePathname: () => mockPathname,
91
106
  useSearchParams: () => new URLSearchParams(mockSearch),
92
107
  }));
@@ -94,6 +109,8 @@ jest.mock("next/navigation", () => ({
94
109
  import { usePhoneTab } from "@working-theory/templates/account";
95
110
 
96
111
  import AccountLayout from "../layout";
112
+ import InboxPage from "../page";
113
+ import { SETTINGS_ROUTES, type SettingsTabKey } from "../settings-frame";
97
114
 
98
115
  /** A page that shows the phone tab the frame hands it. */
99
116
  function PhoneTabProbe(): ReactElement {
@@ -128,16 +145,16 @@ function openByPointer(trigger: HTMLElement): void {
128
145
  fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
129
146
  }
130
147
 
131
- async function renderLayout(locale = "en"): Promise<void> {
132
- const element = await AccountLayout({
133
- children: (
134
- <>
135
- <p data-testid="account-child">page</p>
136
- <PhoneTabProbe />
137
- </>
138
- ),
139
- params: Promise.resolve({ locale }),
140
- });
148
+ async function renderLayout(
149
+ locale = "en",
150
+ children: ReactNode = (
151
+ <>
152
+ <p data-testid="account-child">page</p>
153
+ <PhoneTabProbe />
154
+ </>
155
+ ),
156
+ ): Promise<void> {
157
+ const element = await AccountLayout({ children, params: Promise.resolve({ locale }) });
141
158
  render(element);
142
159
  }
143
160
 
@@ -217,12 +234,11 @@ describe("the account rail (D13, AC-17)", () => {
217
234
 
218
235
  it.each([
219
236
  "/account/support",
220
- // #8334 — the Settings pages (and the old path that redirects to General)
221
- // select no row: Settings has no rail row to select.
222
- "/account/general",
223
- "/account/billing",
237
+ // #8334 — the old Settings path (it redirects to General) selects no
238
+ // row: Settings has no rail row to select. The four Settings pages render
239
+ // no rail at all (#8430, § the settings routes below).
224
240
  "/account/settings",
225
- "/fr/account/billing",
241
+ "/fr/account/support",
226
242
  ])("no row is selected on %s (#8231 D1, #8334)", async (path) => {
227
243
  mockPathname = path;
228
244
  await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
@@ -451,6 +467,96 @@ describe("no session", () => {
451
467
  });
452
468
  });
453
469
 
470
+ describe("the settings routes take the screen (#8430)", () => {
471
+ it.each([
472
+ ["/account/general", "en"],
473
+ ["/account/security", "en"],
474
+ ["/account/notifications", "en"],
475
+ ["/account/privacy", "en"],
476
+ ["/account/billing", "en"],
477
+ ["/fr/account/billing", "fr"],
478
+ ])("on %s the page renders alone — no rail, no Inbox, no AppShell frame", async (path, locale) => {
479
+ mockPathname = path;
480
+ await renderLayout(locale);
481
+ expect(screen.getByTestId("account-child")).toBeInTheDocument();
482
+ expect(screen.queryByTestId("dashboard-shell")).not.toBeInTheDocument();
483
+ expect(screen.queryByTestId("sidebar-desktop")).not.toBeInTheDocument();
484
+ expect(screen.queryByTestId("app-shell-aside")).not.toBeInTheDocument();
485
+ expect(screen.queryByRole("link", { name: "Inbox" })).not.toBeInTheDocument();
486
+ expect(screen.queryByRole("link", { name: "Artifacts" })).not.toBeInTheDocument();
487
+ });
488
+
489
+ it("keeps the shell marker around the page, so the Admin's tokens still apply (#8337 D4)", async () => {
490
+ mockPathname = "/account/general";
491
+ await renderLayout();
492
+ const markers = document.querySelectorAll("[data-ailk-shell]");
493
+ expect(markers).toHaveLength(1);
494
+ const marker = markers[0] as HTMLElement;
495
+ expect(marker.getAttribute("data-ailk-shell")).toBe("account");
496
+ expect(Array.from(marker.classList)).toEqual(["contents"]);
497
+ expect(within(marker).getByTestId("account-child")).toBeInTheDocument();
498
+ });
499
+
500
+ it.each([
501
+ "/account",
502
+ "/account/artifacts",
503
+ "/account/support",
504
+ "/account/inbox/purchase/fc_1",
505
+ // #8441 — the match is exact: a sub-route of a settings path is not listed,
506
+ // so it stays inside the shell.
507
+ "/account/billing/history",
508
+ "/account/general/",
509
+ "/fr/account/security/sessions",
510
+ ])(
511
+ "%s still renders inside the frame",
512
+ async (path) => {
513
+ mockPathname = path;
514
+ await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
515
+ expect(screen.getByTestId("dashboard-shell")).toBeInTheDocument();
516
+ expect(within(screen.getByTestId("sidebar-desktop")).getByRole("link", { name: "Inbox" })).toBeInTheDocument();
517
+ },
518
+ );
519
+ });
520
+
521
+ describe("one main landmark (#8441)", () => {
522
+ type Page = (props: { params: Promise<{ locale: string }> }) => Promise<ReactElement>;
523
+ // Keyed by the list's own type, so a tab added to `SETTINGS_ROUTES` without
524
+ // its page here fails to compile. Billing loads with `import()`:
525
+ // architecture.yaml bans a static import of a relative billing/ path
526
+ // (billing.test.tsx).
527
+ const PAGES: Record<SettingsTabKey, () => Promise<Page>> = {
528
+ general: async () => (await import("../general/page")).default,
529
+ security: async () => (await import("../security/page")).default,
530
+ notifications: async () => (await import("../notifications/page")).default,
531
+ privacy: async () => (await import("../privacy/page")).default,
532
+ billing: async () => (await import("../billing/page")).default,
533
+ };
534
+
535
+ async function renderRoute(path: string, locale: string, page: Page): Promise<void> {
536
+ mockPathname = path;
537
+ await renderLayout(locale, await page({ params: Promise.resolve({ locale }) }));
538
+ }
539
+
540
+ it.each(SETTINGS_ROUTES.flatMap(({ key, path }) => [
541
+ [path, "en", key] as const,
542
+ [`/fr${path}`, "fr", key] as const,
543
+ ]))("%s draws exactly one <main>", async (path, locale, key) => {
544
+ await renderRoute(path, locale, await PAGES[key]());
545
+ expect(screen.getAllByRole("main")).toHaveLength(1);
546
+ expect(document.querySelectorAll("main")).toHaveLength(1);
547
+ });
548
+
549
+ it.each([
550
+ ["/account", "en"],
551
+ ["/fr/account", "fr"],
552
+ ])("the Inbox, %s, draws exactly one <main>", async (path, locale) => {
553
+ await renderRoute(path, locale, InboxPage);
554
+ expect(screen.getByTestId("dashboard-shell")).toBeInTheDocument();
555
+ expect(screen.getAllByRole("main")).toHaveLength(1);
556
+ expect(document.querySelectorAll("main")).toHaveLength(1);
557
+ });
558
+ });
559
+
454
560
  describe("the shell marker (#8337 D4, AC-6)", () => {
455
561
  it("with a session, exactly one [data-ailk-shell=\"account\"] wraps the shell", async () => {
456
562
  await renderLayout();
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Tests for /[locale]/account/notifications — User settings › Notifications
3
- * (#8339 D8, NEW). Moved from general.test.tsx's "Privacy" cookie-settings
4
- * link (#8339 D12, C6) — notification preferences are a later unit's scope.
3
+ * (#8339 D8; #8438 D15, AC-8). Always renders (decision A): one
4
+ * `ConsentSwitchList` of the registry's `area: "communications"` purposes,
5
+ * or one empty line with none declared. The cookie-settings link moved to
6
+ * Privacy (#8438 D14) — this tab no longer renders it.
5
7
  */
6
8
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
9
  import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
11
 
10
- import { render, screen } from "@testing-library/react";
12
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
11
13
 
12
14
  jest.mock("next-intl/server", () => {
13
15
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -29,13 +31,14 @@ jest.mock("next-intl/server", () => {
29
31
  );
30
32
  };
31
33
  },
34
+ getLocale: jest.fn().mockResolvedValue("en"),
32
35
  };
33
36
  });
34
37
 
35
38
  jest.mock("next/headers", () => ({
36
39
  __esModule: true,
37
40
  headers: jest.fn().mockResolvedValue(new Headers()),
38
- cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
41
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc", get: () => undefined }),
39
42
  }));
40
43
 
41
44
  const mockGetSession = jest.fn();
@@ -45,13 +48,19 @@ jest.mock("../../../../../auth", () => ({
45
48
  auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
46
49
  }));
47
50
 
51
+ const mockConsents = jest.fn();
52
+ const mockSetConsent = jest.fn();
48
53
  jest.mock("@working-theory/api-client", () => ({
49
54
  __esModule: true,
50
55
  waitlistSignups: { me: jest.fn() },
51
56
  flowCheckouts: { me: jest.fn() },
52
57
  subscription: { me: jest.fn(), portalSession: jest.fn() },
53
58
  leads: { submit: jest.fn() },
54
- member: { pieces: jest.fn().mockResolvedValue({ ok: true, body: [] }) },
59
+ member: {
60
+ pieces: jest.fn().mockResolvedValue({ ok: true, body: [] }),
61
+ consents: (...args: unknown[]) => mockConsents(...args),
62
+ setConsent: (...args: unknown[]) => mockSetConsent(...args),
63
+ },
55
64
  hubSignIn: { me: jest.fn() },
56
65
  visitorForwardHeaders: () => null,
57
66
  }));
@@ -75,6 +84,31 @@ const SESSION = {
75
84
  },
76
85
  };
77
86
 
87
+ const ok = <T,>(body: T) => ({ ok: true, body });
88
+
89
+ function communicationsPurpose(
90
+ overrides: Partial<{
91
+ id: string;
92
+ kind: "consent" | "preference";
93
+ effective: boolean;
94
+ state: { granted: boolean; at: string; source: string; policyVersion: number } | null;
95
+ }> = {},
96
+ ) {
97
+ return {
98
+ id: overrides.id ?? "marketing_email",
99
+ kind: overrides.kind ?? "consent",
100
+ area: "communications" as const,
101
+ required: false,
102
+ signUp: false,
103
+ description: "Product news and offers.",
104
+ statement: "Receive product news and offers by email.",
105
+ policyHref: "/privacy",
106
+ policyVersion: 1,
107
+ state: overrides.state === undefined ? null : overrides.state,
108
+ effective: overrides.effective ?? false,
109
+ };
110
+ }
111
+
78
112
  let contentRoot: string;
79
113
 
80
114
  beforeEach(() => {
@@ -82,6 +116,8 @@ beforeEach(() => {
82
116
  process.env.AILK_CONTENT_ROOT = contentRoot;
83
117
  writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
84
118
  mockGetSession.mockReset().mockResolvedValue(SESSION);
119
+ mockConsents.mockReset().mockResolvedValue(ok({ purposes: [] }));
120
+ mockSetConsent.mockReset();
85
121
  });
86
122
 
87
123
  afterEach(() => {
@@ -98,29 +134,91 @@ describe("the frame (D7, D8)", () => {
98
134
  expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Notifications");
99
135
  expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
100
136
  expect(screen.getByTestId("account-settings-rail-notifications")).toHaveAttribute("aria-current", "page");
101
- expect(screen.getByTestId("account-settings-rail-billing")).not.toHaveAttribute("aria-current");
137
+ expect(screen.getByTestId("account-settings-rail-privacy")).not.toHaveAttribute("aria-current");
102
138
  });
103
139
  });
104
140
 
105
- describe("cookie settings (D7, moved from general.test.tsx)", () => {
106
- it("links to /privacy#cookie-settings", async () => {
141
+ describe("the standalone shape (#8430)", () => {
142
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
107
143
  await renderPage();
108
- expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
109
- "href",
110
- "/privacy#cookie-settings",
144
+ const settings = screen.getByTestId("account-settings");
145
+ expect(settings).toHaveClass("min-h-screen");
146
+ const column = within(settings).getByRole("complementary");
147
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
148
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
149
+ expect(within(settings).getByRole("main")).toContainElement(
150
+ screen.getByRole("heading", { level: 1 }),
111
151
  );
112
152
  });
153
+ });
113
154
 
114
- it("a non-default locale prefixes the link", async () => {
115
- await renderPage("fr");
116
- expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
117
- "href",
118
- "/fr/privacy#cookie-settings",
155
+ describe("AC-8 — always present, one empty line with no communications purposes declared", () => {
156
+ it("renders exactly one account-notifications-empty line and no cookie link", async () => {
157
+ await renderPage();
158
+ expect(screen.getByTestId("account-notifications-empty")).toHaveTextContent(
159
+ "There's nothing to set here yet.",
119
160
  );
161
+ expect(screen.queryByTestId("account-cookie-settings")).not.toBeInTheDocument();
162
+ expect(screen.queryByTestId("account-notifications")).not.toBeInTheDocument();
120
163
  });
121
164
 
122
- it("renders no deletion button", async () => {
165
+ it("the tab link is present in the frame regardless", async () => {
166
+ await renderPage();
167
+ expect(screen.getByTestId("account-settings-rail-notifications")).toBeInTheDocument();
168
+ });
169
+ });
170
+
171
+ describe("AC-8 — a declared communications preference defaults on with no row", () => {
172
+ it("renders one switch, on", async () => {
173
+ mockConsents.mockResolvedValue(
174
+ ok({ purposes: [communicationsPurpose({ kind: "preference", effective: true, state: null })] }),
175
+ );
176
+ await renderPage();
177
+ expect(screen.queryByTestId("account-notifications-empty")).not.toBeInTheDocument();
178
+ expect(screen.getByTestId("account-notifications-marketing_email-switch")).toBeChecked();
179
+ });
180
+ });
181
+
182
+ describe("AC-8 — a declared communications consent defaults off with no row", () => {
183
+ it("renders one switch, off", async () => {
184
+ mockConsents.mockResolvedValue(
185
+ ok({ purposes: [communicationsPurpose({ kind: "consent", effective: false, state: null })] }),
186
+ );
187
+ await renderPage();
188
+ expect(screen.getByTestId("account-notifications-marketing_email-switch")).not.toBeChecked();
189
+ });
190
+
191
+ it("toggling calls setConsentAction through member.setConsent", async () => {
192
+ mockConsents.mockResolvedValue(
193
+ ok({ purposes: [communicationsPurpose({ kind: "consent", effective: false, state: null })] }),
194
+ );
195
+ mockSetConsent.mockResolvedValue(
196
+ ok({
197
+ purpose: communicationsPurpose({
198
+ kind: "consent",
199
+ effective: true,
200
+ state: { granted: true, at: "2026-10-12T12:00:00.000Z", source: "account_settings", policyVersion: 1 },
201
+ }),
202
+ }),
203
+ );
204
+ await renderPage();
205
+ fireEvent.click(screen.getByTestId("account-notifications-marketing_email-switch"));
206
+ await waitFor(() => expect(mockSetConsent).toHaveBeenCalledTimes(1));
207
+ expect(mockSetConsent).toHaveBeenCalledWith(
208
+ { purposeId: "marketing_email", granted: true, locale: "en" },
209
+ expect.objectContaining({ headers: expect.objectContaining({ cookie: "session=abc" }) }),
210
+ );
211
+ expect(await screen.findByTestId("account-notifications-marketing_email-date")).toHaveTextContent(
212
+ "Given Oct 12, 2026",
213
+ );
214
+ });
215
+ });
216
+
217
+ describe("a failed consents read", () => {
218
+ it("shows the unavailable line, not a broken switch list", async () => {
219
+ mockConsents.mockResolvedValue({ ok: false, status: 503, body: null });
123
220
  await renderPage();
124
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
221
+ expect(screen.getByTestId("account-notifications-unavailable")).toBeInTheDocument();
222
+ expect(screen.queryByTestId("account-notifications-empty")).not.toBeInTheDocument();
125
223
  });
126
224
  });