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
@@ -1,407 +1,92 @@
1
1
  /**
2
- * Tests for /[locale]/account/settings — User settings (#7430, spec 7423
3
- * U4 · D10, D10a; AC-8, plus the operator fold-ins of 2026-10-01).
2
+ * Tests for /[locale]/account/settings — now a redirect to User settings ›
3
+ * General (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
4
+ * D8, D15; AC-6).
4
5
  *
5
- * Covers:
6
- * 1. General, Security, Notifications and Privacy always render; no
7
- * Credentials, Scope, Organizations or Credits section exists.
8
- * 2. Billing renders for `subscription.me()` answering `status: "active"`
9
- * or `portalAvailable: true`; it is absent for `status: null` with
10
- * `portalAvailable: false`, and for a 404.
11
- * 3. General lets a member SET and edit their name (an accepted applicant
12
- * arrives with none, #7418) — saved through Better Auth's `updateUser`.
13
- * 4. The color-mode control renders only when the site's theme defines
14
- * more than one mode.
15
- * 5. Security lists the configured sign-in methods; a Password row only
16
- * when the password method is on.
17
- * 6. Privacy links to the cookie settings and files a deletion request.
6
+ * The one-page, anchored settings of #7430 split into `/account/general`
7
+ * and `/account/billing`; its General, Security, Notifications, Privacy and
8
+ * Billing cases moved to `general.test.tsx` and `billing.test.tsx`, rewritten
9
+ * for D2–D9. A `#billing` fragment never reaches the server, so the server
10
+ * redirect is to General and `BillingAnchor` forwards the fragment there
11
+ * (`general.test.tsx` § AC-6).
12
+ *
13
+ * #8441 — `SETTINGS_ROUTES` (settings-frame.tsx) is the one list of User
14
+ * settings' routes: the tabs read it, and so does `isSettingsPath`, the
15
+ * exact, locale-aware membership test the account frame takes the settings
16
+ * routes out of its shell with (layout.test.tsx renders each route).
18
17
  */
19
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
-
23
- import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
24
-
25
- jest.mock("next-intl/server", () => {
26
- const msgs: unknown = require("../../../../../messages/en.json");
27
- const dig = (obj: unknown, path: string): unknown =>
28
- path.split(".").reduce<unknown>(
29
- (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
30
- obj,
31
- );
32
- return {
33
- __esModule: true,
34
- getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
35
- const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
36
- const messages = dig(msgs, ns);
37
- return (key: string, values?: Record<string, unknown>): string => {
38
- const value = dig(messages, key);
39
- if (typeof value !== "string") return `${ns}.${key}`;
40
- return value.replace(/\{(\w+)\}/g, (m, name: string) =>
41
- values && name in values ? String(values[name]) : m,
42
- );
43
- };
44
- },
45
- };
46
- });
47
-
48
- jest.mock("next/headers", () => ({
49
- __esModule: true,
50
- headers: jest.fn().mockResolvedValue(new Headers()),
51
- cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
52
- }));
53
-
54
- const mockGetSession = jest.fn();
55
- // 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.
56
- jest.mock("../../../../../auth", () => ({
57
- __esModule: true,
58
- auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
59
- }));
60
-
61
- const mockSubscriptionMe = jest.fn();
62
- const mockLeadsSubmit = jest.fn();
63
- jest.mock("@working-theory/api-client", () => ({
64
- __esModule: true,
65
- waitlistSignups: { me: jest.fn() },
66
- flowCheckouts: { me: jest.fn() },
67
- subscription: {
68
- me: (...args: unknown[]) => mockSubscriptionMe(...args),
69
- portalSession: jest.fn(),
70
- },
71
- leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
72
- visitorForwardHeaders: () => null,
73
- }));
74
-
75
- const mockUpdateUser = jest.fn();
76
- jest.mock("better-auth/react", () => ({
77
- __esModule: true,
78
- createAuthClient: () => ({
79
- signOut: jest.fn(),
80
- updateUser: (...args: unknown[]) => mockUpdateUser(...args),
81
- }),
82
- }));
83
18
 
84
- const mockRefresh = jest.fn();
19
+ const mockRedirect = jest.fn();
85
20
  jest.mock("next/navigation", () => ({
86
21
  __esModule: true,
87
- useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
88
- usePathname: () => "/account/settings",
89
- redirect: jest.fn(),
22
+ redirect: (...args: unknown[]) => mockRedirect(...args),
90
23
  }));
91
24
 
92
- // The colour-mode cases test the theme's modes, so they run against an unpinned
93
- // chrome: a scaffold whose design pins light mode (minimalism, #7742 D2) would
94
- // otherwise hide the control in every case. theme-modes.test.ts covers the pin.
95
- // eslint-disable-next-line no-restricted-syntax -- #507: `site-chrome.ts` is a generated data module with no external boundary to mock instead (as theme-modes.test.ts).
96
- jest.mock("../../../../../lib/site-chrome", () => {
97
- const actual = jest.requireActual("../../../../../lib/site-chrome");
98
- const { colorMode, ...unpinned } = actual.siteChrome;
99
- return { ...actual, siteChrome: unpinned };
100
- });
101
-
102
25
  import Page from "../settings/page";
103
-
104
- const SESSION = {
105
- user: {
106
- id: "user_1",
107
- name: "Ada Lovelace",
108
- email: "ada@example.com",
109
- emailVerified: true,
110
- createdAt: new Date("2026-09-01T00:00:00.000Z"),
111
- },
112
- };
113
-
114
- const subscriptionBody = (body: Record<string, unknown>) => ({
115
- ok: true,
116
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false, ...body },
117
- });
118
-
119
- // ── The site's theme, as `_site.mdx` declares it ──────────────────────────────
120
-
121
- let contentRoot: string;
122
-
123
- function writeSite(frontmatter: string): void {
124
- writeFileSync(join(contentRoot, "_site.mdx"), `---\n${frontmatter}\n---\n`);
125
- }
126
-
127
- const ENV_KEYS = [
128
- "AILK_CONTENT_ROOT",
129
- "GOOGLE_CLIENT_ID",
130
- "GOOGLE_CLIENT_SECRET",
131
- "GITHUB_CLIENT_ID",
132
- "GITHUB_CLIENT_SECRET",
133
- "AUTH_EMAIL_SERVER",
134
- "AUTH_EMAIL_FROM",
135
- "AUTH_EMAIL_WEBHOOK_SECRET",
136
- "BETTER_AUTH_URL",
137
- "BETTER_AUTH_SECRET",
138
- ] as const;
139
- const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string>> = {};
140
-
141
- beforeAll(() => {
142
- for (const key of ENV_KEYS) {
143
- const value = process.env[key];
144
- if (value !== undefined) savedEnv[key] = value;
145
- }
146
- });
147
-
148
- afterAll(() => {
149
- for (const key of ENV_KEYS) {
150
- const value = savedEnv[key];
151
- if (value === undefined) delete process.env[key];
152
- else process.env[key] = value;
153
- }
154
- });
26
+ import { isSettingsPath, memberSettingsFrame, SETTINGS_ROUTES, type Translate } from "../settings-frame";
155
27
 
156
28
  beforeEach(() => {
157
- for (const key of ENV_KEYS) delete process.env[key];
158
- contentRoot = mkdtempSync(join(tmpdir(), "ailk-settings-"));
159
- process.env.AILK_CONTENT_ROOT = contentRoot;
160
- writeSite('siteName: "Test"');
161
- mockGetSession.mockReset().mockResolvedValue(SESSION);
162
- mockSubscriptionMe.mockReset().mockResolvedValue(subscriptionBody({}));
163
- mockLeadsSubmit.mockReset().mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
164
- mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
165
- mockRefresh.mockReset();
29
+ mockRedirect.mockReset();
166
30
  });
167
31
 
168
- afterEach(() => {
169
- rmSync(contentRoot, { recursive: true, force: true });
170
- });
171
-
172
- async function renderPage(): Promise<void> {
173
- const element = await Page({ params: Promise.resolve({ locale: "en" }) });
174
- render(element);
175
- }
176
-
177
- function sectionTitles(): string[] {
178
- return screen
179
- .getAllByRole("heading", { level: 2 })
180
- .map((heading) => heading.textContent ?? "");
181
- }
182
-
183
- describe("the base sections (AC-8)", () => {
184
- it("renders General, Security, Notifications and Privacy — and no admin-only section", async () => {
185
- await renderPage();
186
- expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Settings");
187
- expect(sectionTitles()).toEqual(["General", "Security", "Notifications", "Privacy"]);
188
- for (const absent of [/credentials/i, /scope/i, /organizations/i, /credits/i]) {
189
- expect(screen.queryByRole("heading", { name: absent })).not.toBeInTheDocument();
190
- }
32
+ describe("AC-6 — /account/settings redirects to /account/general (D8)", () => {
33
+ it("redirects the default locale to /account/general", async () => {
34
+ await Page({ params: Promise.resolve({ locale: "en" }) });
35
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
36
+ expect(mockRedirect).toHaveBeenCalledWith("/account/general");
191
37
  });
192
38
 
193
- it("each section is an anchor the For-you items link to", async () => {
194
- await renderPage();
195
- for (const id of ["general", "security", "notifications", "privacy"]) {
196
- expect(document.getElementById(id)).toBeInTheDocument();
197
- }
39
+ it("keeps a non-default locale", async () => {
40
+ await Page({ params: Promise.resolve({ locale: "fr" }) });
41
+ expect(mockRedirect).toHaveBeenCalledWith("/fr/account/general");
198
42
  });
199
43
  });
200
44
 
201
- describe("Billing (AC-8, D10)", () => {
202
- it('renders for status: "active", with the status', async () => {
203
- mockSubscriptionMe.mockResolvedValue(subscriptionBody({ status: "active" }));
204
- await renderPage();
205
- const billing = document.getElementById("billing")!;
206
- expect(within(billing).getByRole("heading", { level: 2 })).toHaveTextContent("Billing");
207
- expect(billing).toHaveTextContent("Active");
208
- expect(within(billing).queryByTestId("account-billing-manage")).not.toBeInTheDocument();
209
- });
210
-
211
- it("renders for portalAvailable: true, with Manage billing", async () => {
212
- mockSubscriptionMe.mockResolvedValue(subscriptionBody({ portalAvailable: true }));
213
- await renderPage();
214
- const billing = document.getElementById("billing")!;
215
- expect(within(billing).getByTestId("account-billing-manage")).toHaveTextContent(
216
- "Manage billing",
217
- );
218
- });
219
-
220
- it("shows when access ends, when set", async () => {
221
- mockSubscriptionMe.mockResolvedValue(
222
- subscriptionBody({ status: "canceled", accessRevokesAt: "2026-10-20T00:00:00.000Z" }),
223
- );
224
- await renderPage();
225
- const billing = document.getElementById("billing")!;
226
- expect(billing).toHaveTextContent("Canceled");
227
- expect(billing).toHaveTextContent("Access ends");
228
- });
229
-
230
- it("is absent for status: null with portalAvailable: false", async () => {
231
- await renderPage();
232
- expect(document.getElementById("billing")).not.toBeInTheDocument();
233
- expect(sectionTitles()).not.toContain("Billing");
234
- });
235
-
236
- it("is absent for a 404 (the commerce-disabled build)", async () => {
237
- mockSubscriptionMe.mockResolvedValue({ ok: false, status: 404, body: {} });
238
- await renderPage();
239
- expect(document.getElementById("billing")).not.toBeInTheDocument();
240
- });
241
-
242
- it.each([401, 403])("is absent for a %i", async (status) => {
243
- mockSubscriptionMe.mockResolvedValue({ ok: false, status, body: {} });
244
- await renderPage();
245
- expect(document.getElementById("billing")).not.toBeInTheDocument();
246
- });
247
- });
248
-
249
- describe("General — the member's name (operator fold-in, #7418)", () => {
250
- it("shows the email read-only and the name in an editable field", async () => {
251
- await renderPage();
252
- const general = document.getElementById("general")!;
253
- expect(within(general).getByTestId("account-settings-email")).toHaveTextContent(
254
- "ada@example.com",
255
- );
256
- expect(within(general).getByLabelText("Name")).toHaveValue("Ada Lovelace");
257
- });
258
-
259
- it("a member with no name can set one: Save is disabled until the field changes, then saves through updateUser", async () => {
260
- mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
261
- await renderPage();
262
- const input = screen.getByLabelText("Name");
263
- expect(input).toHaveValue("");
264
- const save = screen.getByTestId("account-name-save");
265
- expect(save).toBeDisabled();
266
- fireEvent.change(input, { target: { value: " Grace Hopper " } });
267
- expect(save).toBeEnabled();
268
- expect(screen.getByTestId("account-name-status")).toHaveTextContent("Unsaved changes");
269
- await act(async () => {
270
- fireEvent.click(save);
271
- });
272
- expect(mockUpdateUser).toHaveBeenCalledWith({ name: "Grace Hopper" });
273
- await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
274
- expect(screen.getByTestId("account-name-status")).toHaveTextContent("Saved.");
275
- });
276
-
277
- it("a blank name is refused without a call", async () => {
278
- await renderPage();
279
- const input = screen.getByLabelText("Name");
280
- fireEvent.change(input, { target: { value: " " } });
281
- await act(async () => {
282
- fireEvent.click(screen.getByTestId("account-name-save"));
283
- });
284
- expect(mockUpdateUser).not.toHaveBeenCalled();
285
- expect(screen.getByRole("alert")).toHaveTextContent("Enter your name.");
286
- });
287
-
288
- it("a failed save says so and keeps the edit", async () => {
289
- mockUpdateUser.mockResolvedValue({ data: null, error: { message: "nope" } });
290
- await renderPage();
291
- const input = screen.getByLabelText("Name");
292
- fireEvent.change(input, { target: { value: "Ada King" } });
293
- await act(async () => {
294
- fireEvent.click(screen.getByTestId("account-name-save"));
295
- });
296
- expect(screen.getByRole("alert")).toHaveTextContent("We couldn't save your name.");
297
- expect(input).toHaveValue("Ada King");
298
- expect(mockRefresh).not.toHaveBeenCalled();
299
- });
300
- });
301
-
302
- describe("General — a save that throws", () => {
303
- it("a network failure says so and re-enables Save", async () => {
304
- mockUpdateUser.mockRejectedValue(new TypeError("Failed to fetch"));
305
- await renderPage();
306
- fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
307
- await act(async () => {
308
- fireEvent.click(screen.getByTestId("account-name-save"));
309
- });
310
- expect(screen.getByRole("alert")).toHaveTextContent("We couldn't save your name.");
311
- expect(screen.getByTestId("account-name-save")).toBeEnabled();
312
- });
313
- });
314
-
315
- describe("General — color mode only on a multi-mode theme (operator fold-in)", () => {
316
- it("a site with no theme block (the default light + dark palette) shows Light · Auto · Dark", async () => {
317
- await renderPage();
318
- const control = screen.getByTestId("account-color-mode");
319
- expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual([
320
- "Light",
321
- "Auto",
322
- "Dark",
45
+ describe("the one list of User settings' routes (#8441)", () => {
46
+ it("names the four tabs, in the reference's order", () => {
47
+ expect(SETTINGS_ROUTES.map(({ key, path }) => [key, path])).toEqual([
48
+ ["general", "/account/general"],
49
+ ["security", "/account/security"],
50
+ ["notifications", "/account/notifications"],
51
+ ["billing", "/account/billing"],
323
52
  ]);
324
53
  });
325
54
 
326
- it("a theme declaring both modes shows the control", async () => {
327
- writeSite('theme:\n colors:\n light:\n bg: "#ffffff"\n dark:\n bg: "#000000"');
328
- await renderPage();
329
- expect(screen.getByTestId("account-color-mode")).toBeInTheDocument();
330
- });
331
-
332
- it("a light-only theme shows no control", async () => {
333
- writeSite('theme:\n colors:\n light:\n bg: "#fdfcfa"\n text: "#1c1917"');
334
- await renderPage();
335
- expect(screen.queryByTestId("account-color-mode")).not.toBeInTheDocument();
336
- });
337
-
338
- it("choosing Dark writes the theme cookie and the root's data-theme", async () => {
339
- await renderPage();
340
- const control = screen.getByTestId("account-color-mode");
341
- fireEvent.click(within(control).getByRole("radio", { name: "Dark" }));
342
- expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
343
- expect(document.cookie).toContain("ailk-theme=dark");
344
- });
345
- });
346
-
347
- describe("Security — the configured sign-in methods (D10)", () => {
348
- it("lists each configured method; no Password row without the password method", async () => {
349
- process.env.GOOGLE_CLIENT_ID = "id";
350
- process.env.GOOGLE_CLIENT_SECRET = "secret";
351
- await renderPage();
352
- const security = document.getElementById("security")!;
353
- expect(within(security).getByText("Google")).toBeInTheDocument();
354
- expect(within(security).queryByText("GitHub")).not.toBeInTheDocument();
355
- expect(within(security).queryByText("Password")).not.toBeInTheDocument();
356
- });
357
-
358
- it("the password method adds a Password row", async () => {
359
- process.env.AUTH_EMAIL_SERVER = "smtp://x";
360
- process.env.AUTH_EMAIL_FROM = "site@example.com";
361
- await renderPage();
362
- const security = document.getElementById("security")!;
363
- expect(within(security).getByText("Password")).toBeInTheDocument();
364
- });
365
-
366
- it("no configured method says so", async () => {
367
- await renderPage();
368
- expect(document.getElementById("security")).toHaveTextContent(
369
- "No sign-in method is configured.",
55
+ it.each(["en", "fr"])("the settings frame's tabs are that list, in %s", (locale) => {
56
+ const t: Translate = (key) => key;
57
+ const { tabs } = memberSettingsFrame(locale, t, "security");
58
+ expect(tabs.map(({ key, href, label }) => [key, href, label])).toEqual(
59
+ SETTINGS_ROUTES.map(({ key, path, titleKey }) => [
60
+ key,
61
+ locale === "en" ? path : `/${locale}${path}`,
62
+ titleKey,
63
+ ]),
370
64
  );
371
65
  });
372
66
  });
373
67
 
374
- describe("Privacy (D10)", () => {
375
- it("links to the cookie settings", async () => {
376
- await renderPage();
377
- const privacy = document.getElementById("privacy")!;
378
- expect(within(privacy).getByText("Cookie settings").closest("a")).toHaveAttribute(
379
- "href",
380
- "/privacy#cookie-settings",
381
- );
382
- });
383
-
384
- it("Request account deletion files a support request of kind deletion", async () => {
385
- await renderPage();
386
- const privacy = document.getElementById("privacy")!;
387
- await act(async () => {
388
- fireEvent.click(within(privacy).getByTestId("account-deletion-request"));
389
- });
390
- await waitFor(() => expect(mockLeadsSubmit).toHaveBeenCalledTimes(1));
391
- const [body] = mockLeadsSubmit.mock.calls[0] as [Record<string, unknown>];
392
- expect(body).toEqual({
393
- source: "web",
394
- email: "ada@example.com",
395
- payload: {
396
- kind: "deletion",
397
- subject: "Account deletion request",
398
- message: "Please delete my account and its data.",
399
- },
400
- });
401
- await waitFor(() =>
402
- expect(within(privacy).getByRole("status")).toHaveTextContent(
403
- "Your deletion request is on its way.",
404
- ),
405
- );
68
+ describe("isSettingsPath — an exact, locale-aware match (#8441)", () => {
69
+ it.each(SETTINGS_ROUTES.map(({ path }) => path))("%s is a settings route, bare or prefixed", (path) => {
70
+ expect(isSettingsPath(path, "en")).toBe(true);
71
+ expect(isSettingsPath(`/fr${path}`, "fr")).toBe(true);
72
+ });
73
+
74
+ it.each([
75
+ ["/account", "en"],
76
+ ["/account/settings", "en"],
77
+ ["/account/support", "en"],
78
+ ["/account/artifacts", "en"],
79
+ // A sub-route stays inside the shell unless it is listed.
80
+ ["/account/billing/history", "en"],
81
+ ["/fr/account/billing/history", "fr"],
82
+ ["/account/general/", "en"],
83
+ ["/account/generalx", "en"],
84
+ // Only the request's own locale is a prefix.
85
+ ["/fr/account/general", "en"],
86
+ ["/de/account/general", "fr"],
87
+ ["/frx/account/general", "fr"],
88
+ ["", "en"],
89
+ ])("%s (locale %s) is not", (path, locale) => {
90
+ expect(isSettingsPath(path, locale)).toBe(false);
406
91
  });
407
92
  });
@@ -189,15 +189,20 @@ describe("submitSupportRequest — the Server Action's own guards", () => {
189
189
  expect(mockLeadsSubmit).not.toHaveBeenCalled();
190
190
  });
191
191
 
192
- it("any kind other than deletion files as support; the email is always the session's", async () => {
193
- await submitSupportRequest(
194
- { kind: "idle" },
195
- form({ kind: "refund", subject: "Hi", message: "Help", email: "someone@else.com" }),
196
- );
197
- expect(mockLeadsSubmit).toHaveBeenCalledWith({
198
- source: "web",
199
- email: "ada@example.com",
200
- payload: { kind: "support", subject: "Hi", message: "Help" },
201
- });
202
- });
192
+ // #8232 D12 — a deletion request goes through `member.requestDeletion`
193
+ // (`profile-actions.ts`), so Support files support only.
194
+ it.each(["refund", "deletion"])(
195
+ "every request files as support, a posted kind=%s included; the email is always the session's",
196
+ async (kind) => {
197
+ await submitSupportRequest(
198
+ { kind: "idle" },
199
+ form({ kind, subject: "Hi", message: "Help", email: "someone@else.com" }),
200
+ );
201
+ expect(mockLeadsSubmit).toHaveBeenCalledWith({
202
+ source: "web",
203
+ email: "ada@example.com",
204
+ payload: { kind: "support", subject: "Hi", message: "Help" },
205
+ });
206
+ },
207
+ );
203
208
  });
@@ -150,7 +150,7 @@ function addNameItem(session: AccountSession, now: string): ForYouItem[] {
150
150
  },
151
151
  title: ctx.t("forYou.addName"),
152
152
  time: { kind: "waiting", at: session.createdAt ?? now },
153
- action: { kind: "open", href: localeHref(ctx.locale, "/account/settings#general") },
153
+ action: { kind: "open", href: localeHref(ctx.locale, "/account/general") },
154
154
  dismissible: true,
155
155
  },
156
156
  ];