create-ailk 0.17.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 (193) 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/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. package/templates/database/__tests__/build.test.ts +0 -131
@@ -19,6 +19,11 @@
19
19
  * Issue #7396 — `mcpEndpoint` is the configured MCP HTTP host or absent. It
20
20
  * was `${baseUrl}/api/mcp`, a path apps/web does not serve, so every agent
21
21
  * that followed it got a 404.
22
+ * Issue #8070 — `unservedTools` drops the tools this topology's MCP server
23
+ * does not register (`create_project` on a spoke).
24
+ * Issue #8057 — `description` resolves from SITE_DESCRIPTION
25
+ * (`siteIdentity.description` in _site.mdx → neutral default) instead of the
26
+ * template's own blurb on every site.
22
27
  */
23
28
 
24
29
  import {
@@ -27,7 +32,7 @@ import {
27
32
  type McpToolCatalogEntry,
28
33
  } from "@working-theory/metadata";
29
34
 
30
- import { SITE_NAME } from "../../../lib/site-brand";
35
+ import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
31
36
 
32
37
  /**
33
38
  * Inline transport descriptor shape — mirrors McpTransportDescriptor from
@@ -107,6 +112,14 @@ export interface BuildMcpManifestOptions {
107
112
  /** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
108
113
  * passes it. */
109
114
  featureGate?: McpCatalogOptions["featureGate"];
115
+ /**
116
+ * Tool names this site's MCP server does not serve (#8070 D7) — the
117
+ * topology seam's `UNSERVED_MCP_TOOLS`: `create_project` on a spoke, none
118
+ * on a hub. Dropped from `tools`, so the manifest never advertises a tool
119
+ * the server will not register. Injected, like `commerceClaimed`. Omitted ⇒
120
+ * none dropped.
121
+ */
122
+ unservedTools?: readonly string[];
110
123
  }
111
124
 
112
125
  /** MCP HTTP transport version this server implements. */
@@ -132,8 +145,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
132
145
  return {
133
146
  schemaVersion: "1",
134
147
  name: SITE_NAME,
135
- description:
136
- "AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
148
+ description: SITE_DESCRIPTION,
137
149
  // Spread conditional to satisfy exactOptionalPropertyTypes: the key is
138
150
  // absent, not undefined, when no MCP HTTP host is configured.
139
151
  ...(opts.mcpHttpUrl ? { mcpEndpoint: opts.mcpHttpUrl } : {}),
@@ -141,7 +153,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
141
153
  addons: { commerce: opts.commerceClaimed === true },
142
154
  features: opts.heldFeatures ?? [],
143
155
  ...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
144
- }),
156
+ }).filter((tool) => !(opts.unservedTools ?? []).includes(tool.name)),
145
157
  transport: {
146
158
  type: "http",
147
159
  version: MCP_HTTP_VERSION,
@@ -26,6 +26,7 @@
26
26
  * is appropriate per the MCP discovery spec.
27
27
  */
28
28
 
29
+ import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
29
30
  import packageJson from "../../../package.json";
30
31
  import {
31
32
  COMMERCE_FEATURE,
@@ -59,6 +60,8 @@ export async function GET(): Promise<Response> {
59
60
  ailkVersion: packageJson.version,
60
61
  commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
61
62
  heldFeatures,
63
+ // The tools this topology's MCP server does not register (#8070 D7).
64
+ unservedTools: UNSERVED_MCP_TOOLS,
62
65
  // Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
63
66
  // entirely when the env var is not set rather than passing undefined.
64
67
  ...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
@@ -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
+ });