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,514 @@
1
+ /**
2
+ * The Artifacts pages (#8234, spec 8234 D3, D4; #8338, spec
3
+ * docs/specs/8338-account-artifacts-structured-like-the-spec.md D3, D7,
4
+ * AC-3).
5
+ *
6
+ * `/account/artifacts` and `/account/artifacts/[pieceId]`, rendered as the
7
+ * RSCs they are, with the api-client's `member.artifacts` as the one mocked
8
+ * read. Covers:
9
+ * - the list renders `AccountListFrame`'s table in the read's order (newest
10
+ * delivery first, never by title), each row's Latest/Delivered/Review
11
+ * cells and caption reading the LATEST version's own number, its
12
+ * delivery date and the review state — approval first, then the latest
13
+ * version's review — and the facet nav's counts over every artifact;
14
+ * - a 404 (and a 401/403 while the email is unverified) is the empty line;
15
+ * any other failure is the error row with Retry and no frame (7423 D12);
16
+ * - the artifact page lists versions newest first, each with its number,
17
+ * delivery date and note, and Download (this site's file route) or View
18
+ * (an https link, new tab, noopener) — and nothing for a link that is not
19
+ * https; no deliverer is shown;
20
+ * - the review form shows on the latest version only, only while the
21
+ * artifact is not approved and that version has no review; after a
22
+ * Request changes the page says the next version will appear there.
23
+ */
24
+
25
+ import { fireEvent, render, screen, within } from "@testing-library/react";
26
+
27
+ jest.mock("next-intl/server", () => {
28
+ const msgs: unknown = require("../../../../../../messages/en.json");
29
+ const dig = (obj: unknown, path: string): unknown =>
30
+ path
31
+ .split(".")
32
+ .reduce<unknown>(
33
+ (acc, k) =>
34
+ acc !== null && typeof acc === "object"
35
+ ? (acc as Record<string, unknown>)[k]
36
+ : undefined,
37
+ obj,
38
+ );
39
+ return {
40
+ __esModule: true,
41
+ getTranslations: async (
42
+ arg: string | { namespace?: string; locale?: string },
43
+ ) => {
44
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
45
+ const messages = dig(msgs, ns);
46
+ return (key: string, values?: Record<string, unknown>): string => {
47
+ const value = dig(messages, key);
48
+ if (typeof value !== "string") return `${ns}.${key}`;
49
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
50
+ values && name in values ? String(values[name]) : m,
51
+ );
52
+ };
53
+ },
54
+ };
55
+ });
56
+
57
+ jest.mock("next/headers", () => ({
58
+ __esModule: true,
59
+ headers: jest.fn().mockResolvedValue(new Headers()),
60
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
61
+ }));
62
+
63
+ const mockGetSession = jest.fn();
64
+ // 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.
65
+ jest.mock("../../../../../../auth", () => ({
66
+ __esModule: true,
67
+ auth: {
68
+ api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
69
+ },
70
+ }));
71
+
72
+ const mockArtifacts = jest.fn();
73
+ jest.mock("@working-theory/api-client", () => ({
74
+ __esModule: true,
75
+ waitlistSignups: { me: jest.fn() },
76
+ subscription: { me: jest.fn() },
77
+ member: { artifacts: (...args: unknown[]) => mockArtifacts(...args) },
78
+ }));
79
+
80
+ const mockNotFound = jest.fn(() => {
81
+ throw new Error("NEXT_NOT_FOUND");
82
+ });
83
+ const mockRefresh = jest.fn();
84
+ jest.mock("next/navigation", () => ({
85
+ __esModule: true,
86
+ notFound: () => mockNotFound(),
87
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
88
+ usePathname: () => "/account/artifacts",
89
+ }));
90
+
91
+ // jsdom does not implement the pointer-capture / scroll APIs Radix Select
92
+ // relies on to open its listbox (packages/ui's own established precedent —
93
+ // packages/ui/src/__tests__/setup.ts).
94
+ beforeAll(() => {
95
+ Element.prototype.hasPointerCapture = (): boolean => false;
96
+ Element.prototype.setPointerCapture = (): void => {};
97
+ Element.prototype.releasePointerCapture = (): void => {};
98
+ Element.prototype.scrollIntoView = (): void => {};
99
+ });
100
+
101
+ import ArtifactPage from "../[pieceId]/page";
102
+ import ListPage from "../page";
103
+
104
+ const VERIFIED = {
105
+ user: {
106
+ id: "user_1",
107
+ name: "Ada",
108
+ email: "ada@example.com",
109
+ emailVerified: true,
110
+ },
111
+ };
112
+
113
+ const ID = {
114
+ zebra: "piece_01920000-0000-7000-8000-00000000000a",
115
+ mango: "piece_01920000-0000-7000-8000-00000000000b",
116
+ alpha: "piece_01920000-0000-7000-8000-00000000000c",
117
+ };
118
+
119
+ interface VersionFixture {
120
+ version: number;
121
+ file?: { filename: string; contentType: string; byteSize: number } | null;
122
+ link?: string | null;
123
+ note?: string | null;
124
+ deliveredAt?: string;
125
+ review?: "approved" | "changes-requested" | null;
126
+ reviewComment?: string | null;
127
+ reviewedAt?: string | null;
128
+ }
129
+
130
+ function version(v: VersionFixture) {
131
+ const hasLink = v.link !== undefined && v.link !== null;
132
+ return {
133
+ version: v.version,
134
+ file: hasLink
135
+ ? null
136
+ : (v.file ?? {
137
+ filename: `v${v.version}.pdf`,
138
+ contentType: "application/pdf",
139
+ byteSize: 10,
140
+ }),
141
+ link: v.link ?? null,
142
+ note: v.note ?? null,
143
+ deliveredAt: v.deliveredAt ?? `2026-09-0${v.version}T12:00:00.000Z`,
144
+ review: v.review ?? null,
145
+ reviewComment: v.reviewComment ?? null,
146
+ reviewedAt: v.reviewedAt ?? (v.review ? "2026-09-20T12:00:00.000Z" : null),
147
+ };
148
+ }
149
+
150
+ function ok(items: unknown[]) {
151
+ return { ok: true, body: { items } };
152
+ }
153
+
154
+ function refused(status: number, code = "x") {
155
+ return {
156
+ ok: false,
157
+ status,
158
+ body: { ok: false, error: { code, message: "x" } },
159
+ };
160
+ }
161
+
162
+ // AC-3's fixture: Zebra (approved, delivered last), Mango (changes requested
163
+ // on the latest version, delivered second — never its OLDER version's date),
164
+ // Alpha (unreviewed) — in the read's own order (never re-sorted by title).
165
+ const ZEBRA = {
166
+ id: ID.zebra,
167
+ title: "Zebra logo",
168
+ approvedAt: "2026-09-25T09:00:00.000Z",
169
+ versions: [version({ version: 3, deliveredAt: "2026-10-03T12:00:00.000Z" })],
170
+ };
171
+ const MANGO = {
172
+ id: ID.mango,
173
+ title: "Mango brief",
174
+ approvedAt: null,
175
+ versions: [
176
+ version({
177
+ version: 2,
178
+ review: "changes-requested",
179
+ reviewComment: "Warmer",
180
+ deliveredAt: "2026-10-02T12:00:00.000Z",
181
+ }),
182
+ version({ version: 1, deliveredAt: "2026-09-20T12:00:00.000Z" }),
183
+ ],
184
+ };
185
+ const ALPHA = {
186
+ id: ID.alpha,
187
+ title: "Alpha deck",
188
+ approvedAt: null,
189
+ versions: [version({ version: 1, deliveredAt: "2026-09-18T12:00:00.000Z" })],
190
+ };
191
+
192
+ beforeEach(() => {
193
+ mockGetSession.mockReset().mockResolvedValue(VERIFIED);
194
+ mockArtifacts.mockReset().mockResolvedValue(ok([ZEBRA, MANGO, ALPHA]));
195
+ mockNotFound.mockClear();
196
+ mockRefresh.mockReset();
197
+ jest.spyOn(console, "error").mockImplementation(() => undefined);
198
+ });
199
+
200
+ afterEach(() => {
201
+ jest.restoreAllMocks();
202
+ });
203
+
204
+ async function renderList(): Promise<void> {
205
+ render(await ListPage({ params: Promise.resolve({ locale: "en" }) }));
206
+ }
207
+
208
+ async function renderArtifact(pieceId: string): Promise<void> {
209
+ render(
210
+ await ArtifactPage({ params: Promise.resolve({ locale: "en", pieceId }) }),
211
+ );
212
+ }
213
+
214
+ const medium = (iso: string): string =>
215
+ new Intl.DateTimeFormat("en", {
216
+ dateStyle: "medium",
217
+ timeZone: "UTC",
218
+ }).format(new Date(iso));
219
+
220
+ function facetItems(): HTMLElement[] {
221
+ return within(screen.getByTestId("artifacts-facets")).getAllByTestId(
222
+ /^artifacts-facets-item-/,
223
+ );
224
+ }
225
+
226
+ describe("/account/artifacts (D3, AC-3)", () => {
227
+ it("renders the table in the read's order, each row's Latest/Delivered/Review and the nav's counts", async () => {
228
+ await renderList();
229
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
230
+ "Artifacts",
231
+ );
232
+
233
+ const table = screen.getByTestId("artifacts-table");
234
+ const titles = within(table).getAllByTestId(/-title$/);
235
+ expect(titles.map((t) => t.textContent)).toEqual([
236
+ "Zebra logo",
237
+ "Mango brief",
238
+ "Alpha deck",
239
+ ]);
240
+ expect(titles.map((t) => t.getAttribute("href"))).toEqual([
241
+ `/account/artifacts/${ID.zebra}`,
242
+ `/account/artifacts/${ID.mango}`,
243
+ `/account/artifacts/${ID.alpha}`,
244
+ ]);
245
+
246
+ const zebraRow = screen.getByTestId(`artifacts-table-row-${ID.zebra}`);
247
+ expect(zebraRow).toHaveTextContent("Version 3");
248
+ expect(zebraRow).toHaveTextContent(medium("2026-10-03T12:00:00.000Z"));
249
+ expect(zebraRow).toHaveTextContent("Approved");
250
+
251
+ const mangoRow = screen.getByTestId(`artifacts-table-row-${ID.mango}`);
252
+ expect(mangoRow).toHaveTextContent(medium("2026-10-02T12:00:00.000Z"));
253
+ expect(mangoRow).not.toHaveTextContent(medium("2026-09-20T12:00:00.000Z"));
254
+ expect(mangoRow).toHaveTextContent("Changes requested");
255
+
256
+ const alphaRow = screen.getByTestId(`artifacts-table-row-${ID.alpha}`);
257
+ expect(alphaRow).toHaveTextContent("Ready for review");
258
+
259
+ const captions = screen.getAllByTestId("artifacts-row");
260
+ // The phone rows render the same read, so their captions exist in the DOM
261
+ // even though only the table is visible at this (desktop) width.
262
+ expect(captions.map((c) => c.textContent)).toEqual([
263
+ expect.stringContaining("Version 3 · Approved"),
264
+ expect.stringContaining("Version 2 · Changes requested"),
265
+ expect.stringContaining("Version 1 · Ready for review"),
266
+ ]);
267
+
268
+ const items = facetItems();
269
+ expect(items.map((i) => i.getAttribute("data-testid"))).toEqual([
270
+ "artifacts-facets-item-all",
271
+ "artifacts-facets-item-ready",
272
+ "artifacts-facets-item-changes-requested",
273
+ "artifacts-facets-item-approved",
274
+ ]);
275
+ expect(items[0]).toHaveTextContent("All artifacts");
276
+ expect(items[0]).toHaveTextContent("3");
277
+ expect(items[1]).toHaveTextContent("1");
278
+ expect(items[2]).toHaveTextContent("1");
279
+ expect(items[3]).toHaveTextContent("1");
280
+ expect(screen.queryByTestId("inbox-error")).toBeNull();
281
+ });
282
+
283
+ it("a 404 read shows the empty line and a nav at 0, not an error row", async () => {
284
+ mockArtifacts.mockResolvedValue(refused(404, "not_found"));
285
+ await renderList();
286
+ expect(screen.getByTestId("artifacts-empty")).toHaveTextContent(
287
+ "Nothing has been delivered to you yet.",
288
+ );
289
+ for (const item of facetItems()) expect(item).toHaveTextContent("0");
290
+ expect(screen.queryByTestId("inbox-error")).toBeNull();
291
+ });
292
+
293
+ it("a 403 while the email is unverified shows the empty line too", async () => {
294
+ mockGetSession.mockResolvedValue({
295
+ user: { ...VERIFIED.user, emailVerified: false },
296
+ });
297
+ mockArtifacts.mockResolvedValue(refused(403, "forbidden"));
298
+ await renderList();
299
+ expect(screen.getByTestId("artifacts-empty")).toBeInTheDocument();
300
+ expect(screen.queryByTestId("inbox-error")).toBeNull();
301
+ });
302
+
303
+ it.each([
304
+ ["a 500", refused(500)],
305
+ ["a status-0 refusal", refused(0, "insecure_transport")],
306
+ ])("%s shows the error row with Retry and no frame", async (_case, res) => {
307
+ mockArtifacts.mockResolvedValue(res);
308
+ await renderList();
309
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent(
310
+ "We couldn't load your artifacts.",
311
+ );
312
+ expect(screen.queryByTestId("artifacts-facets")).toBeNull();
313
+ expect(screen.queryByTestId("artifacts-table")).toBeNull();
314
+ expect(screen.queryByTestId("artifacts-empty")).toBeNull();
315
+ fireEvent.click(screen.getByTestId("inbox-retry"));
316
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
317
+ });
318
+
319
+ // The (authed) layout renders the sign-in gate for a missing session and
320
+ // the page renders in parallel with it, so a `notFound()` here streamed a
321
+ // 404 digest over the gate (the scaffold render sweep's
322
+ // `/account/artifacts -> streamed not-found`). Like `/account/general`, the
323
+ // page now renders its heading alone and reads nothing.
324
+ it("no session renders the heading alone and loads nothing", async () => {
325
+ mockGetSession.mockResolvedValue(null);
326
+ await renderList();
327
+ expect(screen.getByTestId("artifacts")).toBeInTheDocument();
328
+ expect(screen.queryByTestId("artifacts-empty")).toBeNull();
329
+ expect(screen.queryByTestId("inbox-error")).toBeNull();
330
+ expect(mockArtifacts).not.toHaveBeenCalled();
331
+ });
332
+ });
333
+
334
+ const VERSIONED = {
335
+ id: ID.alpha,
336
+ title: "Alpha deck",
337
+ approvedAt: null,
338
+ versions: [
339
+ version({
340
+ version: 3,
341
+ file: {
342
+ filename: "brief-v3.pdf",
343
+ contentType: "application/pdf",
344
+ byteSize: 2048,
345
+ },
346
+ note: "Final colours\nand the new mark",
347
+ deliveredAt: "2026-09-12T10:00:00.000Z",
348
+ }),
349
+ version({
350
+ version: 2,
351
+ link: "https://files.example.com/v2",
352
+ note: "Second pass",
353
+ deliveredAt: "2026-09-08T10:00:00.000Z",
354
+ review: "changes-requested",
355
+ reviewComment: "Bigger logo",
356
+ }),
357
+ version({
358
+ version: 1,
359
+ link: "http://insecure.example.com/v1",
360
+ note: "First draft",
361
+ deliveredAt: "2026-09-01T10:00:00.000Z",
362
+ review: "changes-requested",
363
+ reviewComment: "Warmer",
364
+ }),
365
+ ],
366
+ };
367
+
368
+ describe("/account/artifacts/[pieceId] (D4, AC-2)", () => {
369
+ beforeEach(() => {
370
+ mockArtifacts.mockResolvedValue(ok([VERSIONED]));
371
+ });
372
+
373
+ it("lists the versions newest first, each with its number, delivery date and note — and no deliverer", async () => {
374
+ await renderArtifact(ID.alpha);
375
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
376
+ "Alpha deck",
377
+ );
378
+ expect(screen.getByTestId("artifact-back")).toHaveAttribute(
379
+ "href",
380
+ "/account/artifacts",
381
+ );
382
+ const blocks = screen.getAllByTestId(/^artifact-version-\d+$/);
383
+ expect(blocks.map((b) => b.getAttribute("data-testid"))).toEqual([
384
+ "artifact-version-3",
385
+ "artifact-version-2",
386
+ "artifact-version-1",
387
+ ]);
388
+ for (const v of VERSIONED.versions) {
389
+ const block = screen.getByTestId(`artifact-version-${v.version}`);
390
+ expect(block).toHaveTextContent(`Version ${v.version}`);
391
+ expect(block).toHaveTextContent(`Delivered ${medium(v.deliveredAt)}`);
392
+ expect(block).toHaveTextContent(v.note!.replace("\n", " "));
393
+ }
394
+ expect(
395
+ within(screen.getByTestId("artifact-version-2")).getByText("Bigger logo"),
396
+ ).toBeInTheDocument();
397
+ expect(screen.getByTestId("artifact").textContent).not.toMatch(
398
+ /delivered by/i,
399
+ );
400
+ });
401
+
402
+ it("a file version downloads through this site's route; an https link is View in a new tab; a non-https link has no action", async () => {
403
+ await renderArtifact(ID.alpha);
404
+ const download = screen.getByTestId("artifact-version-3-download");
405
+ expect(download).toHaveAttribute(
406
+ "href",
407
+ `/account/artifacts/${ID.alpha}/versions/3/file`,
408
+ );
409
+ expect(download).toHaveTextContent("Download brief-v3.pdf");
410
+ expect(screen.queryByTestId("artifact-version-3-view")).toBeNull();
411
+
412
+ const view = screen.getByTestId("artifact-version-2-view");
413
+ expect(view).toHaveAttribute("href", "https://files.example.com/v2");
414
+ expect(view).toHaveAttribute("target", "_blank");
415
+ expect(view).toHaveAttribute("rel", "noopener noreferrer");
416
+ expect(view).toHaveTextContent("View");
417
+
418
+ const v1 = screen.getByTestId("artifact-version-1");
419
+ expect(within(v1).queryByRole("link")).toBeNull();
420
+ expect(screen.queryByTestId("artifact-version-1-view")).toBeNull();
421
+ expect(screen.queryByTestId("artifact-version-1-download")).toBeNull();
422
+ });
423
+
424
+ it("an encoded id segment still finds the artifact", async () => {
425
+ await renderArtifact(encodeURIComponent(ID.alpha));
426
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
427
+ "Alpha deck",
428
+ );
429
+ });
430
+
431
+ it("an id the read did not return is a not-found", async () => {
432
+ await expect(renderArtifact(ID.zebra)).rejects.toThrow("NEXT_NOT_FOUND");
433
+ });
434
+
435
+ it("a 404 read is a not-found", async () => {
436
+ mockArtifacts.mockResolvedValue(refused(404, "not_found"));
437
+ await expect(renderArtifact(ID.alpha)).rejects.toThrow("NEXT_NOT_FOUND");
438
+ });
439
+
440
+ it("a 500 read renders the error row with Retry, not a not-found", async () => {
441
+ mockArtifacts.mockResolvedValue(refused(500));
442
+ await renderArtifact(ID.alpha);
443
+ expect(mockNotFound).not.toHaveBeenCalled();
444
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent(
445
+ "We couldn't load this artifact.",
446
+ );
447
+ expect(screen.getByTestId("inbox-retry")).toHaveTextContent("Retry");
448
+ });
449
+ });
450
+
451
+ describe("the review form's place (D4, C3, AC-4)", () => {
452
+ const FORM_IDS = [
453
+ "artifact-review-approve",
454
+ "artifact-review-request-changes",
455
+ "artifact-review-comment",
456
+ ];
457
+
458
+ it("shows once, inside the latest version, while the artifact is not approved and that version is unreviewed", async () => {
459
+ mockArtifacts.mockResolvedValue(ok([VERSIONED]));
460
+ await renderArtifact(ID.alpha);
461
+ for (const id of FORM_IDS) {
462
+ expect(screen.getAllByTestId(id)).toHaveLength(1);
463
+ expect(
464
+ within(screen.getByTestId("artifact-version-3")).getByTestId(id),
465
+ ).toBeInTheDocument();
466
+ }
467
+ expect(screen.getByTestId("artifact-status")).toHaveTextContent(
468
+ "Ready for review",
469
+ );
470
+ expect(screen.queryByTestId("artifact-review-waiting")).toBeNull();
471
+ });
472
+
473
+ it("is absent everywhere once the artifact is approved, which shows Approved and the date", async () => {
474
+ const approvedAt = "2026-09-30T08:00:00.000Z";
475
+ mockArtifacts.mockResolvedValue(ok([{ ...VERSIONED, approvedAt }]));
476
+ await renderArtifact(ID.alpha);
477
+ for (const id of FORM_IDS) expect(screen.queryByTestId(id)).toBeNull();
478
+ expect(screen.getByTestId("artifact-status")).toHaveTextContent("Approved");
479
+ expect(screen.getByTestId("artifact")).toHaveTextContent(
480
+ `Approved ${medium(approvedAt)}`,
481
+ );
482
+ expect(screen.queryByTestId("artifact-review-waiting")).toBeNull();
483
+ });
484
+
485
+ it("is absent on a latest version the member already asked changes on, which says the next version will appear here", async () => {
486
+ const [latest, ...rest] = VERSIONED.versions;
487
+ const reviewed = {
488
+ ...VERSIONED,
489
+ versions: [
490
+ {
491
+ ...latest!,
492
+ review: "changes-requested",
493
+ reviewComment: "Tighten the logo",
494
+ reviewedAt: "2026-09-13T10:00:00.000Z",
495
+ },
496
+ ...rest,
497
+ ],
498
+ };
499
+ mockArtifacts.mockResolvedValue(ok([reviewed]));
500
+ await renderArtifact(ID.alpha);
501
+ for (const id of FORM_IDS) expect(screen.queryByTestId(id)).toBeNull();
502
+ const v3 = screen.getByTestId("artifact-version-3");
503
+ expect(within(v3).getByTestId("artifact-review-waiting")).toHaveTextContent(
504
+ "You requested changes. The next version will appear here.",
505
+ );
506
+ expect(v3).toHaveTextContent(
507
+ `You requested changes on ${medium("2026-09-13T10:00:00.000Z")}.`,
508
+ );
509
+ expect(within(v3).getByText("Tighten the logo")).toBeInTheDocument();
510
+ expect(screen.getByTestId("artifact-status")).toHaveTextContent(
511
+ "Changes requested",
512
+ );
513
+ });
514
+ });