create-ailk 0.18.0 → 0.19.1

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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  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/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -9,19 +9,38 @@
9
9
  * suites (spec 7894 D9), so it runs in this monorepo only.
10
10
  *
11
11
  * Covers:
12
- * - AC-7: with the hub answering 404 to the base reads (the hub credential
13
- * is admitted on none of them, D6), `loadInboxItems` loads `[]` for
14
- * `result` and `purchase` with `failed` false — an empty Inbox, never an
15
- * error row — and every read carries the Bearer credential, not a cookie.
16
- * - AC-5: the settings page renders no `NameForm` and no deletion request,
17
- * and links to `<AILK_HUB_SIGN_IN_URL>/account/settings` instead; Billing
18
- * is absent (its read answers 404).
12
+ * - AC-7: with the hub answering 404 to every read (the hub credential
13
+ * is admitted on none of them, D6), `loadInboxItems` loads `[]` with
14
+ * `failed` false — an empty Inbox, never an error row — and every read
15
+ * carries the Bearer credential, not a cookie. The purchase and
16
+ * appointment kinds read `member.purchases` and `member.appointments`,
17
+ * never `flowCheckouts.me` (#8231 D5–D7).
18
+ * - AC-5, as #8232 rewrites it (spec
19
+ * docs/specs/8232-member-settings-account-general-and-spec.md D2–D7, D9;
20
+ * its AC-2 and AC-7): General no longer links to the hub's settings.
21
+ * Where the hub answers the member routes, the name, photo and deletion
22
+ * go through the member API with the Bearer credential, and a save
23
+ * re-seals the session cookie; where it answers none (the fixture's 404),
24
+ * the name is read-only and no deletion path is offered. Billing says
25
+ * there is no subscription without reading `subscription.me`.
26
+ * - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
27
+ * D10–D12): Billing reads the seam's `loadSpokeBilling` — the plan, the
28
+ * card and Manage billing for a subscription; the plain none line with
29
+ * no button for none or a spoke without the action (`null`); the
30
+ * unreadable line; the error row for a failed read — and never calls
31
+ * `subscription.me` or `member.billing` itself. `openBillingPortal`
32
+ * redirects to the loader's portal URL, and to nothing without one.
33
+ * - #8431 AC-4 (spec docs/specs/8431-account-security-tab-human-sign-spec.md
34
+ * D1–D3): Security says the hub manages sign-in and links to the hub's
35
+ * own Security page, offers no password control and never reads the
36
+ * seam's accounts; Log out POSTs the spoke's sign-out endpoint, never
37
+ * Better Auth, and lands on `/account` only when it succeeded.
19
38
  */
20
39
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
40
  import { tmpdir } from "node:os";
22
41
  import { join } from "node:path";
23
42
 
24
- import { render, screen } from "@testing-library/react";
43
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
25
44
 
26
45
  jest.mock("next-intl/server", () => {
27
46
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -35,22 +54,31 @@ jest.mock("next-intl/server", () => {
35
54
  getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
36
55
  const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
37
56
  const messages = dig(msgs, ns);
38
- return (key: string): string => {
57
+ return (key: string, values?: Record<string, unknown>): string => {
39
58
  const value = dig(messages, key);
40
- return typeof value === "string" ? value : `${ns}.${key}`;
59
+ if (typeof value !== "string") return `${ns}.${key}`;
60
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
61
+ values && name in values ? String(values[name]) : m,
62
+ );
41
63
  };
42
64
  },
43
65
  };
44
66
  });
45
67
 
46
68
  const mockHeaders = jest.fn();
69
+ const mockCookieSet = jest.fn();
47
70
  jest.mock("next/headers", () => ({
48
71
  __esModule: true,
49
72
  headers: (...args: unknown[]) => mockHeaders(...args),
50
- cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
73
+ cookies: jest.fn().mockImplementation(async () => ({
74
+ toString: () => "",
75
+ set: (...args: unknown[]) => mockCookieSet(...args),
76
+ })),
51
77
  }));
52
78
 
53
79
  const mockReadSession = jest.fn();
80
+ const mockLoadSpokeBilling = jest.fn();
81
+ const mockReadOwnAccounts = jest.fn();
54
82
  // eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
55
83
  jest.mock("../../../../../lib/topology.generated", () => ({
56
84
  __esModule: true,
@@ -62,6 +90,10 @@ jest.mock("../../../../../lib/topology.generated", () => ({
62
90
  SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
63
91
  readSession: (...args: unknown[]) => mockReadSession(...args),
64
92
  registerSiteStores: () => Promise.resolve(),
93
+ // #8217 D7 — the billing render's loader; `null` is a spoke without the action.
94
+ loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
95
+ // #8431 D2a — a spoke has no local accounts; Security must never ask.
96
+ readOwnAccounts: (...args: unknown[]) => mockReadOwnAccounts(...args),
65
97
  }));
66
98
 
67
99
  const mockLeadsSubmit = jest.fn();
@@ -70,40 +102,61 @@ const mockWaitlistMe = jest.fn();
70
102
  const mockFlowCheckoutsMe = jest.fn();
71
103
  const mockSubscriptionMe = jest.fn();
72
104
  const mockMemberRead = jest.fn();
105
+ const mockMemberPurchases = jest.fn();
106
+ const mockMemberAppointments = jest.fn();
107
+ const mockUpdateProfile = jest.fn();
108
+ const mockRequestDeletion = jest.fn();
109
+ const mockHubMe = jest.fn();
110
+ const mockMemberBilling = jest.fn();
111
+ const mockPortalSession = jest.fn();
73
112
  jest.mock("@working-theory/api-client", () => ({
74
113
  __esModule: true,
75
114
  waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
76
115
  flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
77
116
  subscription: {
78
117
  me: (...args: unknown[]) => mockSubscriptionMe(...args),
79
- portalSession: jest.fn(),
118
+ portalSession: (...args: unknown[]) => mockPortalSession(...args),
80
119
  },
81
120
  leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
82
121
  // #7920 — the member kinds read the hub with the credential; 404 here.
83
122
  member: {
84
123
  pieces: (...args: unknown[]) => mockMemberRead(...args),
85
124
  messages: (...args: unknown[]) => mockMemberRead(...args),
125
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
126
+ appointments: (...args: unknown[]) => mockMemberAppointments(...args),
127
+ updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
128
+ requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
129
+ // #8217 — only the rendered billing action reads it; billing-source.ts never does.
130
+ billing: (...args: unknown[]) => mockMemberBilling(...args),
86
131
  },
87
132
  visitorForwardHeaders: () => null,
88
- hubSignIn: { me: jest.fn() },
133
+ hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
89
134
  }));
90
135
 
136
+ const mockUpdateUser = jest.fn();
137
+ const mockSignOut = jest.fn();
91
138
  jest.mock("better-auth/react", () => ({
92
139
  __esModule: true,
93
- createAuthClient: () => ({ signOut: jest.fn(), updateUser: jest.fn() }),
140
+ createAuthClient: () => ({
141
+ signOut: (...args: unknown[]) => mockSignOut(...args),
142
+ updateUser: (...args: unknown[]) => mockUpdateUser(...args),
143
+ }),
94
144
  }));
95
145
 
146
+ const mockRedirect = jest.fn();
147
+ const mockPush = jest.fn();
96
148
  jest.mock("next/navigation", () => ({
97
149
  __esModule: true,
98
- useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
99
- usePathname: () => "/account/settings",
100
- redirect: jest.fn(),
150
+ useRouter: () => ({ push: mockPush, refresh: jest.fn(), replace: jest.fn() }),
151
+ usePathname: () => "/account/general",
152
+ redirect: (...args: unknown[]) => mockRedirect(...args),
101
153
  }));
102
154
 
103
- import { seal } from "../../../../../lib/hub-session/seal";
155
+ import { seal, unseal } from "../../../../../lib/hub-session/seal";
104
156
  import { accountSession, loadInboxItems } from "../account-data";
157
+ import { openBillingPortal } from "../actions";
158
+ import GeneralPage from "../general/page";
105
159
  import { submitSupportRequest } from "../support-actions";
106
- import Page from "../settings/page";
107
160
 
108
161
  const HUB = "https://hub.example.com";
109
162
  const SESSION_SECRET = "s".repeat(43);
@@ -142,6 +195,20 @@ beforeEach(() => {
142
195
  mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
143
196
  mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
144
197
  mockMemberRead.mockReset().mockResolvedValue(notFound);
198
+ mockMemberPurchases.mockReset().mockResolvedValue(notFound);
199
+ mockMemberAppointments.mockReset().mockResolvedValue(notFound);
200
+ mockUpdateProfile.mockReset();
201
+ mockRequestDeletion.mockReset();
202
+ mockHubMe.mockReset();
203
+ mockLoadSpokeBilling.mockReset().mockResolvedValue(null);
204
+ mockMemberBilling.mockReset();
205
+ mockPortalSession.mockReset();
206
+ mockRedirect.mockReset();
207
+ mockCookieSet.mockReset();
208
+ mockUpdateUser.mockReset();
209
+ mockReadOwnAccounts.mockReset().mockResolvedValue(null);
210
+ mockSignOut.mockReset();
211
+ mockPush.mockReset();
145
212
  });
146
213
 
147
214
  afterEach(() => {
@@ -150,16 +217,17 @@ afterEach(() => {
150
217
  });
151
218
 
152
219
  describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
153
- it("loads [] for result and purchase, with failed false, when the hub answers 404", async () => {
220
+ it("loads [] for every kind, with failed false, when the hub answers 404", async () => {
154
221
  const session = await accountSession("en");
155
222
  if (session === null) throw new Error("expected a session");
156
223
  expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
157
224
 
158
225
  const inbox = await loadInboxItems(session.ctx);
159
226
  expect(inbox).toEqual({ items: [], failed: false });
160
- expect(mockFlowCheckoutsMe).toHaveBeenCalledWith({
161
- headers: { authorization: `Bearer ${CREDENTIAL}` },
162
- });
227
+ const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
228
+ expect(mockMemberPurchases).toHaveBeenCalledWith(bearer);
229
+ expect(mockMemberAppointments).toHaveBeenCalledWith(bearer);
230
+ expect(mockFlowCheckoutsMe).not.toHaveBeenCalled();
163
231
  });
164
232
 
165
233
  it("has no session when the seam reads none", async () => {
@@ -168,23 +236,77 @@ describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
168
236
  });
169
237
  });
170
238
 
171
- describe("AC-5 — the settings page on a hub-mode spoke", () => {
172
- async function renderPage(): Promise<void> {
173
- render(await Page({ params: Promise.resolve({ locale: "en" }) }));
239
+ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
240
+ const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
241
+
242
+ async function renderGeneral(): Promise<void> {
243
+ render(await GeneralPage({ params: Promise.resolve({ locale: "en" }) }));
174
244
  }
175
245
 
176
- it("renders no NameForm and no deletion request, and links to the hub's account settings", async () => {
177
- await renderPage();
178
- expect(screen.queryByTestId("account-name-form")).not.toBeInTheDocument();
179
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
180
- const href = `${HUB}/account/settings`;
181
- expect(screen.getByTestId("account-settings-manage-on-hub")).toHaveAttribute("href", href);
182
- expect(screen.getByTestId("account-privacy-manage-on-hub")).toHaveAttribute("href", href);
183
- expect(screen.getByTestId("account-settings-name")).toHaveTextContent("Ada Lovelace");
184
- expect(screen.getByText(/You sign in through our main site/)).toBeInTheDocument();
246
+ describe("a hub that answers the member routes", () => {
247
+ beforeEach(() => {
248
+ mockMemberRead.mockResolvedValue({ ok: true, body: [] });
249
+ });
250
+
251
+ it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
252
+ await renderGeneral();
253
+ expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
254
+ expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
255
+ // No hub read carries the photo, so the field starts empty (C5).
256
+ expect(screen.getByLabelText("Photo")).toHaveValue("");
257
+ expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
258
+ expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
259
+ expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
260
+ expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
261
+ });
262
+
263
+ it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
264
+ mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
265
+ mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
266
+ await renderGeneral();
267
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
268
+ await act(async () => {
269
+ fireEvent.click(screen.getByTestId("account-general-save"));
270
+ });
271
+ expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada King" }, bearer);
272
+ expect(mockUpdateUser).not.toHaveBeenCalled();
273
+ expect(mockHubMe).toHaveBeenCalledWith(bearer);
274
+ expect(mockCookieSet).toHaveBeenCalledTimes(1);
275
+ const [name, value] = mockCookieSet.mock.calls[0] as [string, string];
276
+ expect(name).toBe("__Host-ailk-hub-session");
277
+ expect(unseal(value, SESSION_SECRET, "session", name)).toMatchObject({
278
+ accessToken: CREDENTIAL,
279
+ user: { name: "Ada King" },
280
+ });
281
+ });
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" } });
285
+ await renderGeneral();
286
+ await act(async () => {
287
+ fireEvent.click(screen.getByTestId("account-deletion-request"));
288
+ });
289
+ expect(mockRequestDeletion).toHaveBeenCalledWith({}, bearer);
290
+ await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
291
+ expect(mockLeadsSubmit).not.toHaveBeenCalled();
292
+ });
293
+ });
294
+
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();
301
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
302
+ expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
303
+ 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
+ });
185
307
  });
186
308
 
187
- it("files a posted deletion request as a support request — the hub owns the account (SEC-7894-11)", async () => {
309
+ it("files a posted deletion request as a support request — Support files support only (SEC-7894-11)", async () => {
188
310
  mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
189
311
  const form = new FormData();
190
312
  form.set("kind", "deletion");
@@ -196,11 +318,194 @@ describe("AC-5 — the settings page on a hub-mode spoke", () => {
196
318
  );
197
319
  });
198
320
 
199
- it("omits Billing, whose read answers 404 for the hub credential", async () => {
200
- await renderPage();
201
- expect(document.getElementById("billing")).not.toBeInTheDocument();
202
- expect(mockSubscriptionMe).toHaveBeenCalledWith({
203
- headers: { authorization: `Bearer ${CREDENTIAL}` },
321
+ it("Billing says there is no subscription, with no button, without reading subscription.me", async () => {
322
+ // The Billing route's own page, loaded with `import()`: architecture.yaml bans
323
+ // a static import of a relative billing/ path to keep OSS code out of the
324
+ // apps-paid/billing service's source, and this route directory shares the name.
325
+ const { default: BillingPage } = await import("../billing/page");
326
+ render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
327
+ expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
328
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
329
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
330
+ });
331
+ });
332
+
333
+ describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
334
+ const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
335
+ const mockFetch = jest.fn();
336
+
337
+ beforeEach(() => {
338
+ mockFetch.mockReset();
339
+ Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
340
+ });
341
+
342
+ afterEach(() => {
343
+ if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
344
+ else Reflect.deleteProperty(globalThis, "fetch");
345
+ });
346
+
347
+ async function renderSecurity(): Promise<void> {
348
+ const { default: SecurityPage } = await import("../security/page");
349
+ render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
350
+ }
351
+
352
+ it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
353
+ await renderSecurity();
354
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
355
+ "Your sign-in is managed at hub.example.com.",
356
+ );
357
+ const links = screen.getAllByTestId("account-security-hub-link");
358
+ expect(links).toHaveLength(1);
359
+ expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
360
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
361
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
362
+ expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
363
+ expect(screen.queryByText(/other devices/i)).toBeNull();
364
+ expect(mockReadOwnAccounts).not.toHaveBeenCalled();
365
+ });
366
+
367
+ it("with the hub config absent says 'our main site' and links nowhere", async () => {
368
+ delete process.env.AILK_HUB_CLIENT_SECRET;
369
+ await renderSecurity();
370
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
371
+ "Your sign-in is managed by our main site.",
372
+ );
373
+ expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
374
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
375
+ });
376
+
377
+ it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
378
+ mockFetch.mockResolvedValue({ ok: true, status: 204 });
379
+ await renderSecurity();
380
+ await act(async () => {
381
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
382
+ });
383
+ expect(mockFetch).toHaveBeenCalledTimes(1);
384
+ expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
385
+ method: "POST",
386
+ credentials: "same-origin",
204
387
  });
388
+ expect(mockSignOut).not.toHaveBeenCalled();
389
+ expect(mockPush).toHaveBeenCalledWith("/account");
390
+ });
391
+
392
+ it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
393
+ mockFetch.mockResolvedValue({ ok: false, status: 403 });
394
+ await renderSecurity();
395
+ await act(async () => {
396
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
397
+ });
398
+ expect(mockPush).not.toHaveBeenCalled();
399
+ expect(mockSignOut).not.toHaveBeenCalled();
400
+ expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
401
+ });
402
+ });
403
+
404
+ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
405
+ const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
406
+ const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
407
+
408
+ /** A `SpokeBilling` stub: what the rendered action returns. */
409
+ function stub(read: unknown, portalUrl: string | null = PORTAL_URL) {
410
+ return { read: jest.fn().mockResolvedValue(read), portalUrl: jest.fn().mockResolvedValue(portalUrl) };
411
+ }
412
+
413
+ async function renderBilling(): Promise<void> {
414
+ // The Billing route's own page, loaded with `import()` — see the AC-5 case above.
415
+ const { default: BillingPage } = await import("../billing/page");
416
+ render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
417
+ }
418
+
419
+ afterEach(() => {
420
+ // Every case: billing-source.ts reads neither the hub's subscription nor its billing ids.
421
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
422
+ expect(mockMemberBilling).not.toHaveBeenCalled();
423
+ });
424
+
425
+ it("a subscription renders the plan, the card and Manage billing", async () => {
426
+ mockLoadSpokeBilling.mockResolvedValue(
427
+ stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
428
+ );
429
+ await renderBilling();
430
+ const card = screen.getByTestId("account-billing-card");
431
+ expect(card).toHaveTextContent("Pro");
432
+ expect(card).toHaveTextContent("Active");
433
+ expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
434
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
435
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
436
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
437
+ });
438
+
439
+ it("none renders the plain none line and no button", async () => {
440
+ mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null));
441
+ await renderBilling();
442
+ expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
443
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
444
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
445
+ });
446
+
447
+ it.each([
448
+ ["none", { kind: "none", unreadable: true }, "account-billing-card"],
449
+ [
450
+ "a subscription",
451
+ { kind: "subscription", status: "past_due", planName: null, paymentMethod: null, unreadable: true },
452
+ "account-billing-card",
453
+ ],
454
+ ])("unreadable: true on %s adds the unreadable line after it", async (_label, read, baseTestId) => {
455
+ mockLoadSpokeBilling.mockResolvedValue(stub(read));
456
+ await renderBilling();
457
+ const line = screen.getByTestId("account-billing-unreadable");
458
+ expect(line).toHaveTextContent("Some of your billing is with another Stripe account, so it isn't shown here.");
459
+ const base = screen.getByTestId(baseTestId);
460
+ expect(base.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
461
+ });
462
+
463
+ it("a failed read renders the error row, never 'no subscription'", async () => {
464
+ mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "failed" }));
465
+ await renderBilling();
466
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
467
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
468
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
469
+ });
470
+
471
+ it("a loader that fails to load renders the error row", async () => {
472
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
473
+ mockLoadSpokeBilling.mockRejectedValue(new SyntaxError("checkout-config.ts is malformed"));
474
+ await renderBilling();
475
+ expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
476
+ expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
477
+ "[account] read failed source=spoke-billing errName=SyntaxError",
478
+ ]);
479
+ errorSpy.mockRestore();
480
+ });
481
+
482
+ it("a spoke without the action (the loader resolves null) renders none and no button", async () => {
483
+ await renderBilling();
484
+ expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
485
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
486
+ expect(mockLoadSpokeBilling).toHaveBeenCalledTimes(1);
487
+ });
488
+
489
+ it("openBillingPortal redirects to the loader's portal URL", async () => {
490
+ const billing = stub({ kind: "none", unreadable: false });
491
+ mockLoadSpokeBilling.mockResolvedValue(billing);
492
+ await openBillingPortal();
493
+ expect(billing.portalUrl).toHaveBeenCalledWith();
494
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
495
+ expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
496
+ expect(mockPortalSession).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it.each([
500
+ ["the loader's portal is null", () => mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null))],
501
+ ["there is no loader", () => mockLoadSpokeBilling.mockResolvedValue(null)],
502
+ ["the loader fails to load", () => mockLoadSpokeBilling.mockRejectedValue(new Error("load failed"))],
503
+ ])("openBillingPortal redirects nowhere when %s", async (_label, arrange) => {
504
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
505
+ arrange();
506
+ await openBillingPortal();
507
+ expect(mockRedirect).not.toHaveBeenCalled();
508
+ expect(mockPortalSession).not.toHaveBeenCalled();
509
+ errorSpy.mockRestore();
205
510
  });
206
511
  });
@@ -9,6 +9,8 @@
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
+ *
13
+ * The `purchase` kind reads `member.purchases` (#8231 D5).
12
14
  */
13
15
  import { fireEvent, render, screen, within } from "@testing-library/react";
14
16
 
@@ -49,12 +51,12 @@ jest.mock("../../../../../auth", () => ({
49
51
  }));
50
52
 
51
53
  const mockSignupMe = jest.fn();
52
- const mockCheckoutsMe = jest.fn();
54
+ const mockMemberPurchases = jest.fn();
53
55
  jest.mock("@working-theory/api-client", () => ({
54
56
  __esModule: true,
55
57
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
56
- flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
57
58
  subscription: { me: jest.fn() },
59
+ member: { purchases: (...args: unknown[]) => mockMemberPurchases(...args) },
58
60
  }));
59
61
 
60
62
  const mockNotFound = jest.fn(() => {
@@ -74,22 +76,18 @@ const SESSION = {
74
76
  user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
75
77
  };
76
78
 
77
- const CHECKOUT = {
79
+ const PURCHASE = {
78
80
  id: "fc_done",
79
- flowSlug: "founder-flow",
80
- experimentKey: "ailk-founder-diagnostic",
81
- offerKey: "pro",
82
- status: "completed",
81
+ offering: "pro",
83
82
  amountMinor: 4900,
84
83
  currency: "usd",
85
84
  period: "monthly",
86
- completedAt: "2026-09-04T10:00:00.000Z",
87
- createdAt: "2026-09-04T09:58:00.000Z",
85
+ purchasedAt: "2026-09-04T10:00:00.000Z",
88
86
  };
89
87
 
90
88
  beforeEach(() => {
91
89
  mockGetSession.mockReset().mockResolvedValue(SESSION);
92
- mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
90
+ mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
93
91
  mockSignupMe.mockReset();
94
92
  mockNotFound.mockClear();
95
93
  mockRefresh.mockReset();
@@ -113,7 +111,7 @@ describe("one Inbox item", () => {
113
111
  });
114
112
 
115
113
  it("decodes an encoded id segment", async () => {
116
- mockCheckoutsMe.mockResolvedValue({ ok: true, body: [{ ...CHECKOUT, id: "fc done" }] });
114
+ mockMemberPurchases.mockResolvedValue({ ok: true, body: { items: [{ ...PURCHASE, id: "fc done" }] } });
117
115
  await renderItem("purchase", "fc%20done");
118
116
  expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("pro");
119
117
  });
@@ -125,7 +123,7 @@ describe("one Inbox item", () => {
125
123
 
126
124
  it("an unknown kind is a not-found", async () => {
127
125
  await expect(renderItem("nope", "fc_done")).rejects.toThrow("NEXT_NOT_FOUND");
128
- expect(mockCheckoutsMe).not.toHaveBeenCalled();
126
+ expect(mockMemberPurchases).not.toHaveBeenCalled();
129
127
  });
130
128
 
131
129
  it("an id the kind did not load is a not-found", async () => {
@@ -133,7 +131,7 @@ describe("one Inbox item", () => {
133
131
  });
134
132
 
135
133
  it("a failed read renders an error with Retry, not a not-found", async () => {
136
- mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
134
+ mockMemberPurchases.mockResolvedValue({ ok: false, status: 500, body: {} });
137
135
  await renderItem("purchase", "fc_done");
138
136
  expect(mockNotFound).not.toHaveBeenCalled();
139
137
  expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load this item.");