create-ailk 0.19.1 → 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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -126,7 +126,7 @@ import { seal } from "../../../../../lib/hub-session/seal";
126
126
  import { BillingAnchor } from "../general/billing-anchor";
127
127
  import { saveContact } from "../general/contact-actions";
128
128
  import Page from "../general/page";
129
- import { requestAccountDeletion, saveProfile } from "../profile-actions";
129
+ import { saveProfile } from "../profile-actions";
130
130
 
131
131
  const PHOTO = "https://cdn.example.com/ada.png";
132
132
  const SESSION = {
@@ -211,7 +211,7 @@ async function clickSave(): Promise<void> {
211
211
  // ── 1. The frame (AC-1, AC-4) ──────────────────────────────────────────────────
212
212
 
213
213
  describe("AC-1 — the frame (D2, D7)", () => {
214
- 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 () => {
215
215
  await renderPage();
216
216
  expect(screen.getByTestId("account-settings")).toContainElement(
217
217
  screen.getByRole("heading", { level: 1, name: "General" }),
@@ -224,6 +224,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
224
224
  ["General", "/account/general"],
225
225
  ["Security", "/account/security"],
226
226
  ["Notifications", "/account/notifications"],
227
+ ["Privacy", "/account/privacy"],
227
228
  ["Billing", "/account/billing"],
228
229
  ]);
229
230
  expect(links[0]).toHaveAttribute("aria-current", "page");
@@ -239,6 +240,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
239
240
  "href",
240
241
  "/fr/account/notifications",
241
242
  );
243
+ expect(screen.getByTestId("account-settings-rail-privacy")).toHaveAttribute("href", "/fr/account/privacy");
242
244
  expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("href", "/fr/account/billing");
243
245
  });
244
246
 
@@ -252,19 +254,19 @@ describe("AC-1 — the frame (D2, D7)", () => {
252
254
  }
253
255
  });
254
256
 
255
- 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 () => {
256
258
  await renderPage();
257
259
  const pane = screen.getByTestId("account-settings-general");
258
260
  expect(within(pane).getByTestId("account-general-email")).toHaveTextContent("ada@example.com");
259
261
  expect(within(pane).queryByRole("textbox", { name: "Email" })).not.toBeInTheDocument();
260
262
  expect(within(pane).getByLabelText("Name")).toHaveValue("Ada Lovelace");
261
263
  expect(within(pane).getByLabelText("Photo")).toHaveValue(PHOTO);
262
- const form = within(pane).getByTestId("account-general-form");
263
- const deletion = within(pane).getByTestId("account-deletion");
264
- expect(form.compareDocumentPosition(deletion) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
265
- expect(within(deletion).getByTestId("account-deletion-request")).toHaveTextContent(
266
- "Request account deletion",
267
- );
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();
268
270
  });
269
271
 
270
272
  it("the photo is never rendered as an image", async () => {
@@ -284,7 +286,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
284
286
  expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual(["Light", "Auto", "Dark"]);
285
287
  });
286
288
 
287
- 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 () => {
288
290
  const section: AccountSettingsSection = {
289
291
  id: "extra",
290
292
  title: "settings.general.title",
@@ -293,9 +295,9 @@ describe("AC-1 — the frame (D2, D7)", () => {
293
295
  accountSettingsSections.push(section);
294
296
  try {
295
297
  await renderPage();
296
- const deletion = screen.getByTestId("account-deletion");
298
+ const form = screen.getByTestId("account-general-form");
297
299
  const extra = screen.getByTestId("account-settings-extra");
298
- expect(deletion.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
300
+ expect(form.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
299
301
  expect(screen.getByTestId("extra-body")).toBeInTheDocument();
300
302
  } finally {
301
303
  accountSettingsSections.splice(accountSettingsSections.indexOf(section), 1);
@@ -387,7 +389,6 @@ describe("AC-3 — name and photo through the member API (D4, D8)", () => {
387
389
  mockPieces.mockResolvedValue(refusal(503, "tenant_unresolved"));
388
390
  await renderPage();
389
391
  expect(screen.getByLabelText("Photo")).toBeInTheDocument();
390
- expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
391
392
  });
392
393
  });
393
394
 
@@ -494,13 +495,6 @@ describe("AC-3 — a single site with no member API (a 404 from the pieces read)
494
495
  );
495
496
  expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
496
497
  });
497
-
498
- it("points the deletion request at Support, with no Request button (C2, D5)", async () => {
499
- await renderPage();
500
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
501
- expect(screen.getByTestId("account-deletion-support")).toHaveAttribute("href", "/account/support");
502
- expect(screen.getByText("To ask for your account to be deleted, contact support.")).toBeInTheDocument();
503
- });
504
498
  });
505
499
 
506
500
  // ── #8435 D13 (AC-6) — the contact details on this site ───────────────────────
@@ -527,23 +521,23 @@ describe("AC-6 — the contact details (#8435 D13)", () => {
527
521
  await renderPage();
528
522
  const section = screen.getByTestId("account-contact-section");
529
523
  expect(mockContact).toHaveBeenCalledWith(COOKIE_INIT);
530
- expect(within(section).getByTestId("account-contact-given-name")).toHaveValue("Ada");
531
- expect(within(section).getByTestId("account-contact-company")).toHaveValue("Engines Ltd");
524
+ expect(within(section).getByTestId("contact-given-name")).toHaveValue("Ada");
525
+ expect(within(section).getByTestId("contact-company")).toHaveValue("Engines Ltd");
532
526
  expect(within(section).getByTestId("field-addressLine1")).toHaveValue("1 Analytical Way");
533
527
  // The profile form's single "Save changes" button stays unique.
534
528
  expect(screen.getAllByRole("button", { name: "Save changes" })).toHaveLength(1);
535
- expect(within(section).getByTestId("account-contact-save")).toHaveTextContent("Save contact details");
529
+ expect(within(section).getByTestId("contact-details-save")).toHaveTextContent("Save contact details");
536
530
  });
537
531
 
538
532
  it("with no confirmed contact yet, the form starts blank", async () => {
539
533
  await renderPage();
540
- expect(screen.getByTestId("account-contact-given-name")).toHaveValue("");
534
+ expect(screen.getByTestId("contact-given-name")).toHaveValue("");
541
535
  });
542
536
 
543
537
  it("a failed read renders the section's unavailable line, not the form", async () => {
544
538
  mockContact.mockResolvedValue(refusal(503, "tenant_unresolved"));
545
539
  await renderPage();
546
- expect(screen.queryByTestId("account-contact-form")).not.toBeInTheDocument();
540
+ expect(screen.queryByTestId("contact-details-form")).not.toBeInTheDocument();
547
541
  expect(screen.getByTestId("account-contact-unavailable")).toHaveTextContent(
548
542
  "Contact details aren't available on this site right now.",
549
543
  );
@@ -565,9 +559,9 @@ describe("AC-6 — the contact details (#8435 D13)", () => {
565
559
  it("saving posts all six keys through saveContact and answers Saved", async () => {
566
560
  mockContact.mockResolvedValue(ok({ contact: CONTACT }));
567
561
  await renderPage();
568
- fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
562
+ fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
569
563
  await act(async () => {
570
- fireEvent.submit(screen.getByTestId("account-contact-form"));
564
+ fireEvent.submit(screen.getByTestId("contact-details-form"));
571
565
  });
572
566
  await waitFor(() => expect(mockUpdateContact).toHaveBeenCalledTimes(1));
573
567
  expect(mockUpdateContact).toHaveBeenCalledWith(
@@ -581,20 +575,20 @@ describe("AC-6 — the contact details (#8435 D13)", () => {
581
575
  },
582
576
  COOKIE_INIT,
583
577
  );
584
- expect(await screen.findByTestId("account-contact-message")).toHaveTextContent("Saved.");
578
+ expect(await screen.findByTestId("contact-details-message")).toHaveTextContent("Saved.");
585
579
  });
586
580
 
587
581
  it("a refused telephone shows its own line and keeps the fields as typed", async () => {
588
582
  mockUpdateContact.mockResolvedValue(refusal(400, "telephone_invalid"));
589
583
  await renderPage();
590
- fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
584
+ fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
591
585
  await act(async () => {
592
- fireEvent.submit(screen.getByTestId("account-contact-form"));
586
+ fireEvent.submit(screen.getByTestId("contact-details-form"));
593
587
  });
594
- expect(await screen.findByTestId("account-contact-message")).toHaveTextContent(
588
+ expect(await screen.findByTestId("contact-details-message")).toHaveTextContent(
595
589
  "Enter the phone number with its country code.",
596
590
  );
597
- expect(screen.getByTestId("account-contact-company")).toHaveValue("Acme");
591
+ expect(screen.getByTestId("contact-company")).toHaveValue("Acme");
598
592
  });
599
593
  });
600
594
 
@@ -638,57 +632,8 @@ describe("AC-6 — saveContact (#8435 D13)", () => {
638
632
  });
639
633
  });
640
634
 
641
- // ── 4. The deletion request (AC-4) ────────────────────────────────────────────
642
-
643
- describe("AC-4 — Request account deletion through the member API (D5)", () => {
644
- async function requestDeletion(): Promise<void> {
645
- await act(async () => {
646
- fireEvent.click(screen.getByTestId("account-deletion-request"));
647
- });
648
- }
649
-
650
- it.each([
651
- ["site-operator", "Sent to the site operator."],
652
- ["hub-operator", "Sent to the hub operator."],
653
- ])("an answer naming %s shows %s — the answer picks the line", async (sentTo, line) => {
654
- mockRequestDeletion.mockResolvedValue(ok({ sentTo }));
655
- await renderPage();
656
- await requestDeletion();
657
- expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
658
- await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent(line));
659
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
660
- expect(mockLeadsSubmit).not.toHaveBeenCalled();
661
- });
662
-
663
- it("a refusal shows its line and keeps the button", async () => {
664
- mockRequestDeletion.mockResolvedValue(refusal(403, "email_unverified"));
665
- await renderPage();
666
- await requestDeletion();
667
- await waitFor(() =>
668
- expect(screen.getByRole("alert")).toHaveTextContent("Confirm your email address first, then ask again."),
669
- );
670
- expect(screen.getByTestId("account-deletion-request")).toBeEnabled();
671
- });
672
-
673
- it.each([
674
- [refusal(403, "email_unverified"), "unverified"],
675
- [refusal(404, "not_found"), "unavailable"],
676
- [refusal(503, "not_configured"), "unavailable"],
677
- [refusal(503, "delivery_failed"), "unavailable"],
678
- [refusal(500, "internal"), "failed"],
679
- ])("requestAccountDeletion maps %j to %s", async (answer, reason) => {
680
- mockRequestDeletion.mockResolvedValue(answer);
681
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason });
682
- expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
683
- });
684
-
685
- it("requestAccountDeletion answers who it went to, and a throw is failed", async () => {
686
- mockRequestDeletion.mockResolvedValue(ok({ sentTo: "hub-operator" }));
687
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: true, sentTo: "hub-operator" });
688
- mockRequestDeletion.mockRejectedValue(new TypeError("fetch failed"));
689
- await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason: "failed" });
690
- });
691
- });
635
+ // The deletion-request cases moved to privacy.test.tsx — Request account
636
+ // deletion now renders on Privacy, not General (#8438 D16).
692
637
 
693
638
  // ── 5. #billing (AC-6) ────────────────────────────────────────────────────────
694
639
 
@@ -248,13 +248,12 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
248
248
  mockMemberRead.mockResolvedValue({ ok: true, body: [] });
249
249
  });
250
250
 
251
- it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
251
+ it("renders the name and photo form, with no link to the hub's settings", async () => {
252
252
  await renderGeneral();
253
253
  expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
254
254
  expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
255
255
  // No hub read carries the photo, so the field starts empty (C5).
256
256
  expect(screen.getByLabelText("Photo")).toHaveValue("");
257
- expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
258
257
  expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
259
258
  expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
260
259
  expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
@@ -279,10 +278,32 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
279
278
  user: { name: "Ada King" },
280
279
  });
281
280
  });
281
+ });
282
282
 
283
- it("the deletion request goes through the member API and names the hub operator", async () => {
284
- mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
283
+ describe("a hub that answers no member route (the fixture's 404)", () => {
284
+ it("shows the name read-only and offers no save", async () => {
285
285
  await renderGeneral();
286
+ expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
287
+ expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
288
+ expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
289
+ expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
290
+ expect(mockUpdateUser).not.toHaveBeenCalled();
291
+ });
292
+ });
293
+
294
+ // #8438 D16 — Request account deletion moved to Privacy; the hub-mode
295
+ // deletion coverage above moves with it, expectations unchanged.
296
+ describe("Privacy's deletion block on a hub-mode spoke (moved from General, #8438 D16)", () => {
297
+ async function renderPrivacy(): Promise<void> {
298
+ const { default: PrivacyPage } = await import("../privacy/page");
299
+ render(await PrivacyPage({ params: Promise.resolve({ locale: "en" }) }));
300
+ }
301
+
302
+ it("a hub that answers the member routes: the deletion request goes through the member API and names the hub operator", async () => {
303
+ mockMemberRead.mockResolvedValue({ ok: true, body: [] });
304
+ mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
305
+ await renderPrivacy();
306
+ expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
286
307
  await act(async () => {
287
308
  fireEvent.click(screen.getByTestId("account-deletion-request"));
288
309
  });
@@ -290,19 +311,12 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
290
311
  await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
291
312
  expect(mockLeadsSubmit).not.toHaveBeenCalled();
292
313
  });
293
- });
294
314
 
295
- describe("a hub that answers no member route (the fixture's 404)", () => {
296
- it("shows the name read-only and offers no save and no deletion path", async () => {
297
- await renderGeneral();
298
- expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
299
- expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
300
- expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
315
+ it("a hub that answers no member route: offers no deletion path", async () => {
316
+ await renderPrivacy();
301
317
  expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
302
318
  expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
303
319
  expect(screen.getByText("This site can't take deletion requests right now.")).toBeInTheDocument();
304
- expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
305
- expect(mockUpdateUser).not.toHaveBeenCalled();
306
320
  });
307
321
  });
308
322
 
@@ -9,8 +9,12 @@
9
9
  * 2. An unknown kind, or an id the kind did not load, is a not-found —
10
10
  * never an empty frame.
11
11
  * 3. A failed read renders an error with Retry (D12's posture).
12
+ * 4. A message whose `action` is `onboarding` has an Open onboarding
13
+ * action to the locale's `/account/onboarding`; a message with no
14
+ * action has none (#8515 D6, AC-4).
12
15
  *
13
- * The `purchase` kind reads `member.purchases` (#8231 D5).
16
+ * The `purchase` kind reads `member.purchases` (#8231 D5); the `message`
17
+ * kind reads `member.messages`.
14
18
  */
15
19
  import { fireEvent, render, screen, within } from "@testing-library/react";
16
20
 
@@ -52,11 +56,15 @@ jest.mock("../../../../../auth", () => ({
52
56
 
53
57
  const mockSignupMe = jest.fn();
54
58
  const mockMemberPurchases = jest.fn();
59
+ const mockMemberMessages = jest.fn();
55
60
  jest.mock("@working-theory/api-client", () => ({
56
61
  __esModule: true,
57
62
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
58
63
  subscription: { me: jest.fn() },
59
- member: { purchases: (...args: unknown[]) => mockMemberPurchases(...args) },
64
+ member: {
65
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
66
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
67
+ },
60
68
  }));
61
69
 
62
70
  const mockNotFound = jest.fn(() => {
@@ -85,9 +93,30 @@ const PURCHASE = {
85
93
  purchasedAt: "2026-09-04T10:00:00.000Z",
86
94
  };
87
95
 
96
+ const MESSAGE = {
97
+ id: "int_plain",
98
+ subject: "Hello",
99
+ body: "A note.",
100
+ file: null,
101
+ action: null,
102
+ createdAt: "2026-10-14T09:00:00.000Z",
103
+ readAt: null,
104
+ };
105
+ const MESSAGES = [
106
+ MESSAGE,
107
+ {
108
+ ...MESSAGE,
109
+ id: "int_onboarding",
110
+ subject: "Your onboarding questions",
111
+ body: "Open this message in your account and choose Open onboarding.",
112
+ action: "onboarding",
113
+ },
114
+ ];
115
+
88
116
  beforeEach(() => {
89
117
  mockGetSession.mockReset().mockResolvedValue(SESSION);
90
118
  mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
119
+ mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: MESSAGES } });
91
120
  mockSignupMe.mockReset();
92
121
  mockNotFound.mockClear();
93
122
  mockRefresh.mockReset();
@@ -139,3 +168,29 @@ describe("one Inbox item", () => {
139
168
  expect(mockRefresh).toHaveBeenCalledTimes(1);
140
169
  });
141
170
  });
171
+
172
+ describe("a message's onboarding action (#8515 D6, AC-4)", () => {
173
+ it("an onboarding message links to the locale's onboarding page", async () => {
174
+ await renderItem("message", "int_onboarding", "fr");
175
+ const links = screen
176
+ .getAllByRole("link", { name: "Open onboarding" })
177
+ .map((link) => link.getAttribute("href"));
178
+ expect(links.length).toBeGreaterThan(0);
179
+ expect(links.every((href) => href === "/fr/account/onboarding")).toBe(true);
180
+ expect(screen.getByTestId("account-item-action-onboarding-desk")).toHaveAttribute(
181
+ "href",
182
+ "/fr/account/onboarding",
183
+ );
184
+ // The body stays plain text.
185
+ expect(
186
+ screen.getByText("Open this message in your account and choose Open onboarding.").closest("a"),
187
+ ).toBeNull();
188
+ });
189
+
190
+ it("a message with no action has no onboarding link", async () => {
191
+ await renderItem("message", "int_plain", "fr");
192
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello");
193
+ expect(screen.queryByRole("link", { name: "Open onboarding" })).not.toBeInTheDocument();
194
+ expect(screen.queryByTestId("account-item-action-onboarding-desk")).not.toBeInTheDocument();
195
+ });
196
+ });
@@ -472,6 +472,7 @@ describe("the settings routes take the screen (#8430)", () => {
472
472
  ["/account/general", "en"],
473
473
  ["/account/security", "en"],
474
474
  ["/account/notifications", "en"],
475
+ ["/account/privacy", "en"],
475
476
  ["/account/billing", "en"],
476
477
  ["/fr/account/billing", "fr"],
477
478
  ])("on %s the page renders alone — no rail, no Inbox, no AppShell frame", async (path, locale) => {
@@ -527,6 +528,7 @@ describe("one main landmark (#8441)", () => {
527
528
  general: async () => (await import("../general/page")).default,
528
529
  security: async () => (await import("../security/page")).default,
529
530
  notifications: async () => (await import("../notifications/page")).default,
531
+ privacy: async () => (await import("../privacy/page")).default,
530
532
  billing: async () => (await import("../billing/page")).default,
531
533
  };
532
534
 
@@ -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, within } 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,7 +134,7 @@ 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
 
@@ -116,25 +152,73 @@ describe("the standalone shape (#8430)", () => {
116
152
  });
117
153
  });
118
154
 
119
- describe("cookie settings (D7, moved from general.test.tsx)", () => {
120
- it("links to /privacy#cookie-settings", async () => {
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.",
160
+ );
161
+ expect(screen.queryByTestId("account-cookie-settings")).not.toBeInTheDocument();
162
+ expect(screen.queryByTestId("account-notifications")).not.toBeInTheDocument();
163
+ });
164
+
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
+ );
121
176
  await renderPage();
122
- expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
123
- "href",
124
- "/privacy#cookie-settings",
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 })] }),
125
186
  );
187
+ await renderPage();
188
+ expect(screen.getByTestId("account-notifications-marketing_email-switch")).not.toBeChecked();
126
189
  });
127
190
 
128
- it("a non-default locale prefixes the link", async () => {
129
- await renderPage("fr");
130
- expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
131
- "href",
132
- "/fr/privacy#cookie-settings",
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",
133
213
  );
134
214
  });
215
+ });
135
216
 
136
- it("renders no deletion button", async () => {
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 });
137
220
  await renderPage();
138
- 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();
139
223
  });
140
224
  });