create-ailk 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Tests for the account layout — the base area's SignedInShell frame
3
- * (#7430, spec 7423 U4 · D13; the U4 half of AC-17).
3
+ * (#7430, spec 7423 U4 · D13; the U4 half of AC-17; #8231 D1).
4
4
  *
5
5
  * Covers:
6
- * 1. The rail: the site logo as the top tile (a link home), Inbox as its
7
- * only row, a footer of the avatar and ? only.
6
+ * 1. The rail: the site logo as the top tile (a link home), then Inbox and
7
+ * a Library group holding Artifacts — each selected on its own paths,
8
+ * no row on a Settings page — and a footer of the avatar and ? only.
9
+ * No Settings row (#8334: Settings lives in the avatar menu, as on the
10
+ * Admin).
8
11
  * 2. The avatar opens the card menu (name, then Settings and Log out side
9
- * by side); Log out signs out and routes home.
12
+ * by side); Settings is the one route to /account/general; Log out
13
+ * signs out and routes home.
10
14
  * 3. ? opens Contact support (→ /account/support) and, only when the site
11
15
  * configures a help URL, Help center.
12
16
  * 4. Below md the tab bar reads avatar · Inbox · For you · Help, with no
@@ -55,13 +59,19 @@ jest.mock("../../../../../auth", () => ({
55
59
  auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
56
60
  }));
57
61
 
58
- const mockCheckoutsMe = jest.fn();
59
62
  const mockSubscriptionMe = jest.fn();
63
+ // #8231 D7 — the member reads call on every topology; here every one 404s.
64
+ const mockMemberNotFound = jest.fn();
60
65
  jest.mock("@working-theory/api-client", () => ({
61
66
  __esModule: true,
62
67
  waitlistSignups: { me: jest.fn() },
63
- flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
64
68
  subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
69
+ member: {
70
+ pieces: (...args: unknown[]) => mockMemberNotFound(...args),
71
+ messages: (...args: unknown[]) => mockMemberNotFound(...args),
72
+ purchases: (...args: unknown[]) => mockMemberNotFound(...args),
73
+ appointments: (...args: unknown[]) => mockMemberNotFound(...args),
74
+ },
65
75
  }));
66
76
 
67
77
  const mockSignOut = jest.fn();
@@ -139,8 +149,8 @@ beforeEach(() => {
139
149
  mockSearch = "";
140
150
  delete process.env.AILK_HELP_CENTER_URL;
141
151
  mockGetSession.mockReset().mockResolvedValue(SESSION);
142
- mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [] });
143
152
  mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
153
+ mockMemberNotFound.mockReset().mockResolvedValue({ ok: false, status: 404, body: null });
144
154
  mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
145
155
  mockPush.mockReset();
146
156
  mockRefresh.mockReset();
@@ -167,16 +177,69 @@ describe("the account rail (D13, AC-17)", () => {
167
177
  expect(within(rail).getByTestId("account-site-logo")).toHaveAttribute("href", "/fr/");
168
178
  });
169
179
 
170
- it("Inbox is the rail's only row, selected on /account and on an item page", async () => {
171
- await renderLayout();
172
- const rail = screen.getByTestId("sidebar-desktop");
173
- const links = within(rail)
180
+ /** The rail's rows, in DOM order — every link but the logo. */
181
+ function railRows(): HTMLElement[] {
182
+ return within(screen.getByTestId("sidebar-desktop"))
174
183
  .getAllByRole("link")
175
184
  .filter((link) => link.getAttribute("data-testid") !== "account-site-logo");
176
- expect(links).toHaveLength(1);
177
- expect(links[0]).toHaveTextContent("Inbox");
178
- expect(links[0]).toHaveAttribute("href", "/account");
179
- expect(links[0]).toHaveAttribute("aria-current", "page");
185
+ }
186
+
187
+ it("the rows are Inbox, then Artifacts under Library — no Settings row (#8231 D1, #8334)", async () => {
188
+ await renderLayout();
189
+ const rows = railRows();
190
+ expect(rows.map((row) => [row.textContent, row.getAttribute("href")])).toEqual([
191
+ ["Inbox", "/account"],
192
+ ["Artifacts", "/account/artifacts"],
193
+ ]);
194
+ expect(rows[0]).toHaveAttribute("aria-current", "page");
195
+ // Artifacts sits in the Library group.
196
+ const heading = screen.getByTestId("sidebar-group-heading-library");
197
+ expect(heading).toHaveTextContent("Library");
198
+ const library = heading.parentElement as HTMLElement;
199
+ expect(within(library).getByText("Artifacts")).toBeInTheDocument();
200
+ // #8334 — Settings is nowhere in the rail, at any viewport: not a row, not
201
+ // a tab. The avatar menu is its one route (the next describe).
202
+ expect(within(screen.getByTestId("sidebar-desktop")).queryByText("Settings")).not.toBeInTheDocument();
203
+ expect(within(screen.getByTestId("bottom-tab-bar")).queryByText("Settings")).not.toBeInTheDocument();
204
+ });
205
+
206
+ it.each([
207
+ ["/account", "Inbox"],
208
+ ["/account/inbox/purchase/x", "Inbox"],
209
+ ["/account/artifacts", "Artifacts"],
210
+ ["/account/artifacts/p1", "Artifacts"],
211
+ ])("on %s only %s is selected (#8231 D1)", async (path, selected) => {
212
+ mockPathname = path;
213
+ await renderLayout();
214
+ const current = railRows().filter((row) => row.getAttribute("aria-current") === "page");
215
+ expect(current.map((row) => row.textContent)).toEqual([selected]);
216
+ });
217
+
218
+ it.each([
219
+ "/account/support",
220
+ // #8334 — the Settings pages (and the old path that redirects to General)
221
+ // select no row: Settings has no rail row to select.
222
+ "/account/general",
223
+ "/account/billing",
224
+ "/account/settings",
225
+ "/fr/account/billing",
226
+ ])("no row is selected on %s (#8231 D1, #8334)", async (path) => {
227
+ mockPathname = path;
228
+ await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
229
+ const rows = railRows();
230
+ expect(rows).toHaveLength(2);
231
+ expect(rows.some((row) => row.hasAttribute("aria-current"))).toBe(false);
232
+ });
233
+
234
+ it("a non-default locale prefixes every row (#8231 D1)", async () => {
235
+ mockPathname = "/fr/account/artifacts";
236
+ await renderLayout("fr");
237
+ const rows = railRows();
238
+ expect(rows.map((row) => row.getAttribute("href"))).toEqual([
239
+ "/fr/account",
240
+ "/fr/account/artifacts",
241
+ ]);
242
+ expect(rows[1]).toHaveAttribute("aria-current", "page");
180
243
  });
181
244
 
182
245
  it("an Inbox item page keeps Inbox selected", async () => {
@@ -215,11 +278,28 @@ describe("the avatar menu (D13)", () => {
215
278
  const actions = within(menu).getByTestId("account-menu-actions");
216
279
  expect(within(actions).getByText("Settings").closest("a")).toHaveAttribute(
217
280
  "href",
218
- "/account/settings",
281
+ "/account/general",
219
282
  );
220
283
  expect(within(actions).getByTestId("account-menu-sign-out")).toHaveTextContent("Log out");
221
284
  });
222
285
 
286
+ it("Settings in the menu is the one route to General, locale-prefixed (#8334)", async () => {
287
+ mockPathname = "/fr/account";
288
+ await renderLayout("fr");
289
+ openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
290
+ const actions = within(screen.getByTestId("account-menu-content")).getByTestId(
291
+ "account-menu-actions",
292
+ );
293
+ expect(within(actions).getByText("Settings").closest("a")).toHaveAttribute(
294
+ "href",
295
+ "/fr/account/general",
296
+ );
297
+ // The rail offers no second Settings link.
298
+ expect(
299
+ within(screen.getByTestId("sidebar-desktop")).queryByRole("link", { name: "Settings" }),
300
+ ).not.toBeInTheDocument();
301
+ });
302
+
223
303
  it("Log out signs out, then routes home and refreshes", async () => {
224
304
  await renderLayout();
225
305
  openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
@@ -370,3 +450,22 @@ describe("no session", () => {
370
450
  expect(screen.queryByTestId("sidebar-desktop")).not.toBeInTheDocument();
371
451
  });
372
452
  });
453
+
454
+ describe("the shell marker (#8337 D4, AC-6)", () => {
455
+ it("with a session, exactly one [data-ailk-shell=\"account\"] wraps the shell", async () => {
456
+ await renderLayout();
457
+ const markers = document.querySelectorAll("[data-ailk-shell]");
458
+ expect(markers).toHaveLength(1);
459
+ const marker = markers[0] as HTMLElement;
460
+ expect(marker.getAttribute("data-ailk-shell")).toBe("account");
461
+ expect(marker.getAttribute("data-testid")).toBe("account-shell");
462
+ expect(Array.from(marker.classList)).toEqual(["contents"]);
463
+ expect(within(marker).getByTestId("dashboard-shell")).toBeInTheDocument();
464
+ });
465
+
466
+ it("with no session, no [data-ailk-shell] exists", async () => {
467
+ mockGetSession.mockResolvedValue(null);
468
+ await renderLayout();
469
+ expect(document.querySelector("[data-ailk-shell]")).not.toBeInTheDocument();
470
+ });
471
+ });
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Tests for the member New menu (#8233, spec
3
+ * docs/specs/8233-member-new-menu-the-new-spec.md D1–D4; AC-1, AC-2, AC-6).
4
+ *
5
+ * Covers:
6
+ * 1. New over the Inbox offers exactly Schedule service · Request quote ·
7
+ * Send message, in that order, as menu items that are not links; each
8
+ * opens a popup over the Inbox titled with its label, and nothing
9
+ * navigates (AC-1).
10
+ * 2. Each row is offered only while its member read answers: a 404, or a
11
+ * switched-off request form, leaves it out; every read 404 (a Free
12
+ * build) leaves no New button and no error row; a failed read drops
13
+ * its row AND shows the Inbox error row (AC-2).
14
+ * 3. Placement: on the Inbox route the frame's phone top bar holds the
15
+ * compact New; elsewhere in /account it holds nothing; outside a frame
16
+ * only the desktop New shows, in the h1's header row (AC-6).
17
+ */
18
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
19
+ import type { ReactElement } from "react";
20
+
21
+ // The account suites' next-intl mock: dotted namespaces and dotted keys
22
+ // resolve against en.json, `{name}` placeholders fill from `values`.
23
+ jest.mock("next-intl/server", () => {
24
+ const msgs: unknown = require("../../../../../messages/en.json");
25
+ const dig = (obj: unknown, path: string): unknown =>
26
+ path.split(".").reduce<unknown>(
27
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
28
+ obj,
29
+ );
30
+ return {
31
+ __esModule: true,
32
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
33
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
34
+ const messages = dig(msgs, ns);
35
+ return (key: string, values?: Record<string, unknown>): string => {
36
+ const value = dig(messages, key);
37
+ if (typeof value !== "string") return `${ns}.${key}`;
38
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
39
+ values && name in values ? String(values[name]) : m,
40
+ );
41
+ };
42
+ },
43
+ };
44
+ });
45
+
46
+ jest.mock("next/headers", () => ({
47
+ __esModule: true,
48
+ headers: jest.fn().mockResolvedValue(new Headers()),
49
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
50
+ }));
51
+
52
+ const mockGetSession = jest.fn();
53
+ // 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.
54
+ jest.mock("../../../../../auth", () => ({
55
+ __esModule: true,
56
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
57
+ }));
58
+
59
+ const mockSubscriptionMe = jest.fn();
60
+ const mockMemberPieces = jest.fn();
61
+ const mockMemberMessages = jest.fn();
62
+ const mockMemberPurchases = jest.fn();
63
+ const mockMemberAppointments = jest.fn();
64
+ const mockMemberMeetingTypes = jest.fn();
65
+ const mockMemberRequestForm = jest.fn();
66
+ jest.mock("@working-theory/api-client", () => ({
67
+ __esModule: true,
68
+ waitlistSignups: { me: jest.fn() },
69
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
70
+ member: {
71
+ pieces: (...args: unknown[]) => mockMemberPieces(...args),
72
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
73
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
74
+ appointments: (...args: unknown[]) => mockMemberAppointments(...args),
75
+ meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
76
+ requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
77
+ },
78
+ }));
79
+
80
+ jest.mock("better-auth/react", () => ({
81
+ __esModule: true,
82
+ createAuthClient: () => ({ signOut: jest.fn() }),
83
+ }));
84
+
85
+ const mockPush = jest.fn();
86
+ const mockRefresh = jest.fn();
87
+ let mockPathname = "/account";
88
+ jest.mock("next/navigation", () => ({
89
+ __esModule: true,
90
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
91
+ usePathname: () => mockPathname,
92
+ useSearchParams: () => new URLSearchParams(),
93
+ }));
94
+
95
+ import { PhoneTabProvider } from "@working-theory/templates/account";
96
+ import { requestQuoteFlowConfig } from "@working-theory/validation";
97
+
98
+ import AccountLayout from "../layout";
99
+ import { NewMenu } from "../new-menu";
100
+ import Page from "../page";
101
+
102
+ const SESSION = {
103
+ user: {
104
+ id: "user_1",
105
+ name: "Ada",
106
+ email: "ada@example.com",
107
+ emailVerified: true,
108
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
109
+ },
110
+ };
111
+
112
+ /** The cookie a single site forwards (`forwardedCookieInit()`, no Origin here). */
113
+ const INIT = { headers: { cookie: "session=abc" } };
114
+
115
+ const notFound = {
116
+ ok: false,
117
+ status: 404,
118
+ body: { ok: false, error: { code: "not_found", message: "Not found" } },
119
+ };
120
+ const failure = (status: number) => ({
121
+ ok: false,
122
+ status,
123
+ body: { ok: false, error: { code: "failed", message: "x" } },
124
+ });
125
+ const ok = <T,>(body: T) => ({ ok: true, body });
126
+
127
+ const TYPE = { slug: "consult", title: "Consultation", durationMinutes: 30 };
128
+ const FORM = {
129
+ form: { title: "Request a quote", defaultLocale: "en", steps: requestQuoteFlowConfig.steps },
130
+ };
131
+
132
+ const LABELS = ["Schedule service", "Request quote", "Send message"];
133
+
134
+ beforeAll(() => {
135
+ // Radix menus open on pointerdown; jsdom has no PointerEvent.
136
+ if (typeof globalThis.PointerEvent === "undefined") {
137
+ // eslint-disable-next-line no-restricted-syntax -- a MouseEvent stands in for jsdom's missing PointerEvent
138
+ globalThis.PointerEvent = MouseEvent as unknown as typeof PointerEvent;
139
+ }
140
+ });
141
+
142
+ beforeEach(() => {
143
+ window.localStorage.clear();
144
+ mockPathname = "/account";
145
+ mockPush.mockReset();
146
+ mockRefresh.mockReset();
147
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
148
+ mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
149
+ mockMemberPieces.mockReset().mockResolvedValue(notFound);
150
+ mockMemberPurchases.mockReset().mockResolvedValue(notFound);
151
+ mockMemberAppointments.mockReset().mockResolvedValue(notFound);
152
+ // Every action offered unless a case says otherwise.
153
+ mockMemberMeetingTypes.mockReset().mockResolvedValue(ok({ items: [TYPE] }));
154
+ mockMemberRequestForm.mockReset().mockResolvedValue(ok(FORM));
155
+ mockMemberMessages.mockReset().mockResolvedValue(ok({ items: [] }));
156
+ });
157
+
158
+ function openByPointer(trigger: HTMLElement): void {
159
+ fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
160
+ }
161
+
162
+ async function renderInbox(): Promise<void> {
163
+ const element = await Page({ params: Promise.resolve({ locale: "en" }) });
164
+ render(<PhoneTabProvider tab="inbox">{element}</PhoneTabProvider>);
165
+ }
166
+
167
+ /** The New menu's rows, as their visible names. */
168
+ function openMenu(trigger = screen.getByTestId("account-new-menu-trigger")): string[] {
169
+ openByPointer(trigger);
170
+ const content = screen.getByTestId("account-new-menu-content");
171
+ return within(content)
172
+ .getAllByRole("menuitem")
173
+ .map((item) => item.textContent ?? "");
174
+ }
175
+
176
+ describe("New offers the three actions, each a popup over the Inbox (AC-1)", () => {
177
+ it("lists exactly Schedule service · Request quote · Send message, none a link", async () => {
178
+ await renderInbox();
179
+ expect(openMenu()).toEqual(LABELS);
180
+ const content = screen.getByTestId("account-new-menu-content");
181
+ expect(content.querySelector("a")).toBeNull();
182
+ expect(within(content).getByTestId("account-new-menu-item-schedule-service")).toBeInTheDocument();
183
+ expect(within(content).getByTestId("account-new-menu-item-request-quote")).toBeInTheDocument();
184
+ expect(within(content).getByTestId("account-new-menu-item-send-message")).toBeInTheDocument();
185
+ });
186
+
187
+ it.each(LABELS.map((label, i) => [label, i] as const))(
188
+ "%s opens a popup titled with its label over the Inbox, and never navigates",
189
+ async (label, index) => {
190
+ await renderInbox();
191
+ openMenu();
192
+ fireEvent.click(screen.getAllByRole("menuitem")[index]!);
193
+ const popup = await screen.findByTestId("account-new-popup");
194
+ expect(within(popup).getByRole("heading", { name: label })).toBeInTheDocument();
195
+ expect(screen.getByTestId("account-inbox")).toBeInTheDocument();
196
+ expect(mockPush).not.toHaveBeenCalled();
197
+ // Close leaves the Inbox as it was, with nothing refreshed.
198
+ fireEvent.click(within(popup).getByRole("button", { name: "Close" }));
199
+ await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
200
+ expect(mockRefresh).not.toHaveBeenCalled();
201
+ },
202
+ );
203
+ });
204
+
205
+ describe("each action is offered only while its read answers (AC-2)", () => {
206
+ it("meetingTypes 404 → Request quote and Send message only", async () => {
207
+ mockMemberMeetingTypes.mockResolvedValue(notFound);
208
+ await renderInbox();
209
+ expect(openMenu()).toEqual(["Request quote", "Send message"]);
210
+ });
211
+
212
+ it.each([
213
+ ["{ form: null }", ok({ form: null })],
214
+ ["404", notFound],
215
+ ])("requestForm %s → no Request quote", async (_name, answer) => {
216
+ mockMemberRequestForm.mockResolvedValue(answer);
217
+ await renderInbox();
218
+ expect(openMenu()).toEqual(["Schedule service", "Send message"]);
219
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
220
+ });
221
+
222
+ it("meetingTypes { items: [] } → Schedule service is still offered (its popup says why)", async () => {
223
+ mockMemberMeetingTypes.mockResolvedValue(ok({ items: [] }));
224
+ await renderInbox();
225
+ expect(openMenu()).toEqual(LABELS);
226
+ });
227
+
228
+ it("every member read 404 (a Free build) → no New button and no error row", async () => {
229
+ mockMemberMeetingTypes.mockResolvedValue(notFound);
230
+ mockMemberRequestForm.mockResolvedValue(notFound);
231
+ mockMemberMessages.mockResolvedValue(notFound);
232
+ await renderInbox();
233
+ expect(screen.queryByTestId("account-new-menu-trigger")).not.toBeInTheDocument();
234
+ expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
235
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
236
+ });
237
+
238
+ it("requestForm 500 → no Request quote, and the Inbox error row (D12 parity)", async () => {
239
+ mockMemberRequestForm.mockResolvedValue(failure(500));
240
+ await renderInbox();
241
+ expect(openMenu()).toEqual(["Schedule service", "Send message"]);
242
+ expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
243
+ });
244
+
245
+ it("each availability read receives the request's init unchanged", async () => {
246
+ await renderInbox();
247
+ expect(mockMemberMeetingTypes).toHaveBeenCalledWith(INIT);
248
+ expect(mockMemberRequestForm).toHaveBeenCalledWith(INIT);
249
+ expect(mockMemberMessages).toHaveBeenCalledWith(INIT);
250
+ });
251
+
252
+ it("signed out → no New button and no read", async () => {
253
+ mockGetSession.mockResolvedValue(null);
254
+ await renderInbox();
255
+ expect(screen.queryByTestId("account-new-menu-trigger")).not.toBeInTheDocument();
256
+ expect(mockMemberMeetingTypes).not.toHaveBeenCalled();
257
+ });
258
+ });
259
+
260
+ describe("placement — the h1's row from md up, the phone top bar below (AC-6)", () => {
261
+ const ACTIONS = LABELS.map((label, i) => ({
262
+ id: ["schedule-service", "request-quote", "send-message"][i]!,
263
+ label,
264
+ popup: <p>{label} body</p>,
265
+ }));
266
+
267
+ function InboxChild(): ReactElement {
268
+ return <NewMenu actions={ACTIONS} labels={{ trigger: "New", close: "Close" }} />;
269
+ }
270
+
271
+ async function renderLayout(): Promise<void> {
272
+ const element = await AccountLayout({
273
+ children: <InboxChild />,
274
+ params: Promise.resolve({ locale: "en" }),
275
+ });
276
+ render(element);
277
+ }
278
+
279
+ it("on /account the compact New sits in the top bar's trailing cell and opens the same menu", async () => {
280
+ await renderLayout();
281
+ const end = screen.getByTestId("sidebar-topbar-end");
282
+ const compact = await within(end).findByTestId("account-new-menu-trigger-compact");
283
+ expect(compact).toHaveAccessibleName("New");
284
+ expect(openMenu(compact)).toEqual(LABELS);
285
+ });
286
+
287
+ it("on /account/settings the top bar holds no New slot", async () => {
288
+ mockPathname = "/account/settings";
289
+ await renderLayout();
290
+ expect(screen.queryByTestId("account-top-bar-actions")).not.toBeInTheDocument();
291
+ expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
292
+ });
293
+
294
+ it("outside a frame there is no compact New, and New sits in the h1's header row", async () => {
295
+ await renderInbox();
296
+ expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
297
+ const heading = screen.getByRole("heading", { level: 1 });
298
+ expect(heading.parentElement).toContainElement(screen.getByTestId("account-new-menu-trigger"));
299
+ });
300
+
301
+ it("with no action NewMenu renders nothing", () => {
302
+ const { container } = render(<NewMenu actions={[]} labels={{ trigger: "New", close: "Close" }} />);
303
+ expect(container).toBeEmptyDOMElement();
304
+ });
305
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Tests for account/new-popups.tsx — the New actions with their popups bound
3
+ * (#8233 D1, D2, D6–D8; spec AC-7's popup half, moved here with the popups:
4
+ * `lib/account-config.ts` may import nothing under `(authed)`, so the
5
+ * binding lives in the route).
6
+ *
7
+ * Covers:
8
+ * 1. `newActions` is `accountNewActions` in menu order, each read untouched
9
+ * (the same `available` function the config exports).
10
+ * 2. Each popup's props carry no credential and no email — only the locale
11
+ * and strings (SEC-8233-1).
12
+ */
13
+
14
+ jest.mock("@working-theory/api-client", () => ({
15
+ __esModule: true,
16
+ waitlistSignups: { me: jest.fn() },
17
+ subscription: { me: jest.fn() },
18
+ member: {
19
+ pieces: jest.fn(),
20
+ messages: jest.fn(),
21
+ purchases: jest.fn(),
22
+ appointments: jest.fn(),
23
+ meetingTypes: jest.fn(),
24
+ requestForm: jest.fn(),
25
+ },
26
+ }));
27
+
28
+ import type {
29
+ AccountContext,
30
+ AccountNewAction,
31
+ } from "@working-theory/templates/account";
32
+ import type { ReactElement } from "react";
33
+
34
+ import { accountNewActions } from "../../../../../lib/account-config";
35
+ import { newActions } from "../new-popups";
36
+
37
+ const INIT = { headers: { cookie: "session=abc" } };
38
+
39
+ function ctx(): AccountContext {
40
+ return {
41
+ locale: "en",
42
+ user: { name: "Ada", email: "ada@example.com", emailVerified: true },
43
+ t: (key) => `t:${key}`,
44
+ init: INIT,
45
+ };
46
+ }
47
+
48
+ function action(id: string): AccountNewAction {
49
+ const found = newActions.find((a) => a.id === id);
50
+ if (!found) throw new Error(`no New action ${id}`);
51
+ return found;
52
+ }
53
+
54
+ describe("newActions — the config's actions with their popups (#8233 D1, D2)", () => {
55
+ it("is accountNewActions in menu order, each read the config's own", () => {
56
+ expect(newActions.map((a) => [a.id, a.label])).toEqual(
57
+ accountNewActions.map((a) => [a.id, a.label]),
58
+ );
59
+ expect(newActions.map((a) => a.id)).toEqual([
60
+ "schedule-service",
61
+ "request-quote",
62
+ "send-message",
63
+ ]);
64
+ newActions.forEach((a, i) => {
65
+ expect(a.available).toBe(accountNewActions[i]!.available);
66
+ });
67
+ });
68
+
69
+ it.each(["schedule-service", "request-quote", "send-message"])(
70
+ "%s renders a popup whose props carry no cookie and no email (SEC-8233-1)",
71
+ (id) => {
72
+ const popup = action(id).renderPopup(ctx()) as ReactElement<
73
+ Record<string, unknown>
74
+ >;
75
+ const props = JSON.stringify(popup.props);
76
+ expect(props).not.toContain("session=abc");
77
+ expect(props).not.toContain("ada@example.com");
78
+ expect(popup.props).toMatchObject({ locale: "en" });
79
+ expect(Object.keys(popup.props).sort()).toEqual(["labels", "locale"]);
80
+ },
81
+ );
82
+ });