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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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
|
+
});
|