create-ailk 0.1.1 → 0.3.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.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comparison instrument for #5314 — diffs the routes the server ACTUALLY
|
|
3
|
+
* registers against each surface that claims to describe them.
|
|
4
|
+
*
|
|
5
|
+
* Two surfaces describe this API's endpoints: the root index `endpoints`
|
|
6
|
+
* object (`server.ts`) and the OpenAPI `paths` object (`spec.ts`). Before
|
|
7
|
+
* #5314 both were hand-maintained literals, and both had drifted — 11 and 7
|
|
8
|
+
* entries against 68 registered routes, with `/v1/project-listings/public`
|
|
9
|
+
* missing from both and `/v1/billing/usage` missing from the root index.
|
|
10
|
+
*
|
|
11
|
+
* This module only COMPARES. The derivation that keeps the surfaces honest
|
|
12
|
+
* lives in `spec.ts` beside the shapes it produces; keeping the comparison
|
|
13
|
+
* separate is what lets the gate assert against a surface it did not build.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately free of any `fastify` or `spec.ts` import: it takes plain data,
|
|
16
|
+
* so the gate can feed it a SERVED document (fetched over `inject`) rather
|
|
17
|
+
* than the object the code under test just returned.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One route as Fastify registered it. `url` is Fastify-shaped (`:param`). */
|
|
21
|
+
export interface RegisteredRoute {
|
|
22
|
+
/** Uppercase HTTP method. HEAD is excluded — Fastify derives it from GET. */
|
|
23
|
+
method: string;
|
|
24
|
+
/** Fastify path, e.g. `/v1/content/:type/:slug`. */
|
|
25
|
+
url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One surface's disagreement with the registered route table. */
|
|
29
|
+
export interface SurfaceDiff {
|
|
30
|
+
/** Which surface this diff describes. */
|
|
31
|
+
surface: "openapi-paths" | "root-index-endpoints";
|
|
32
|
+
/** `METHOD /path` registered by the server but absent from the surface. */
|
|
33
|
+
missingFromSurface: string[];
|
|
34
|
+
/** `METHOD /path` named by the surface but not registered by the server. */
|
|
35
|
+
orphanedInSurface: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fastify path → OpenAPI path template: `/v1/content/:type` becomes
|
|
40
|
+
* `/v1/content/{type}`. OpenAPI 3.1 has no `:param` form, so a document that
|
|
41
|
+
* published the Fastify string would be describing a path no client can build.
|
|
42
|
+
*/
|
|
43
|
+
export function toOpenApiPath(fastifyPath: string): string {
|
|
44
|
+
return fastifyPath
|
|
45
|
+
.split("/")
|
|
46
|
+
.map((segment) =>
|
|
47
|
+
segment.startsWith(":") ? `{${segment.slice(1)}}` : segment,
|
|
48
|
+
)
|
|
49
|
+
.join("/");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The comparison key both sides normalise to: `GET /v1/content/types`. */
|
|
53
|
+
export function routeKey(method: string, path: string): string {
|
|
54
|
+
return `${method.toUpperCase()} ${path}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Registered routes as OpenAPI-shaped comparison keys. */
|
|
58
|
+
function registeredOpenApiKeys(routes: RegisteredRoute[]): Set<string> {
|
|
59
|
+
return new Set(routes.map((r) => routeKey(r.method, toOpenApiPath(r.url))));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Registered routes as Fastify-shaped comparison keys. */
|
|
63
|
+
function registeredFastifyKeys(routes: RegisteredRoute[]): Set<string> {
|
|
64
|
+
return new Set(routes.map((r) => routeKey(r.method, r.url)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The OpenAPI methods a path item declares. Non-method keys an OpenAPI path
|
|
69
|
+
* item may legally carry (`parameters`, `summary`, `description`, `servers`,
|
|
70
|
+
* `$ref`) are not operations and must not be compared as if they were.
|
|
71
|
+
*/
|
|
72
|
+
const OPENAPI_METHODS = new Set([
|
|
73
|
+
"get",
|
|
74
|
+
"put",
|
|
75
|
+
"post",
|
|
76
|
+
"delete",
|
|
77
|
+
"options",
|
|
78
|
+
"head",
|
|
79
|
+
"patch",
|
|
80
|
+
"trace",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Comparison keys a served OpenAPI `paths` object claims.
|
|
85
|
+
*
|
|
86
|
+
* Takes the served document's `paths` as plain data so the gate can compare
|
|
87
|
+
* what an agent would actually fetch, not what the builder returned.
|
|
88
|
+
*/
|
|
89
|
+
export function openApiSurfaceKeys(
|
|
90
|
+
paths: Record<string, Record<string, unknown>>,
|
|
91
|
+
): Set<string> {
|
|
92
|
+
const keys = new Set<string>();
|
|
93
|
+
for (const [path, item] of Object.entries(paths ?? {})) {
|
|
94
|
+
for (const method of Object.keys(item ?? {})) {
|
|
95
|
+
if (!OPENAPI_METHODS.has(method.toLowerCase())) continue;
|
|
96
|
+
if (method.toLowerCase() === "head") continue;
|
|
97
|
+
keys.add(routeKey(method, path));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return keys;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Comparison keys a root-index `endpoints` object claims.
|
|
105
|
+
*
|
|
106
|
+
* Each value is `"METHOD[/METHOD...] /path"` — the shape the hand-authored
|
|
107
|
+
* object already used (`"GET/PATCH /v1/user/profile"`), preserved so no
|
|
108
|
+
* published entry changes shape. A value that does not parse is reported as
|
|
109
|
+
* an orphan rather than skipped: an unparseable entry is precisely the kind
|
|
110
|
+
* of hand-edit this instrument exists to catch.
|
|
111
|
+
*/
|
|
112
|
+
export function rootIndexSurfaceKeys(
|
|
113
|
+
endpoints: Record<string, string>,
|
|
114
|
+
): Set<string> {
|
|
115
|
+
const keys = new Set<string>();
|
|
116
|
+
for (const value of Object.values(endpoints ?? {})) {
|
|
117
|
+
const match = /^([A-Za-z/]+)\s+(\S+)$/.exec(value ?? "");
|
|
118
|
+
if (!match?.[1] || !match[2]) {
|
|
119
|
+
keys.add(`UNPARSEABLE ${value}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const method of match[1].split("/")) {
|
|
123
|
+
if (!method) continue;
|
|
124
|
+
if (method.toUpperCase() === "HEAD") continue;
|
|
125
|
+
keys.add(routeKey(method, match[2]));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return keys;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function diff(registered: Set<string>, surface: Set<string>) {
|
|
132
|
+
return {
|
|
133
|
+
missingFromSurface: [...registered].filter((k) => !surface.has(k)).sort(),
|
|
134
|
+
orphanedInSurface: [...surface].filter((k) => !registered.has(k)).sort(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Diff the registered route table against both surfaces.
|
|
140
|
+
*
|
|
141
|
+
* `paths` is the served OpenAPI `paths` object (OpenAPI-templated); `endpoints`
|
|
142
|
+
* is the served root-index `endpoints` object (Fastify-shaped, matching the
|
|
143
|
+
* strings that surface has always published). Each is compared in its own
|
|
144
|
+
* vocabulary so neither surface is judged against a shape it never used.
|
|
145
|
+
*/
|
|
146
|
+
export function compareSurfaces(
|
|
147
|
+
registered: RegisteredRoute[],
|
|
148
|
+
paths: Record<string, Record<string, unknown>>,
|
|
149
|
+
endpoints: Record<string, string>,
|
|
150
|
+
): SurfaceDiff[] {
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
surface: "openapi-paths",
|
|
154
|
+
...diff(registeredOpenApiKeys(registered), openApiSurfaceKeys(paths)),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
surface: "root-index-endpoints",
|
|
158
|
+
...diff(
|
|
159
|
+
registeredFastifyKeys(registered),
|
|
160
|
+
rootIndexSurfaceKeys(endpoints),
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether any surface disagrees with the registered route table. */
|
|
167
|
+
export function hasDrift(diffs: SurfaceDiff[]): boolean {
|
|
168
|
+
return diffs.some(
|
|
169
|
+
(d) => d.missingFromSurface.length > 0 || d.orphanedInSurface.length > 0,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Render the drift for a failing assertion, or `""` when there is none.
|
|
175
|
+
*
|
|
176
|
+
* A surface that agrees contributes nothing, so the empty string means "no
|
|
177
|
+
* drift" and can be asserted against directly. Counts come first so a reader
|
|
178
|
+
* sees the scale before the list, then EVERY entry — a truncated drift report
|
|
179
|
+
* is how a gate teaches people to ignore it.
|
|
180
|
+
*/
|
|
181
|
+
export function formatSurfaceDrift(diffs: SurfaceDiff[]): string {
|
|
182
|
+
const lines: string[] = [];
|
|
183
|
+
for (const d of diffs) {
|
|
184
|
+
if (d.missingFromSurface.length === 0 && d.orphanedInSurface.length === 0) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
lines.push(
|
|
188
|
+
`${d.surface}: ${d.missingFromSurface.length} registered route(s) missing from the surface, ` +
|
|
189
|
+
`${d.orphanedInSurface.length} surface entr(ies) with no registered route`,
|
|
190
|
+
);
|
|
191
|
+
for (const k of d.missingFromSurface) lines.push(` missing: ${k}`);
|
|
192
|
+
for (const k of d.orphanedInSurface) lines.push(` orphaned: ${k}`);
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
@@ -61,14 +61,28 @@ const validBody = {
|
|
|
61
61
|
|
|
62
62
|
describe("POST /api/content/:type", () => {
|
|
63
63
|
let app: FastifyInstance;
|
|
64
|
+
// The write handlers only register when content-write is enabled
|
|
65
|
+
// (routes/content/index.ts's isModuleEnabled('content-write') gate); an
|
|
66
|
+
// unset AILK_ENABLED_MODULES no longer defaults to every module on
|
|
67
|
+
// (D-44, #5368), so this suite enables it explicitly.
|
|
68
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
64
69
|
|
|
65
70
|
beforeEach(async () => {
|
|
71
|
+
process.env.AILK_ENABLED_MODULES = "content-write";
|
|
66
72
|
app = require("fastify")();
|
|
67
73
|
await app.register(contentRoutes);
|
|
68
74
|
jest.clearAllMocks();
|
|
69
75
|
mockCheckLicense.mockReset();
|
|
70
76
|
});
|
|
71
77
|
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
if (priorEnabledModules === undefined) {
|
|
80
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
81
|
+
} else {
|
|
82
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
72
86
|
afterEach(async () => {
|
|
73
87
|
await app.close();
|
|
74
88
|
});
|
|
@@ -49,14 +49,28 @@ function freeLicense() {
|
|
|
49
49
|
|
|
50
50
|
describe("DELETE /api/content/:type/:slug", () => {
|
|
51
51
|
let app: FastifyInstance;
|
|
52
|
+
// The write handlers only register when content-write is enabled
|
|
53
|
+
// (routes/content/index.ts's isModuleEnabled('content-write') gate); an
|
|
54
|
+
// unset AILK_ENABLED_MODULES no longer defaults to every module on
|
|
55
|
+
// (D-44, #5368), so this suite enables it explicitly.
|
|
56
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
52
57
|
|
|
53
58
|
beforeEach(async () => {
|
|
59
|
+
process.env.AILK_ENABLED_MODULES = "content-write";
|
|
54
60
|
app = require("fastify")();
|
|
55
61
|
await app.register(contentRoutes);
|
|
56
62
|
jest.clearAllMocks();
|
|
57
63
|
mockCheckLicense.mockReset();
|
|
58
64
|
});
|
|
59
65
|
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
if (priorEnabledModules === undefined) {
|
|
68
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
69
|
+
} else {
|
|
70
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
60
74
|
afterEach(async () => {
|
|
61
75
|
await app.close();
|
|
62
76
|
});
|
|
@@ -61,14 +61,28 @@ const validBody = {
|
|
|
61
61
|
|
|
62
62
|
describe("PATCH /api/content/:type/:slug", () => {
|
|
63
63
|
let app: FastifyInstance;
|
|
64
|
+
// The write handlers only register when content-write is enabled
|
|
65
|
+
// (routes/content/index.ts's isModuleEnabled('content-write') gate); an
|
|
66
|
+
// unset AILK_ENABLED_MODULES no longer defaults to every module on
|
|
67
|
+
// (D-44, #5368), so this suite enables it explicitly.
|
|
68
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
64
69
|
|
|
65
70
|
beforeEach(async () => {
|
|
71
|
+
process.env.AILK_ENABLED_MODULES = "content-write";
|
|
66
72
|
app = require("fastify")();
|
|
67
73
|
await app.register(contentRoutes);
|
|
68
74
|
jest.clearAllMocks();
|
|
69
75
|
mockCheckLicense.mockReset();
|
|
70
76
|
});
|
|
71
77
|
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
if (priorEnabledModules === undefined) {
|
|
80
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
81
|
+
} else {
|
|
82
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
72
86
|
afterEach(async () => {
|
|
73
87
|
await app.close();
|
|
74
88
|
});
|
|
@@ -24,25 +24,52 @@
|
|
|
24
24
|
import type { FastifyPluginAsync } from "fastify";
|
|
25
25
|
|
|
26
26
|
import { isModuleEnabled } from "../../config/modules.js";
|
|
27
|
+
import { loadSlice } from "../../lib/slice-load.js";
|
|
27
28
|
|
|
28
29
|
import { getPageRoute } from "./get-page.js";
|
|
29
30
|
import { listPagesRoute } from "./list-pages.js";
|
|
30
31
|
import { listTypesRoute } from "./list-types.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Content-write slice entry points, as { specifier, exportName }.
|
|
34
|
+
* Content-write slice entry points, as { specifier, exportName, load }.
|
|
34
35
|
*
|
|
35
36
|
* What defeats `tsc`'s resolution is the INDIRECTION, not the type: TypeScript
|
|
36
37
|
* resolves `import("...")` only when the argument is a string-literal
|
|
37
|
-
* EXPRESSION written at the call site.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* EXPRESSION written at the call site.
|
|
39
|
+
*
|
|
40
|
+
* WHICH indirection matters, and #5304 is the correction. This table used to
|
|
41
|
+
* hold the specifier as a string and the loop read it out per iteration — a
|
|
42
|
+
* shape `@vercel/nft` cannot evaluate either, so the deploy-time tracer did not
|
|
43
|
+
* emit these files and a deployed site shipped without them. A module-level
|
|
44
|
+
* constant dereferenced into `import()` is unresolvable to `tsc` and traceable
|
|
45
|
+
* to `nft`, so the strip and the deployment are both satisfied. `specifier` is
|
|
46
|
+
* carried for the drift message below and reads the same constant as `load`, so
|
|
47
|
+
* the two cannot disagree.
|
|
41
48
|
*/
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const CREATE_ROUTE = "./create.js";
|
|
50
|
+
const UPDATE_ROUTE = "./update.js";
|
|
51
|
+
const DELETE_ROUTE = "./delete.js";
|
|
52
|
+
|
|
53
|
+
const WRITE_ROUTES: ReadonlyArray<{
|
|
54
|
+
specifier: string;
|
|
55
|
+
exportName: string;
|
|
56
|
+
load: () => Promise<unknown>;
|
|
57
|
+
}> = [
|
|
58
|
+
{
|
|
59
|
+
specifier: CREATE_ROUTE,
|
|
60
|
+
exportName: "createPageRoute",
|
|
61
|
+
load: () => import(CREATE_ROUTE),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
specifier: UPDATE_ROUTE,
|
|
65
|
+
exportName: "updatePageRoute",
|
|
66
|
+
load: () => import(UPDATE_ROUTE),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
specifier: DELETE_ROUTE,
|
|
70
|
+
exportName: "deletePageRoute",
|
|
71
|
+
load: () => import(DELETE_ROUTE),
|
|
72
|
+
},
|
|
46
73
|
];
|
|
47
74
|
|
|
48
75
|
export const contentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
@@ -51,8 +78,8 @@ export const contentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
51
78
|
await fastify.register(getPageRoute);
|
|
52
79
|
|
|
53
80
|
if (isModuleEnabled("content-write")) {
|
|
54
|
-
for (const { specifier, exportName } of WRITE_ROUTES) {
|
|
55
|
-
const mod = (await
|
|
81
|
+
for (const { specifier, exportName, load } of WRITE_ROUTES) {
|
|
82
|
+
const mod = (await loadSlice(specifier, load)) as Record<
|
|
56
83
|
string,
|
|
57
84
|
FastifyPluginAsync | undefined
|
|
58
85
|
>;
|
package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Not marked oss: false (D-48/#5366 escalation) — tests the
|
|
2
|
+
// always-shipping project-listings anonymous surface; see
|
|
3
|
+
// routes/project-listings/me.ts's header comment.
|
|
1
4
|
/**
|
|
2
5
|
* @file configured-application.test.ts
|
|
3
6
|
*
|
|
@@ -43,6 +46,13 @@ jest.mock("../../../lib/prisma.js");
|
|
|
43
46
|
|
|
44
47
|
jest.mock("@working-theory/email", () => ({
|
|
45
48
|
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
49
|
+
// #5406 — `start` now reaches the resume email. Declared here for the reason
|
|
50
|
+
// docs/lessons/project-listings/04 records: a mock factory that omits an
|
|
51
|
+
// export the code under test calls leaves it `undefined` at the callsite.
|
|
52
|
+
// These suites configure no `resumeEmail`, so the send short-circuits before
|
|
53
|
+
// this is reached; it is declared so a future fixture that DOES configure
|
|
54
|
+
// one fails on its assertion rather than on a missing mock.
|
|
55
|
+
sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
|
|
46
56
|
}));
|
|
47
57
|
|
|
48
58
|
jest.mock("@working-theory/license", () => ({
|
|
@@ -190,6 +200,17 @@ function installStore(): void {
|
|
|
190
200
|
},
|
|
191
201
|
);
|
|
192
202
|
|
|
203
|
+
// #5275 — the grant write `start` makes after the row. Append-only; it only
|
|
204
|
+
// needs to resolve here (drafts.test.ts asserts on what it is called with).
|
|
205
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
206
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
210
|
+
// transaction; the mock hands the same store-backed client through.
|
|
211
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
212
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
213
|
+
);
|
|
193
214
|
(prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
|
|
194
215
|
const now = new Date();
|
|
195
216
|
const row: Row = {
|
|
@@ -206,6 +227,13 @@ function installStore(): void {
|
|
|
206
227
|
anythingElse: null,
|
|
207
228
|
address: null,
|
|
208
229
|
questions: null,
|
|
230
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
231
|
+
// started draft on a site that asks for one name line: parts null,
|
|
232
|
+
// no phone, no verification stamp.
|
|
233
|
+
founderFirstName: null,
|
|
234
|
+
founderLastName: null,
|
|
235
|
+
founderPhone: null,
|
|
236
|
+
emailVerifiedAt: null,
|
|
209
237
|
founderCompany: null,
|
|
210
238
|
leadId: null,
|
|
211
239
|
headline: null,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Not marked oss: false (D-48/#5366 escalation) — tests the
|
|
2
|
+
// always-shipping project-listings anonymous surface; see
|
|
3
|
+
// routes/project-listings/me.ts's header comment.
|
|
1
4
|
/**
|
|
2
5
|
* @file drafts.test.ts
|
|
3
6
|
*
|
|
@@ -40,6 +43,13 @@ jest.mock("../../../lib/prisma.js");
|
|
|
40
43
|
|
|
41
44
|
jest.mock("@working-theory/email", () => ({
|
|
42
45
|
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
46
|
+
// #5406 — `start` now reaches the resume email. Declared here for the reason
|
|
47
|
+
// docs/lessons/project-listings/04 records: a mock factory that omits an
|
|
48
|
+
// export the code under test calls leaves it `undefined` at the callsite.
|
|
49
|
+
// These suites configure no `resumeEmail`, so the send short-circuits before
|
|
50
|
+
// this is reached; it is declared so a future fixture that DOES configure
|
|
51
|
+
// one fails on its assertion rather than on a missing mock.
|
|
52
|
+
sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
|
|
43
53
|
}));
|
|
44
54
|
|
|
45
55
|
jest.mock("@working-theory/license", () => ({
|
|
@@ -52,8 +62,15 @@ jest.mock("@working-theory/license", () => ({
|
|
|
52
62
|
}),
|
|
53
63
|
}));
|
|
54
64
|
|
|
65
|
+
import {
|
|
66
|
+
resolveConsentStatement,
|
|
67
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
68
|
+
} from "@working-theory/validation";
|
|
69
|
+
|
|
55
70
|
import { prisma } from "../../../lib/prisma.js";
|
|
56
71
|
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
72
|
+
import {
|
|
73
|
+
MAX_CONSENT_KEYS, UNRECOVERABLE_CONSENT_STATEMENT } from "../../../services/consent.js";
|
|
57
74
|
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
58
75
|
import { startProjectListingRoute } from "../start.js";
|
|
59
76
|
import { submitProjectListingRoute } from "../submit.js";
|
|
@@ -164,6 +181,11 @@ function installStore(): void {
|
|
|
164
181
|
},
|
|
165
182
|
);
|
|
166
183
|
|
|
184
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
185
|
+
// transaction; the mock hands the same store-backed client through.
|
|
186
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
187
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
188
|
+
);
|
|
167
189
|
(prisma.projectListing.create as jest.Mock).mockImplementation(
|
|
168
190
|
({ data }: { data: Row }) => {
|
|
169
191
|
const now = new Date();
|
|
@@ -181,6 +203,13 @@ function installStore(): void {
|
|
|
181
203
|
anythingElse: null,
|
|
182
204
|
address: null,
|
|
183
205
|
questions: null,
|
|
206
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
207
|
+
// started draft on a site that asks for one name line: parts null,
|
|
208
|
+
// no phone, no verification stamp.
|
|
209
|
+
founderFirstName: null,
|
|
210
|
+
founderLastName: null,
|
|
211
|
+
founderPhone: null,
|
|
212
|
+
emailVerifiedAt: null,
|
|
184
213
|
founderCompany: null,
|
|
185
214
|
leadId: null,
|
|
186
215
|
headline: null,
|
|
@@ -226,6 +255,12 @@ function installStore(): void {
|
|
|
226
255
|
return Promise.resolve({ count: matched.length });
|
|
227
256
|
},
|
|
228
257
|
);
|
|
258
|
+
|
|
259
|
+
// #5275 — the grant write `start` now makes. Append-only, so the mock only
|
|
260
|
+
// needs to resolve; the start tests below assert on what it was called with.
|
|
261
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
262
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
263
|
+
);
|
|
229
264
|
}
|
|
230
265
|
|
|
231
266
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -335,6 +370,115 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
335
370
|
expect(rows).toHaveLength(0);
|
|
336
371
|
});
|
|
337
372
|
|
|
373
|
+
// ── start: the consent grant (#5275 D7/D11) ──
|
|
374
|
+
|
|
375
|
+
it.each([
|
|
376
|
+
["DECLINES the required purpose", { service_terms: false }],
|
|
377
|
+
["is EMPTY beside the literal", {}],
|
|
378
|
+
["OMITS the required purpose", { marketing: true }],
|
|
379
|
+
])(
|
|
380
|
+
"start: a consents map that %s is a 400 and creates no row and no grant",
|
|
381
|
+
async (_label, consents) => {
|
|
382
|
+
const res = await app.inject({
|
|
383
|
+
method: "POST",
|
|
384
|
+
url: "/v1/project-listings",
|
|
385
|
+
payload: { ...START_PAYLOAD, consents, locale: "en" },
|
|
386
|
+
});
|
|
387
|
+
expect(res.statusCode).toBe(400);
|
|
388
|
+
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
389
|
+
expect(body.ok).toBe(false);
|
|
390
|
+
expect(body.error.code).toBe("invalid_input");
|
|
391
|
+
expect(body.error.message).toMatch(/service_terms/);
|
|
392
|
+
expect(rows).toHaveLength(0);
|
|
393
|
+
expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
it("start: a consents map with more than MAX_CONSENT_KEYS purposes is a 400", async () => {
|
|
398
|
+
const consents: Record<string, boolean> = { service_terms: true };
|
|
399
|
+
for (let i = 0; i < MAX_CONSENT_KEYS; i += 1) consents[`junk_${i}`] = true;
|
|
400
|
+
const res = await app.inject({
|
|
401
|
+
method: "POST",
|
|
402
|
+
url: "/v1/project-listings",
|
|
403
|
+
payload: { ...START_PAYLOAD, consents, locale: "en" },
|
|
404
|
+
});
|
|
405
|
+
expect(res.statusCode).toBe(400);
|
|
406
|
+
expect(rows).toHaveLength(0);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("start: a grant write that fails takes the row with it — one transaction, no stranded listing", async () => {
|
|
410
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
411
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => {
|
|
412
|
+
const before = rows.length;
|
|
413
|
+
try {
|
|
414
|
+
return await fn(prisma);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
rows.splice(before); // roll back the in-memory store
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
(prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(new Error("db down"));
|
|
422
|
+
const res = await app.inject({
|
|
423
|
+
method: "POST",
|
|
424
|
+
url: "/v1/project-listings",
|
|
425
|
+
payload: { ...START_PAYLOAD, consents: { service_terms: true }, locale: "en" },
|
|
426
|
+
});
|
|
427
|
+
expect(res.statusCode).toBe(500);
|
|
428
|
+
expect(rows).toHaveLength(0);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("start: a body carrying consents records one grant per purpose with the DEFAULT statement text", async () => {
|
|
432
|
+
const { id } = await startDraft(app, { consents: { service_terms: true }, locale: "en" });
|
|
433
|
+
|
|
434
|
+
// The expected wording is DERIVED from the registry the server resolves
|
|
435
|
+
// from, never retyped — the whole point is that the row carries the string
|
|
436
|
+
// the renderer showed, not a string this test happens to agree with.
|
|
437
|
+
const purpose = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!;
|
|
438
|
+
const { statement } = resolveConsentStatement(purpose, "en");
|
|
439
|
+
|
|
440
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
441
|
+
const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
|
|
442
|
+
data: Row[];
|
|
443
|
+
};
|
|
444
|
+
expect(data).toHaveLength(1);
|
|
445
|
+
expect(data[0]).toMatchObject({
|
|
446
|
+
siteId: SITE_ID,
|
|
447
|
+
subjectType: "project_listing",
|
|
448
|
+
subjectId: id,
|
|
449
|
+
purposeId: "service_terms",
|
|
450
|
+
granted: true,
|
|
451
|
+
locale: "en",
|
|
452
|
+
statement,
|
|
453
|
+
statementHash: sha256(statement),
|
|
454
|
+
});
|
|
455
|
+
expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
|
|
456
|
+
// The row's own consentAt is untouched by the grant write (spec §2.3).
|
|
457
|
+
expect(rows[0]!["consentAt"]).toBeInstanceOf(Date);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("start: a body carrying only the legacy literal records ONE grant marked unrecoverable", async () => {
|
|
461
|
+
const { id } = await startDraft(app);
|
|
462
|
+
|
|
463
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
464
|
+
const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
|
|
465
|
+
data: Row[];
|
|
466
|
+
};
|
|
467
|
+
expect(data).toHaveLength(1);
|
|
468
|
+
expect(data[0]).toMatchObject({
|
|
469
|
+
siteId: SITE_ID,
|
|
470
|
+
subjectType: "project_listing",
|
|
471
|
+
subjectId: id,
|
|
472
|
+
purposeId: "service_terms",
|
|
473
|
+
granted: true,
|
|
474
|
+
locale: "und",
|
|
475
|
+
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
476
|
+
statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
477
|
+
});
|
|
478
|
+
// Never today's wording for a legacy call — that would fabricate evidence (D11).
|
|
479
|
+
expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
|
|
480
|
+
});
|
|
481
|
+
|
|
338
482
|
// ── patch ──
|
|
339
483
|
|
|
340
484
|
it("patch: a SINGLE-field patch round-trips", async () => {
|