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
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Tests for /[locale]/account/general — User settings › General (#8339
3
+ * D8; builds on #8232 D1-D8; AC-1, AC-3, AC-4, AC-6).
4
+ *
5
+ * General now renders through the shared `AccountSettings` composition
6
+ * (@working-theory/templates/account, spec 8339 D4) via the `GeneralSettings`
7
+ * adapter — the Admin's form structure and test ids
8
+ * (`account-general-*`), not the former `account-name-*` ones. Sign-in and
9
+ * cookie settings moved to Security/Notifications (security.test.tsx,
10
+ * notifications.test.tsx); this file keeps:
11
+ *
12
+ * 1. The frame — Back to the Inbox, the four-tab rail with General
13
+ * current.
14
+ * 2. The color-mode control, the profile form (email read-only, name,
15
+ * photo, Save) and the deletion block in the pane's DOM order.
16
+ * 3. The profile form on the member path: one Save sends the name, and
17
+ * the photo only when it changed; a refusal keeps what was typed.
18
+ * 4. The profile form with no member API (a 404): Better Auth's save, no
19
+ * photo field.
20
+ * 5. The deletion request: the answer names who it went to; the action's
21
+ * refusal mapping; a Support pointer with no member API.
22
+ * 6. `#billing` forwards to Billing; `#general` and no fragment stay.
23
+ */
24
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
29
+
30
+ jest.mock("next-intl/server", () => {
31
+ const msgs: unknown = require("../../../../../messages/en.json");
32
+ const dig = (obj: unknown, path: string): unknown =>
33
+ path.split(".").reduce<unknown>(
34
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
35
+ obj,
36
+ );
37
+ return {
38
+ __esModule: true,
39
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
40
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
41
+ const messages = dig(msgs, ns);
42
+ return (key: string, values?: Record<string, unknown>): string => {
43
+ const value = dig(messages, key);
44
+ if (typeof value !== "string") return `${ns}.${key}`;
45
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
46
+ values && name in values ? String(values[name]) : m,
47
+ );
48
+ };
49
+ },
50
+ };
51
+ });
52
+
53
+ const mockHeaders = jest.fn();
54
+ const mockCookieSet = jest.fn();
55
+ jest.mock("next/headers", () => ({
56
+ __esModule: true,
57
+ headers: (...args: unknown[]) => mockHeaders(...args),
58
+ cookies: jest.fn().mockImplementation(async () => ({
59
+ toString: () => "session=abc",
60
+ set: (...args: unknown[]) => mockCookieSet(...args),
61
+ })),
62
+ }));
63
+
64
+ const mockGetSession = jest.fn();
65
+ // 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.
66
+ jest.mock("../../../../../auth", () => ({
67
+ __esModule: true,
68
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
69
+ }));
70
+
71
+ const mockPieces = jest.fn();
72
+ const mockUpdateProfile = jest.fn();
73
+ const mockRequestDeletion = jest.fn();
74
+ const mockHubMe = jest.fn();
75
+ const mockLeadsSubmit = jest.fn();
76
+ jest.mock("@working-theory/api-client", () => ({
77
+ __esModule: true,
78
+ waitlistSignups: { me: jest.fn() },
79
+ flowCheckouts: { me: jest.fn() },
80
+ subscription: { me: jest.fn(), portalSession: jest.fn() },
81
+ leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
82
+ member: {
83
+ pieces: (...args: unknown[]) => mockPieces(...args),
84
+ updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
85
+ requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
86
+ },
87
+ hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
88
+ visitorForwardHeaders: () => null,
89
+ }));
90
+
91
+ const mockUpdateUser = jest.fn();
92
+ jest.mock("better-auth/react", () => ({
93
+ __esModule: true,
94
+ createAuthClient: () => ({
95
+ signOut: jest.fn(),
96
+ updateUser: (...args: unknown[]) => mockUpdateUser(...args),
97
+ }),
98
+ }));
99
+
100
+ const mockRefresh = jest.fn();
101
+ const mockReplace = jest.fn();
102
+ jest.mock("next/navigation", () => ({
103
+ __esModule: true,
104
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh, replace: mockReplace }),
105
+ usePathname: () => "/account/general",
106
+ redirect: jest.fn(),
107
+ }));
108
+
109
+ // The colour-mode cases test the theme's modes, so they run against an unpinned
110
+ // chrome, as settings.test.tsx did (theme-modes.test.ts covers the pin).
111
+ // 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).
112
+ jest.mock("../../../../../lib/site-chrome", () => {
113
+ const actual = jest.requireActual("../../../../../lib/site-chrome");
114
+ const { colorMode, ...unpinned } = actual.siteChrome;
115
+ return { ...actual, siteChrome: unpinned };
116
+ });
117
+
118
+ import type { AccountSettingsSection } from "@working-theory/templates/account";
119
+
120
+ import { accountSettingsSections } from "../../../../../lib/account-config";
121
+ import { seal } from "../../../../../lib/hub-session/seal";
122
+ import { BillingAnchor } from "../general/billing-anchor";
123
+ import Page from "../general/page";
124
+ import { requestAccountDeletion, saveProfile } from "../profile-actions";
125
+
126
+ const PHOTO = "https://cdn.example.com/ada.png";
127
+ const SESSION = {
128
+ user: {
129
+ id: "user_1",
130
+ name: "Ada Lovelace",
131
+ email: "ada@example.com",
132
+ emailVerified: true,
133
+ image: PHOTO,
134
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
135
+ },
136
+ };
137
+ const COOKIE_INIT = { headers: { cookie: "session=abc" } };
138
+ const ok = <T,>(body: T) => ({ ok: true, body });
139
+ const refusal = (status: number, code: string) => ({
140
+ ok: false,
141
+ status,
142
+ body: { ok: false, error: { code, message: code } },
143
+ });
144
+
145
+ // ── The site's theme and env ─────────────────────────────────────────────────
146
+
147
+ let contentRoot: string;
148
+
149
+ function writeSite(frontmatter: string): void {
150
+ writeFileSync(join(contentRoot, "_site.mdx"), `---\n${frontmatter}\n---\n`);
151
+ }
152
+
153
+ const ENV = process.env;
154
+ const ENV_KEYS = [
155
+ "GOOGLE_CLIENT_ID",
156
+ "GOOGLE_CLIENT_SECRET",
157
+ "GITHUB_CLIENT_ID",
158
+ "GITHUB_CLIENT_SECRET",
159
+ "AUTH_EMAIL_SERVER",
160
+ "AUTH_EMAIL_FROM",
161
+ "AUTH_EMAIL_WEBHOOK_SECRET",
162
+ "BETTER_AUTH_URL",
163
+ "BETTER_AUTH_SECRET",
164
+ "AILK_HUB_SIGN_IN_URL",
165
+ "AILK_HUB_CLIENT_ID",
166
+ "AILK_HUB_CLIENT_SECRET",
167
+ "AILK_HUB_SESSION_SECRET",
168
+ ] as const;
169
+
170
+ beforeEach(() => {
171
+ process.env = { ...ENV };
172
+ for (const key of ENV_KEYS) delete process.env[key];
173
+ contentRoot = mkdtempSync(join(tmpdir(), "ailk-general-"));
174
+ process.env.AILK_CONTENT_ROOT = contentRoot;
175
+ writeSite('siteName: "Test"');
176
+ mockHeaders.mockReset().mockResolvedValue(new Headers());
177
+ mockCookieSet.mockReset();
178
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
179
+ mockPieces.mockReset().mockResolvedValue(ok([]));
180
+ mockUpdateProfile.mockReset().mockResolvedValue(ok({ name: "Ada King", image: PHOTO }));
181
+ mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
182
+ mockHubMe.mockReset();
183
+ mockLeadsSubmit.mockReset();
184
+ mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
185
+ mockRefresh.mockReset();
186
+ mockReplace.mockReset();
187
+ });
188
+
189
+ afterEach(() => {
190
+ rmSync(contentRoot, { recursive: true, force: true });
191
+ process.env = ENV;
192
+ });
193
+
194
+ async function renderPage(locale = "en"): Promise<void> {
195
+ render(await Page({ params: Promise.resolve({ locale }) }));
196
+ }
197
+
198
+ async function clickSave(): Promise<void> {
199
+ await act(async () => {
200
+ fireEvent.click(screen.getByTestId("account-general-save"));
201
+ });
202
+ }
203
+
204
+ // ── 1. The frame (AC-1, AC-4) ──────────────────────────────────────────────────
205
+
206
+ describe("AC-1 — the frame (D2, D7)", () => {
207
+ it("renders in the settings frame: Back to the Inbox, and the four tabs with General current", async () => {
208
+ await renderPage();
209
+ expect(screen.getByTestId("account-settings")).toContainElement(
210
+ screen.getByRole("heading", { level: 1, name: "General" }),
211
+ );
212
+ expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
213
+ expect(screen.getByTestId("account-settings-back")).toHaveTextContent("Back");
214
+ const rail = screen.getByRole("navigation", { name: "Settings" });
215
+ const links = within(rail).getAllByRole("link");
216
+ expect(links.map((link) => [link.textContent, link.getAttribute("href")])).toEqual([
217
+ ["General", "/account/general"],
218
+ ["Security", "/account/security"],
219
+ ["Notifications", "/account/notifications"],
220
+ ["Billing", "/account/billing"],
221
+ ]);
222
+ expect(links[0]).toHaveAttribute("aria-current", "page");
223
+ for (const link of links.slice(1)) expect(link).not.toHaveAttribute("aria-current");
224
+ });
225
+
226
+ it("a non-default locale prefixes Back and the rail", async () => {
227
+ await renderPage("fr");
228
+ expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/fr/account");
229
+ expect(screen.getByTestId("account-settings-rail-general")).toHaveAttribute("href", "/fr/account/general");
230
+ expect(screen.getByTestId("account-settings-rail-security")).toHaveAttribute("href", "/fr/account/security");
231
+ expect(screen.getByTestId("account-settings-rail-notifications")).toHaveAttribute(
232
+ "href",
233
+ "/fr/account/notifications",
234
+ );
235
+ expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("href", "/fr/account/billing");
236
+ });
237
+
238
+ it("renders no Security or Notifications content on General", async () => {
239
+ await renderPage();
240
+ expect(screen.queryByTestId("account-signin-method")).not.toBeInTheDocument();
241
+ expect(screen.queryByTestId("account-cookie-settings")).not.toBeInTheDocument();
242
+ expect(screen.queryByText("Sign-in methods")).not.toBeInTheDocument();
243
+ for (const absent of [/credentials/i, /scope/i, /organizations/i, /credits/i]) {
244
+ expect(screen.queryByRole("heading", { name: absent })).not.toBeInTheDocument();
245
+ }
246
+ });
247
+
248
+ it("the pane holds the email read-only, the name and photo inputs, in order before the deletion block", async () => {
249
+ await renderPage();
250
+ const pane = screen.getByTestId("account-settings-general");
251
+ expect(within(pane).getByTestId("account-general-email")).toHaveTextContent("ada@example.com");
252
+ expect(within(pane).queryByRole("textbox", { name: "Email" })).not.toBeInTheDocument();
253
+ expect(within(pane).getByLabelText("Name")).toHaveValue("Ada Lovelace");
254
+ expect(within(pane).getByLabelText("Photo")).toHaveValue(PHOTO);
255
+ const form = within(pane).getByTestId("account-general-form");
256
+ const deletion = within(pane).getByTestId("account-deletion");
257
+ expect(form.compareDocumentPosition(deletion) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
258
+ expect(within(deletion).getByTestId("account-deletion-request")).toHaveTextContent(
259
+ "Request account deletion",
260
+ );
261
+ });
262
+
263
+ it("the photo is never rendered as an image", async () => {
264
+ await renderPage();
265
+ expect(document.querySelector(`img[src="${PHOTO}"]`)).toBeNull();
266
+ });
267
+
268
+ it("a light-only theme renders no color-mode control", async () => {
269
+ writeSite('theme:\n colors:\n light:\n bg: "#fdfcfa"\n text: "#1c1917"');
270
+ await renderPage();
271
+ expect(screen.queryByTestId("account-color-mode")).not.toBeInTheDocument();
272
+ });
273
+
274
+ it("the color-mode control holds Light · Auto · Dark", async () => {
275
+ await renderPage();
276
+ const control = screen.getByTestId("account-color-mode");
277
+ expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual(["Light", "Auto", "Dark"]);
278
+ });
279
+
280
+ it("a contributed settings section renders after the deletion block (C8)", async () => {
281
+ const section: AccountSettingsSection = {
282
+ id: "extra",
283
+ title: "settings.general.title",
284
+ render: () => <p data-testid="extra-body">Extra</p>,
285
+ };
286
+ accountSettingsSections.push(section);
287
+ try {
288
+ await renderPage();
289
+ const deletion = screen.getByTestId("account-deletion");
290
+ const extra = screen.getByTestId("account-settings-extra");
291
+ expect(deletion.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
292
+ expect(screen.getByTestId("extra-body")).toBeInTheDocument();
293
+ } finally {
294
+ accountSettingsSections.splice(accountSettingsSections.indexOf(section), 1);
295
+ }
296
+ });
297
+ });
298
+
299
+ // ── 2. The profile form on the member path (AC-3) ─────────────────────────────
300
+
301
+ describe("AC-3 — name and photo through the member API (D4, D8)", () => {
302
+ it("a changed name saves { name } with no image key, then refreshes", async () => {
303
+ await renderPage();
304
+ const save = screen.getByTestId("account-general-save");
305
+ expect(save).toBeDisabled();
306
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Ada King " } });
307
+ await clickSave();
308
+ expect(mockUpdateProfile).toHaveBeenCalledTimes(1);
309
+ expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada King" }, COOKIE_INIT);
310
+ await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
311
+ expect(screen.getByTestId("account-general-status")).toHaveTextContent("Saved.");
312
+ expect(mockUpdateUser).not.toHaveBeenCalled();
313
+ });
314
+
315
+ it("a typed photo sends { name, image }", async () => {
316
+ await renderPage();
317
+ const next = "https://cdn.example.com/new.png";
318
+ mockUpdateProfile.mockResolvedValue(ok({ name: "Ada Lovelace", image: next }));
319
+ fireEvent.change(screen.getByLabelText("Photo"), { target: { value: next } });
320
+ await clickSave();
321
+ expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada Lovelace", image: next }, COOKIE_INIT);
322
+ });
323
+
324
+ it("emptying a started photo sends image: null", async () => {
325
+ await renderPage();
326
+ mockUpdateProfile.mockResolvedValue(ok({ name: "Ada Lovelace", image: null }));
327
+ fireEvent.change(screen.getByLabelText("Photo"), { target: { value: "" } });
328
+ await clickSave();
329
+ expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada Lovelace", image: null }, COOKIE_INIT);
330
+ });
331
+
332
+ it("a refused photo says so plainly and both fields keep what was typed (AC-MAA-07)", async () => {
333
+ mockUpdateProfile.mockResolvedValue(refusal(400, "link_not_https"));
334
+ await renderPage();
335
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
336
+ fireEvent.change(screen.getByLabelText("Photo"), { target: { value: "http://cdn.example.com/a.png" } });
337
+ await clickSave();
338
+ expect(screen.getByRole("alert")).toHaveTextContent("Enter a link that starts with https://.");
339
+ expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
340
+ expect(screen.getByLabelText("Photo")).toHaveValue("http://cdn.example.com/a.png");
341
+ expect(mockRefresh).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it.each([
345
+ [refusal(403, "email_unverified"), "Confirm your email address first, then try again."],
346
+ [refusal(404, "not_found"), "Profile changes aren't available on this site yet."],
347
+ [refusal(500, "internal"), "We couldn't save your changes. Try again."],
348
+ ])("a refusal shows its message and keeps the edit (%#)", async (answer, message) => {
349
+ mockUpdateProfile.mockResolvedValue(answer);
350
+ await renderPage();
351
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
352
+ await clickSave();
353
+ expect(screen.getByTestId("account-general-status")).toHaveTextContent(message);
354
+ expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
355
+ });
356
+
357
+ it("a blank name calls nothing", async () => {
358
+ await renderPage();
359
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: " " } });
360
+ await clickSave();
361
+ expect(mockUpdateProfile).not.toHaveBeenCalled();
362
+ expect(screen.getByRole("alert")).toHaveTextContent("Enter your name.");
363
+ });
364
+
365
+ it("a 503 from the pieces read still offers the member writes", async () => {
366
+ mockPieces.mockResolvedValue(refusal(503, "tenant_unresolved"));
367
+ await renderPage();
368
+ expect(screen.getByLabelText("Photo")).toBeInTheDocument();
369
+ expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
370
+ });
371
+ });
372
+
373
+ describe("AC-3 — saveProfile (D6)", () => {
374
+ it("calls member.updateProfile with forwardedCookieInit()'s init and answers the saved pair", async () => {
375
+ mockHeaders.mockResolvedValue(new Headers({ origin: "https://site.example.com" }));
376
+ await expect(saveProfile({ name: " Ada King ", image: " " })).resolves.toEqual({
377
+ ok: true,
378
+ name: "Ada King",
379
+ image: PHOTO,
380
+ });
381
+ expect(mockUpdateProfile).toHaveBeenCalledWith(
382
+ { name: "Ada King", image: null },
383
+ { headers: { cookie: "session=abc", origin: "https://site.example.com" } },
384
+ );
385
+ });
386
+
387
+ it.each([
388
+ [refusal(400, "link_not_https"), "photo"],
389
+ [refusal(400, "invalid_input"), "failed"],
390
+ [refusal(403, "email_unverified"), "unverified"],
391
+ [refusal(404, "not_found"), "unavailable"],
392
+ [refusal(503, "tenant_unresolved"), "unavailable"],
393
+ [refusal(500, "internal"), "failed"],
394
+ ])("maps %j to %s", async (answer, reason) => {
395
+ mockUpdateProfile.mockResolvedValue(answer);
396
+ await expect(saveProfile({ name: "Ada" })).resolves.toEqual({ ok: false, reason });
397
+ });
398
+
399
+ it("refuses an image that is neither a string nor null, rather than clearing the photo", async () => {
400
+ await expect(saveProfile({ name: "Ada", image: 42 as never })).resolves.toEqual({
401
+ ok: false,
402
+ reason: "photo",
403
+ });
404
+ expect(mockUpdateProfile).not.toHaveBeenCalled();
405
+ await expect(saveProfile(null as never)).resolves.toEqual({ ok: false, reason: "required" });
406
+ });
407
+
408
+ it("a throw is failed; a blank name is required with no call", async () => {
409
+ mockUpdateProfile.mockRejectedValue(new TypeError("fetch failed"));
410
+ await expect(saveProfile({ name: "Ada" })).resolves.toEqual({ ok: false, reason: "failed" });
411
+ mockUpdateProfile.mockClear();
412
+ await expect(saveProfile({ name: " " })).resolves.toEqual({ ok: false, reason: "required" });
413
+ expect(mockUpdateProfile).not.toHaveBeenCalled();
414
+ });
415
+
416
+ it("re-seals the hub session once after a 200 (D11), and never after a refusal", async () => {
417
+ const secret = "s".repeat(43);
418
+ process.env.AILK_HUB_SIGN_IN_URL = "https://hub.example.com";
419
+ process.env.AILK_HUB_CLIENT_ID = "spoke_0001";
420
+ process.env.AILK_HUB_CLIENT_SECRET = `ailk_hcs_${"c".repeat(43)}`;
421
+ process.env.AILK_HUB_SESSION_SECRET = secret;
422
+ process.env.NEXT_PUBLIC_BASE_URL = "http://127.0.0.1:3100";
423
+ const user = { name: "Ada Lovelace", email: "ada@example.com", emailVerified: true, createdAt: "2026-09-01T00:00:00.000Z" };
424
+ const sealed = seal(
425
+ { v: 1, accessToken: `ailk_hst_${"t".repeat(43)}`, expiresAt: Date.now() + 60_000, user },
426
+ secret,
427
+ "session",
428
+ "ailk-hub-session",
429
+ );
430
+ mockHeaders.mockResolvedValue(new Headers({ cookie: `ailk-hub-session=${sealed}` }));
431
+ mockHubMe.mockResolvedValue(ok({ user: { ...user, name: "Ada King" }, billingCustomerId: null }));
432
+
433
+ await saveProfile({ name: "Ada King" });
434
+ expect(mockHubMe).toHaveBeenCalledTimes(1);
435
+ expect(mockCookieSet).toHaveBeenCalledTimes(1);
436
+
437
+ mockUpdateProfile.mockResolvedValue(refusal(403, "email_unverified"));
438
+ await saveProfile({ name: "Ada King" });
439
+ expect(mockHubMe).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ it("re-seals nothing on a single site with no hub configuration", async () => {
443
+ await expect(saveProfile({ name: "Ada King" })).resolves.toMatchObject({ ok: true });
444
+ expect(mockHubMe).not.toHaveBeenCalled();
445
+ expect(mockCookieSet).not.toHaveBeenCalled();
446
+ });
447
+ });
448
+
449
+ // ── 3. No member API (C2, D3) ─────────────────────────────────────────────────
450
+
451
+ describe("AC-3 — a single site with no member API (a 404 from the pieces read)", () => {
452
+ beforeEach(() => {
453
+ mockPieces.mockResolvedValue(refusal(404, "not_found"));
454
+ });
455
+
456
+ it("shows no photo field and saves the name through Better Auth, not the member API", async () => {
457
+ await renderPage();
458
+ expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
459
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Grace Hopper " } });
460
+ await clickSave();
461
+ expect(mockUpdateUser).toHaveBeenCalledWith({ name: "Grace Hopper" });
462
+ expect(mockUpdateProfile).not.toHaveBeenCalled();
463
+ await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
464
+ });
465
+
466
+ it("a failed Better Auth save says so and keeps the edit", async () => {
467
+ mockUpdateUser.mockResolvedValue({ data: null, error: { message: "nope" } });
468
+ await renderPage();
469
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
470
+ await clickSave();
471
+ expect(screen.getByTestId("account-general-status")).toHaveTextContent(
472
+ "We couldn't save your changes. Try again.",
473
+ );
474
+ expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
475
+ });
476
+
477
+ it("points the deletion request at Support, with no Request button (C2, D5)", async () => {
478
+ await renderPage();
479
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
480
+ expect(screen.getByTestId("account-deletion-support")).toHaveAttribute("href", "/account/support");
481
+ expect(screen.getByText("To ask for your account to be deleted, contact support.")).toBeInTheDocument();
482
+ });
483
+ });
484
+
485
+ // ── 4. The deletion request (AC-4) ────────────────────────────────────────────
486
+
487
+ describe("AC-4 — Request account deletion through the member API (D5)", () => {
488
+ async function requestDeletion(): Promise<void> {
489
+ await act(async () => {
490
+ fireEvent.click(screen.getByTestId("account-deletion-request"));
491
+ });
492
+ }
493
+
494
+ it.each([
495
+ ["site-operator", "Sent to the site operator."],
496
+ ["hub-operator", "Sent to the hub operator."],
497
+ ])("an answer naming %s shows %s — the answer picks the line", async (sentTo, line) => {
498
+ mockRequestDeletion.mockResolvedValue(ok({ sentTo }));
499
+ await renderPage();
500
+ await requestDeletion();
501
+ expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
502
+ await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent(line));
503
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
504
+ expect(mockLeadsSubmit).not.toHaveBeenCalled();
505
+ });
506
+
507
+ it("a refusal shows its line and keeps the button", async () => {
508
+ mockRequestDeletion.mockResolvedValue(refusal(403, "email_unverified"));
509
+ await renderPage();
510
+ await requestDeletion();
511
+ await waitFor(() =>
512
+ expect(screen.getByRole("alert")).toHaveTextContent("Confirm your email address first, then ask again."),
513
+ );
514
+ expect(screen.getByTestId("account-deletion-request")).toBeEnabled();
515
+ });
516
+
517
+ it.each([
518
+ [refusal(403, "email_unverified"), "unverified"],
519
+ [refusal(404, "not_found"), "unavailable"],
520
+ [refusal(503, "not_configured"), "unavailable"],
521
+ [refusal(503, "delivery_failed"), "unavailable"],
522
+ [refusal(500, "internal"), "failed"],
523
+ ])("requestAccountDeletion maps %j to %s", async (answer, reason) => {
524
+ mockRequestDeletion.mockResolvedValue(answer);
525
+ await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason });
526
+ expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
527
+ });
528
+
529
+ it("requestAccountDeletion answers who it went to, and a throw is failed", async () => {
530
+ mockRequestDeletion.mockResolvedValue(ok({ sentTo: "hub-operator" }));
531
+ await expect(requestAccountDeletion()).resolves.toEqual({ ok: true, sentTo: "hub-operator" });
532
+ mockRequestDeletion.mockRejectedValue(new TypeError("fetch failed"));
533
+ await expect(requestAccountDeletion()).resolves.toEqual({ ok: false, reason: "failed" });
534
+ });
535
+ });
536
+
537
+ // ── 5. #billing (AC-6) ────────────────────────────────────────────────────────
538
+
539
+ describe("AC-6 — BillingAnchor forwards #billing to Billing (D8)", () => {
540
+ afterEach(() => {
541
+ window.location.hash = "";
542
+ });
543
+
544
+ it("#billing replaces the route with /account/billing, once", () => {
545
+ window.location.hash = "#billing";
546
+ render(<BillingAnchor locale="en" />);
547
+ expect(mockReplace).toHaveBeenCalledTimes(1);
548
+ expect(mockReplace).toHaveBeenCalledWith("/account/billing");
549
+ });
550
+
551
+ it("keeps the locale", () => {
552
+ window.location.hash = "#billing";
553
+ render(<BillingAnchor locale="fr" />);
554
+ expect(mockReplace).toHaveBeenCalledWith("/fr/account/billing");
555
+ });
556
+
557
+ it.each(["#general", ""])("%j stays on General", (hash) => {
558
+ window.location.hash = hash;
559
+ render(<BillingAnchor locale="en" />);
560
+ expect(mockReplace).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it("General carries the anchor", async () => {
564
+ window.location.hash = "#billing";
565
+ await renderPage();
566
+ expect(mockReplace).toHaveBeenCalledWith("/account/billing");
567
+ });
568
+ });