create-ailk 0.18.0 → 0.19.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 (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  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/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Tests for /[locale]/account/billing — User settings › Billing (#8232, spec
3
+ * docs/specs/8232-member-settings-account-general-and-spec.md D1, D9; AC-2).
4
+ * The single-site render: `subscription.me` is the mocked boundary.
5
+ *
6
+ * Covers:
7
+ * (a) a subscription shows the plan, the status, the card and Manage in
8
+ * Stripe; (b) no Manage without a portal; (c) `status: null` and each
9
+ * of 401, 403 and 404 say plainly there is no subscription, with no
10
+ * button; (d) a 500 or a throw shows the error row, never "no
11
+ * subscription"; (e) a contributed billing section renders after every
12
+ * one of these; (f) the read asks for `include: "billing"`.
13
+ * #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
14
+ * D10, D11): `openBillingPortal()` mints the portal through
15
+ * `billingPortalUrl()` — the hub's route, once, with the cookie init — and
16
+ * redirects to it; a non-2xx or a throw redirects nowhere. The single site
17
+ * never shows the unreadable line.
18
+ */
19
+ import { render, screen, within } from "@testing-library/react";
20
+
21
+ jest.mock("next-intl/server", () => {
22
+ const msgs: unknown = require("../../../../../messages/en.json");
23
+ const dig = (obj: unknown, path: string): unknown =>
24
+ path.split(".").reduce<unknown>(
25
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
26
+ obj,
27
+ );
28
+ return {
29
+ __esModule: true,
30
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
31
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
32
+ const messages = dig(msgs, ns);
33
+ return (key: string, values?: Record<string, unknown>): string => {
34
+ const value = dig(messages, key);
35
+ if (typeof value !== "string") return `${ns}.${key}`;
36
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
37
+ values && name in values ? String(values[name]) : m,
38
+ );
39
+ };
40
+ },
41
+ };
42
+ });
43
+
44
+ jest.mock("next/headers", () => ({
45
+ __esModule: true,
46
+ headers: jest.fn().mockResolvedValue(new Headers()),
47
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
48
+ }));
49
+
50
+ const mockGetSession = jest.fn();
51
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
52
+ jest.mock("../../../../../auth", () => ({
53
+ __esModule: true,
54
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
55
+ }));
56
+
57
+ const mockSubscriptionMe = jest.fn();
58
+ const mockPortalSession = jest.fn();
59
+ jest.mock("@working-theory/api-client", () => ({
60
+ __esModule: true,
61
+ waitlistSignups: { me: jest.fn() },
62
+ flowCheckouts: { me: jest.fn() },
63
+ subscription: {
64
+ me: (...args: unknown[]) => mockSubscriptionMe(...args),
65
+ portalSession: (...args: unknown[]) => mockPortalSession(...args),
66
+ },
67
+ leads: { submit: jest.fn() },
68
+ member: {},
69
+ hubSignIn: { me: jest.fn() },
70
+ visitorForwardHeaders: () => null,
71
+ }));
72
+
73
+ const mockRedirect = jest.fn();
74
+ jest.mock("next/navigation", () => ({
75
+ __esModule: true,
76
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
77
+ usePathname: () => "/account/billing",
78
+ redirect: (...args: unknown[]) => mockRedirect(...args),
79
+ }));
80
+
81
+ import type { AccountBillingSection } from "@working-theory/templates/account";
82
+
83
+ import { accountBillingSections } from "../../../../../lib/account-config";
84
+ import { openBillingPortal } from "../actions";
85
+
86
+ // The Billing route's own page, loaded with `import()`: architecture.yaml bans
87
+ // a static import of a relative billing/ path to keep OSS code out of the
88
+ // apps-paid/billing service's source, and this route directory shares the name.
89
+ const loadPage = async () => (await import("../billing/page")).default;
90
+
91
+ const SESSION = {
92
+ user: {
93
+ id: "user_1",
94
+ name: "Ada Lovelace",
95
+ email: "ada@example.com",
96
+ emailVerified: true,
97
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
98
+ },
99
+ };
100
+
101
+ const ACTIVE = {
102
+ actionRequired: false,
103
+ status: "active",
104
+ accessRevokesAt: null,
105
+ portalAvailable: true,
106
+ planName: "Pro",
107
+ paymentMethod: { brand: "visa", last4: "4242", expMonth: 4, expYear: 2027 },
108
+ };
109
+
110
+ const answer = (body: Record<string, unknown>) => ({ ok: true, body: { ...ACTIVE, ...body } });
111
+
112
+ beforeEach(() => {
113
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
114
+ mockSubscriptionMe.mockReset().mockResolvedValue(answer({}));
115
+ mockPortalSession.mockReset();
116
+ mockRedirect.mockReset();
117
+ });
118
+
119
+ async function renderPage(locale = "en"): Promise<void> {
120
+ const Page = await loadPage();
121
+ render(await Page({ params: Promise.resolve({ locale }) }));
122
+ }
123
+
124
+ describe("the frame (D7)", () => {
125
+ it("renders in the settings frame with Billing current", async () => {
126
+ await renderPage();
127
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Billing");
128
+ expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
129
+ expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("aria-current", "page");
130
+ expect(screen.getByTestId("account-settings-rail-general")).not.toHaveAttribute("aria-current");
131
+ });
132
+ });
133
+
134
+ describe("AC-5 — Billing as the current-plan card (D8)", () => {
135
+ it("(a) shows the plan, the status, the card and Manage in Stripe under the Current plan heading", async () => {
136
+ await renderPage();
137
+ expect(screen.getByText("Current plan")).toBeInTheDocument();
138
+ const card = screen.getByTestId("account-billing-card");
139
+ expect(within(card).getByTestId("account-billing-plan")).toHaveTextContent("Pro");
140
+ expect(within(card).getByTestId("account-billing-status")).toHaveTextContent("Active");
141
+ expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
142
+ "visa ending in 4242, expires 04/2027",
143
+ );
144
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
145
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
146
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
147
+ });
148
+
149
+ it("(a) no plan name reads as 'Your subscription', and no card as 'No card on file'", async () => {
150
+ mockSubscriptionMe.mockResolvedValue(
151
+ answer({ status: "past_due", planName: null, paymentMethod: null }),
152
+ );
153
+ await renderPage();
154
+ expect(screen.getByTestId("account-billing-plan")).toHaveTextContent("Your subscription");
155
+ expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Payment due");
156
+ expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
157
+ });
158
+
159
+ it("(a) an older API without the billing fields still renders the status", async () => {
160
+ mockSubscriptionMe.mockResolvedValue({
161
+ ok: true,
162
+ body: { actionRequired: false, status: "trialing", accessRevokesAt: null, portalAvailable: false },
163
+ });
164
+ await renderPage();
165
+ expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Trial");
166
+ expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
167
+ });
168
+
169
+ it("(a) appends when access ends, when set", async () => {
170
+ mockSubscriptionMe.mockResolvedValue(
171
+ answer({ status: "canceled", accessRevokesAt: "2026-10-20T00:00:00.000Z" }),
172
+ );
173
+ await renderPage();
174
+ const status = screen.getByTestId("account-billing-status");
175
+ expect(status).toHaveTextContent("Canceled");
176
+ expect(status).toHaveTextContent("Access ends");
177
+ });
178
+
179
+ it("(b) no portal, no Manage button", async () => {
180
+ mockSubscriptionMe.mockResolvedValue(answer({ portalAvailable: false }));
181
+ await renderPage();
182
+ expect(screen.getByTestId("account-billing-card")).toBeInTheDocument();
183
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
184
+ });
185
+
186
+ it("(c) status: null renders the same card shape with the plain line and no button — even with a portal", async () => {
187
+ mockSubscriptionMe.mockResolvedValue(answer({ status: null, portalAvailable: true }));
188
+ await renderPage();
189
+ expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
190
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
191
+ expect(screen.queryByTestId("account-billing-plan")).not.toBeInTheDocument();
192
+ expect(screen.queryByTestId("account-billing-status")).not.toBeInTheDocument();
193
+ expect(screen.queryByTestId("account-billing-payment")).not.toBeInTheDocument();
194
+ });
195
+
196
+ it.each([401, 403, 404])("(c) a %i says there is no subscription, with no button", async (status) => {
197
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status, body: {} });
198
+ await renderPage();
199
+ expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
200
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
201
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
202
+ });
203
+
204
+ it("(g) through D6, the member card renders the shared CurrentPlanCard — exactly one card", async () => {
205
+ await renderPage();
206
+ expect(screen.getAllByTestId("account-billing-card")).toHaveLength(1);
207
+ });
208
+
209
+ it("(d) a 500 shows the error row with Retry, not 'no subscription'", async () => {
210
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} });
211
+ await renderPage();
212
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
213
+ expect(screen.getByTestId("inbox-retry")).toBeInTheDocument();
214
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
215
+ });
216
+
217
+ it("(d) a throw shows the error row, not 'no subscription'", async () => {
218
+ mockSubscriptionMe.mockRejectedValue(new TypeError("fetch failed"));
219
+ await renderPage();
220
+ expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
221
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
222
+ });
223
+
224
+ it("(f) the read asks for the billing fields with the caller's cookie", async () => {
225
+ await renderPage();
226
+ expect(mockSubscriptionMe).toHaveBeenCalledWith(
227
+ { headers: { cookie: "session=abc" } },
228
+ { include: "billing" },
229
+ );
230
+ });
231
+ });
232
+
233
+ describe("AC-2 (e) — contributed billing sections render after the base content", () => {
234
+ const section: AccountBillingSection = {
235
+ id: "licence",
236
+ title: "settings.billing.title",
237
+ render: () => <p data-testid="licence-body">Licence</p>,
238
+ };
239
+
240
+ beforeAll(() => {
241
+ accountBillingSections.push(section);
242
+ });
243
+
244
+ afterAll(() => {
245
+ accountBillingSections.splice(accountBillingSections.indexOf(section), 1);
246
+ });
247
+
248
+ const states: [string, () => void, string][] = [
249
+ ["a subscription", () => undefined, "account-billing-card"],
250
+ ["no subscription", () => mockSubscriptionMe.mockResolvedValue(answer({ status: null })), "account-billing-card"],
251
+ ["a 404", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 404, body: {} }), "account-billing-card"],
252
+ ["a failed read", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} }), "inbox-error"],
253
+ ];
254
+
255
+ it.each(states)("after %s", async (_label, arrange, baseTestId) => {
256
+ arrange();
257
+ await renderPage();
258
+ const base = screen.getByTestId(baseTestId);
259
+ const contributed = screen.getByTestId("account-settings-licence");
260
+ expect(within(contributed).getByTestId("licence-body")).toBeInTheDocument();
261
+ expect(base.compareDocumentPosition(contributed) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
262
+ });
263
+ });
264
+
265
+ describe("#8217 AC-1 — the single site's Manage in Stripe (D10, D11)", () => {
266
+ const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
267
+
268
+ it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
269
+ mockPortalSession.mockResolvedValue({ ok: true, body: { url: PORTAL_URL } });
270
+ await openBillingPortal();
271
+ expect(mockPortalSession).toHaveBeenCalledTimes(1);
272
+ expect(mockPortalSession).toHaveBeenCalledWith({ headers: { cookie: "session=abc" } });
273
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
274
+ expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
275
+ });
276
+
277
+ it.each([
278
+ ["a non-2xx", () => mockPortalSession.mockResolvedValue({ ok: false, status: 404, body: {} }), "[openBillingPortal] status=404"],
279
+ ["a throw", () => mockPortalSession.mockRejectedValue(new TypeError("fetch failed")), "[openBillingPortal] errName=TypeError"],
280
+ ])("%s redirects nowhere and logs only a status or a name", async (_label, arrange, line) => {
281
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
282
+ arrange();
283
+ await openBillingPortal();
284
+ expect(mockRedirect).not.toHaveBeenCalled();
285
+ expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([line]);
286
+ errorSpy.mockRestore();
287
+ });
288
+ });