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
@@ -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
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Tests for /[locale]/account/notifications — User settings › Notifications
3
+ * (#8339 D8, NEW). Moved from general.test.tsx's "Privacy" cookie-settings
4
+ * link (#8339 D12, C6) — notification preferences are a later unit's scope.
5
+ */
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { render, screen, within } from "@testing-library/react";
11
+
12
+ jest.mock("next-intl/server", () => {
13
+ const msgs: unknown = require("../../../../../messages/en.json");
14
+ const dig = (obj: unknown, path: string): unknown =>
15
+ path.split(".").reduce<unknown>(
16
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
17
+ obj,
18
+ );
19
+ return {
20
+ __esModule: true,
21
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
22
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
23
+ const messages = dig(msgs, ns);
24
+ return (key: string, values?: Record<string, unknown>): string => {
25
+ const value = dig(messages, key);
26
+ if (typeof value !== "string") return `${ns}.${key}`;
27
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
28
+ values && name in values ? String(values[name]) : m,
29
+ );
30
+ };
31
+ },
32
+ };
33
+ });
34
+
35
+ jest.mock("next/headers", () => ({
36
+ __esModule: true,
37
+ headers: jest.fn().mockResolvedValue(new Headers()),
38
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
39
+ }));
40
+
41
+ const mockGetSession = jest.fn();
42
+ // 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.
43
+ jest.mock("../../../../../auth", () => ({
44
+ __esModule: true,
45
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
46
+ }));
47
+
48
+ jest.mock("@working-theory/api-client", () => ({
49
+ __esModule: true,
50
+ waitlistSignups: { me: jest.fn() },
51
+ flowCheckouts: { me: jest.fn() },
52
+ subscription: { me: jest.fn(), portalSession: jest.fn() },
53
+ leads: { submit: jest.fn() },
54
+ member: { pieces: jest.fn().mockResolvedValue({ ok: true, body: [] }) },
55
+ hubSignIn: { me: jest.fn() },
56
+ visitorForwardHeaders: () => null,
57
+ }));
58
+
59
+ jest.mock("next/navigation", () => ({
60
+ __esModule: true,
61
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
62
+ usePathname: () => "/account/notifications",
63
+ redirect: jest.fn(),
64
+ }));
65
+
66
+ import Page from "../notifications/page";
67
+
68
+ const SESSION = {
69
+ user: {
70
+ id: "user_1",
71
+ name: "Ada Lovelace",
72
+ email: "ada@example.com",
73
+ emailVerified: true,
74
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
75
+ },
76
+ };
77
+
78
+ let contentRoot: string;
79
+
80
+ beforeEach(() => {
81
+ contentRoot = mkdtempSync(join(tmpdir(), "ailk-notifications-"));
82
+ process.env.AILK_CONTENT_ROOT = contentRoot;
83
+ writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
84
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
85
+ });
86
+
87
+ afterEach(() => {
88
+ rmSync(contentRoot, { recursive: true, force: true });
89
+ });
90
+
91
+ async function renderPage(locale = "en"): Promise<void> {
92
+ render(await Page({ params: Promise.resolve({ locale }) }));
93
+ }
94
+
95
+ describe("the frame (D7, D8)", () => {
96
+ it("renders in the settings frame with Notifications current", async () => {
97
+ await renderPage();
98
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Notifications");
99
+ expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
100
+ expect(screen.getByTestId("account-settings-rail-notifications")).toHaveAttribute("aria-current", "page");
101
+ expect(screen.getByTestId("account-settings-rail-billing")).not.toHaveAttribute("aria-current");
102
+ });
103
+ });
104
+
105
+ describe("the standalone shape (#8430)", () => {
106
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
107
+ await renderPage();
108
+ const settings = screen.getByTestId("account-settings");
109
+ expect(settings).toHaveClass("min-h-screen");
110
+ const column = within(settings).getByRole("complementary");
111
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
112
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
113
+ expect(within(settings).getByRole("main")).toContainElement(
114
+ screen.getByRole("heading", { level: 1 }),
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("cookie settings (D7, moved from general.test.tsx)", () => {
120
+ it("links to /privacy#cookie-settings", async () => {
121
+ await renderPage();
122
+ expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
123
+ "href",
124
+ "/privacy#cookie-settings",
125
+ );
126
+ });
127
+
128
+ it("a non-default locale prefixes the link", async () => {
129
+ await renderPage("fr");
130
+ expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute(
131
+ "href",
132
+ "/fr/privacy#cookie-settings",
133
+ );
134
+ });
135
+
136
+ it("renders no deletion button", async () => {
137
+ await renderPage();
138
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
139
+ });
140
+ });