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
@@ -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
+ });