create-ailk 0.1.0 → 0.2.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/README.md +124 -3
- package/component-catalog.md +456 -0
- package/dist/cli.js +92 -16
- package/dist/component-catalog.d.ts +11 -0
- package/dist/component-catalog.js +39 -0
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +24 -0
- package/dist/derive-architecture.d.ts +13 -0
- package/dist/derive-architecture.js +238 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +19 -2
- package/dist/lib/extract-module-bundle.d.ts +16 -0
- package/dist/lib/extract-module-bundle.js +184 -0
- package/dist/lib/fetch-module.d.ts +55 -0
- package/dist/lib/fetch-module.js +125 -0
- package/dist/lib/module-license-gate.d.ts +48 -0
- package/dist/lib/module-license-gate.js +67 -0
- package/dist/lib/module-tier.d.ts +35 -0
- package/dist/lib/module-tier.js +37 -0
- package/dist/module-architecture.d.ts +175 -0
- package/dist/module-architecture.js +663 -0
- package/dist/parse-args.d.ts +47 -1
- package/dist/parse-args.js +143 -2
- package/dist/programmatic.d.ts +160 -3
- package/dist/programmatic.js +196 -7
- package/dist/surfaces.d.ts +211 -0
- package/dist/surfaces.js +532 -0
- package/dist/sync-routes.d.ts +92 -0
- package/dist/sync-routes.js +350 -0
- package/package.json +12 -5
- package/templates/.claude/rules/architecture.md +6 -2
- package/templates/.claude/skills/README.md +9 -9
- package/templates/.claude/skills/add-schema/SKILL.md +14 -7
- package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
- package/templates/.claude/skills/provision-config/SKILL.md +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
- package/templates/.env.example +126 -2
- package/templates/CLAUDE.md +11 -5
- package/templates/README.md +1 -1
- package/templates/apps/api/.env.example +38 -0
- package/templates/apps/api/CLAUDE.md +78 -1
- package/templates/apps/api/LICENSE +201 -0
- package/templates/apps/api/api/index.ts +6 -141
- package/templates/apps/api/jest.config.cjs +16 -0
- package/templates/apps/api/package.json +22 -6
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
- package/templates/apps/api/src/__tests__/server.test.ts +46 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
- package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
- package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
- package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
- package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
- package/templates/apps/api/src/bin/seed-presets.ts +58 -0
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
- package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
- package/templates/apps/api/src/cli.ts +5 -11
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/config/modules.ts +43 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
- package/templates/apps/api/src/lib/site.ts +16 -0
- package/templates/apps/api/src/lib/stripe.ts +22 -0
- package/templates/apps/api/src/lib/tenant-db.ts +225 -0
- package/templates/apps/api/src/lib/ws-token.ts +91 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
- package/templates/apps/api/src/middleware/auth.ts +43 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
- package/templates/apps/api/src/middleware/tenant.ts +99 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
- package/templates/apps/api/src/openapi/spec.ts +312 -3
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
- package/templates/apps/api/src/routes/aeo/index.ts +107 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
- package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
- package/templates/apps/api/src/routes/billing/index.ts +36 -0
- package/templates/apps/api/src/routes/billing/portal.ts +182 -0
- package/templates/apps/api/src/routes/billing/read.ts +75 -0
- package/templates/apps/api/src/routes/billing/usage.ts +153 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
- package/templates/apps/api/src/routes/content/create.ts +1 -0
- package/templates/apps/api/src/routes/content/delete.ts +1 -0
- package/templates/apps/api/src/routes/content/index.ts +42 -6
- package/templates/apps/api/src/routes/content/update.ts +1 -0
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
- package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
- package/templates/apps/api/src/routes/flows/README.md +147 -0
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
- package/templates/apps/api/src/routes/flows/index.ts +202 -0
- package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
- package/templates/apps/api/src/routes/leads/index.ts +43 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
- package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
- package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
- package/templates/apps/api/src/routes/schedule/index.ts +249 -0
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
- package/templates/apps/api/src/routes/slack/actions.ts +177 -0
- package/templates/apps/api/src/routes/slack/index.ts +38 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
- package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
- package/templates/apps/api/src/routes/webhooks/README.md +35 -12
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
- package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
- package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
- package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
- package/templates/apps/api/src/server.ts +307 -7
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
- 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/__tests__/deliverable-fulfillment.test.ts +523 -0
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
- package/templates/apps/api/src/services/abandon-email.ts +307 -0
- package/templates/apps/api/src/services/aeo-score.ts +288 -0
- package/templates/apps/api/src/services/answer-distributions.ts +138 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
- package/templates/apps/api/src/services/entitlements.ts +102 -0
- package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
- package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
- package/templates/apps/api/src/services/flow-engine.ts +1296 -0
- package/templates/apps/api/src/services/lead-promotion.ts +176 -0
- package/templates/apps/api/src/services/lead-routing.ts +3 -3
- package/templates/apps/api/src/services/listing-config.ts +102 -0
- package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/playbook-compile.ts +398 -0
- package/templates/apps/api/src/services/playbook-render.ts +263 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
- package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
- package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
- package/templates/apps/api/src/services/project-listings.ts +1472 -0
- package/templates/apps/api/src/services/recommender-capture.ts +835 -0
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
- package/templates/apps/api/src/services/scheduling/index.ts +63 -0
- package/templates/apps/api/src/services/scheduling/types.ts +88 -0
- package/templates/apps/api/src/services/slack-notify.ts +240 -0
- package/templates/apps/api/src/services/tenant-context.ts +451 -0
- package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
- package/templates/apps/api/src/services/usage-credits.ts +340 -0
- package/templates/apps/api/src/services/usage-metering.ts +699 -0
- package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
- package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
- package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
- package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
- package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
- package/templates/apps/api/src/vercel-handler.ts +165 -0
- package/templates/apps/api/tsconfig.json +0 -12
- package/templates/apps/mcp/CLAUDE.md +19 -9
- package/templates/apps/mcp/LICENSE +201 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
- package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
- package/templates/apps/mcp/package.json +6 -4
- package/templates/apps/mcp/src/config/modules.ts +37 -0
- package/templates/apps/mcp/src/server.ts +109 -18
- package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
- package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
- package/templates/apps/mcp/src/tools/create_page.ts +1 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
- package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
- package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
- package/templates/apps/mcp/src/tools/index.ts +25 -0
- package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
- package/templates/apps/mcp/src/tools/purchase.ts +3 -0
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
- package/templates/apps/mcp/src/tools/update_page.ts +1 -0
- package/templates/apps/mcp/tsconfig.json +1 -9
- package/templates/apps/web/.env.example +44 -0
- package/templates/apps/web/CLAUDE.md +28 -0
- package/templates/apps/web/LICENSE +201 -0
- package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
- package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
- package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
- package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
- package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
- package/templates/apps/web/__tests__/setup.ts +22 -32
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
- package/templates/apps/web/app/[locale]/layout.tsx +127 -16
- package/templates/apps/web/app/[locale]/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
- package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
- package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
- package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/globals.css +35 -0
- package/templates/apps/web/app/llms.txt/route.ts +2 -1
- package/templates/apps/web/app/og/route.tsx +10 -2
- package/templates/apps/web/auth.ts +8 -0
- package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
- package/templates/apps/web/content/en/blog/README.txt +25 -0
- package/templates/apps/web/content/en/docs/index.mdx +28 -0
- package/templates/apps/web/jest.config.cjs +90 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
- package/templates/apps/web/lib/account-config.ts +13 -0
- package/templates/apps/web/lib/server-api.ts +85 -0
- package/templates/apps/web/lib/site-brand.tsx +121 -0
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/messages/en.json +275 -7
- package/templates/apps/web/middleware.ts +11 -3
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/apps/web/package.json +17 -7
- package/templates/apps/web/postcss.config.mjs +23 -0
- package/templates/apps/web/public/.well-known/security.txt +3 -3
- package/templates/apps/web/src/lib/routes.ts +15 -2
- package/templates/apps/web/tsconfig.json +1 -16
- package/templates/content/_site.mdx +36 -25
- package/templates/content/en/AboutPage/about.mdx +24 -14
- package/templates/content/en/ContactPage/contact.mdx +23 -14
- package/templates/content/en/FAQPage/faq.mdx +10 -392
- package/templates/content/en/HomePage/home.mdx +14 -285
- package/templates/database/CHANGELOG.md +443 -0
- package/templates/database/LICENSE +201 -0
- package/templates/database/README.md +3 -1
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
- package/templates/database/__tests__/flows-presets.test.ts +173 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
- package/templates/database/__tests__/seed.test.ts +88 -0
- package/templates/database/__tests__/user-cascade.test.ts +19 -4
- package/templates/database/auth/schema.prisma +521 -0
- package/templates/database/config/schema.prisma +234 -0
- package/templates/database/content/schema.prisma +263 -0
- package/templates/database/flows/listing-diagnostic.ts +182 -0
- package/templates/database/flows/presets.ts +315 -0
- package/templates/database/inbox/__tests__/schema.test.ts +4 -0
- package/templates/database/inbox/schema.prisma +574 -3
- package/templates/database/jest.config.cjs +8 -0
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -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/ops/schema.prisma +27 -0
- package/templates/database/package.json +42 -4
- package/templates/database/prisma.config.ts +1 -1
- package/templates/database/schema.prisma +19 -435
- package/templates/database/seed-test.ts +14 -4
- package/templates/database/seed.ts +148 -0
- package/templates/database/tenancy.ts +45 -0
- package/templates/database/tsconfig.flows.json +15 -0
- package/templates/database/tsconfig.json +7 -1
- package/templates/database/tsconfig.tenancy.json +14 -0
- package/templates/package.json +30 -55
- package/templates/pnpm-workspace.yaml +0 -3
- package/templates/project.yaml +1 -1
- package/templates/tsconfig.json +1 -37
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
- package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
- package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
- package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
- package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
- package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
- package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
- package/templates/apps/web/__tests__/page.test.tsx +0 -230
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
- package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
- package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
- package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
- package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
- package/templates/apps/web/content/en/blog/index.mdx +0 -15
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
- package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +0 -85
- package/templates/content/ar/HomePage/home.mdx +0 -123
- package/templates/content/ar/_site.mdx +0 -35
- package/templates/content/de/_site.mdx +0 -29
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.schema.json +0 -82
- package/templates/content/en/BookCall/book-call.mdx +0 -19
- package/templates/content/en/BookCall/book-call.schema.json +0 -121
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
- package/templates/content/en/CareersList/careers.mdx +0 -24
- package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
- package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
- package/templates/content/en/ClientsList/clients.mdx +0 -38
- package/templates/content/en/ContactPage/contact.schema.json +0 -100
- package/templates/content/en/ContactPoint/primary.mdx +0 -14
- package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
- package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
- package/templates/content/en/CoursesList/courses.mdx +0 -22
- package/templates/content/en/DownloadsList/downloads.mdx +0 -22
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
- package/templates/content/en/EventsList/events.mdx +0 -26
- package/templates/content/en/FAQPage/faq.schema.json +0 -231
- package/templates/content/en/GlossaryList/glossary.mdx +0 -19
- package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
- package/templates/content/en/HomePage/home.schema.json +0 -88
- package/templates/content/en/HomePage/launch-service.mdx +0 -475
- package/templates/content/en/HomePage/launch-service.schema.json +0 -138
- package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
- package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
- package/templates/content/en/Integration/github-actions.mdx +0 -31
- package/templates/content/en/Integration/openai-api.mdx +0 -26
- package/templates/content/en/Integration/vercel.mdx +0 -32
- package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
- package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
- package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
- package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
- package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
- package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
- package/templates/content/en/LandingPage/developers.mdx +0 -19
- package/templates/content/en/LandingPage/developers.schema.json +0 -109
- package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
- package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
- package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
- package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
- package/templates/content/en/LandingPage/platform.mdx +0 -369
- package/templates/content/en/LandingPage/platform.schema.json +0 -166
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
- package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
- package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
- package/templates/content/en/Organization/site-owner.mdx +0 -16
- package/templates/content/en/Partner/prisma.mdx +0 -31
- package/templates/content/en/Partner/supabase.mdx +0 -30
- package/templates/content/en/Partner/vercel.mdx +0 -28
- package/templates/content/en/PartnersList/partners.mdx +0 -22
- package/templates/content/en/Person/founder.mdx +0 -18
- package/templates/content/en/Pricing/pricing.mdx +0 -538
- package/templates/content/en/Pricing/pricing.schema.json +0 -209
- package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
- package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
- package/templates/content/en/Product/ailk.mdx +0 -19
- package/templates/content/en/Product/ailk.schema.json +0 -94
- package/templates/content/en/Product/api-launch-kit.mdx +0 -26
- package/templates/content/en/Product/launch-kit.mdx +0 -28
- package/templates/content/en/ProductsList/products.mdx +0 -26
- package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
- package/templates/content/en/ProjectsList/projects.mdx +0 -25
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
- package/templates/content/en/ResourcesHub/resources.mdx +0 -22
- package/templates/content/en/Service/consulting.mdx +0 -22
- package/templates/content/en/Service/implementation.mdx +0 -30
- package/templates/content/en/Service/launch-service.mdx +0 -232
- package/templates/content/en/Service/launch.mdx +0 -228
- package/templates/content/en/ServicesList/services.mdx +0 -84
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
- package/templates/content/en/TeamList/team.mdx +0 -63
- package/templates/content/en/TeamMember/founder.mdx +0 -72
- package/templates/content/en/TermsPage/terms.mdx +0 -19
- package/templates/content/en/TermsPage/terms.schema.json +0 -99
- package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
- package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
- package/templates/content/en-XA/AboutPage/about.mdx +0 -8
- package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
- package/templates/content/en-XA/HomePage/home.mdx +0 -8
- package/templates/content/es/_site.mdx +0 -30
- package/templates/content/fr/_site.mdx +0 -30
- package/templates/content/ja/_site.mdx +0 -30
- package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
- package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
- package/templates/content/pt-BR/HomePage/home.mdx +0 -23
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file drafts.test.ts
|
|
3
|
+
*
|
|
4
|
+
* THE ACCEPTANCE GATE for the draft lifecycle (#5036 D1/D7) — the three
|
|
5
|
+
* anonymous routes that replaced the single `POST /v1/project-listings`
|
|
6
|
+
* create: `start`, `patch` and `submit`.
|
|
7
|
+
*
|
|
8
|
+
* Real routes and real services, mocked `lib/prisma.js` backed by a small
|
|
9
|
+
* in-memory row store, so a patch genuinely WRITES and a later read genuinely
|
|
10
|
+
* READS BACK. A `jest.fn()` returning a canned object could not tell a
|
|
11
|
+
* round-trip from a no-op, and the round-trip is the thing under test.
|
|
12
|
+
* `routeLead` runs for real against a per-test temp path
|
|
13
|
+
* (`setupLeadFileEnv`), matching the create test's discipline before it;
|
|
14
|
+
* `@working-theory/email` and `@working-theory/license` stay mocked (external
|
|
15
|
+
* package boundaries — see docs/lessons/project-listings/02).
|
|
16
|
+
*
|
|
17
|
+
* `slack-notify` is NOT mocked, deliberately: `AILK_SLACK_BOT_TOKEN` is unset
|
|
18
|
+
* here, so `postListingApplication` takes its own documented no-op branch
|
|
19
|
+
* (`slack-skipped`) and never reaches `fetch`. Stubbing the module would be
|
|
20
|
+
* mocking internal code to avoid a boundary that is already closed.
|
|
21
|
+
*
|
|
22
|
+
* Four of these fail QUIETLY if they regress, which is why each is here:
|
|
23
|
+
* - a draft created from name and email alone must PERSIST, and a
|
|
24
|
+
* single-field patch must round-trip — otherwise the lifecycle silently
|
|
25
|
+
* stores nothing and the founder loses ten steps of work;
|
|
26
|
+
* - an incomplete draft at submit must be refused with the missing fields
|
|
27
|
+
* NAMED, not silently accepted into `requested`;
|
|
28
|
+
* - a resume token must grant ONE listing and nothing else, since it is the
|
|
29
|
+
* only credential on an anonymous surface;
|
|
30
|
+
* - drafts must COUNT toward the two-listing limit, or the limit is evaded
|
|
31
|
+
* by never submitting.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
|
|
35
|
+
import Fastify from "fastify";
|
|
36
|
+
import type { FastifyInstance } from "fastify";
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
|
|
39
|
+
jest.mock("../../../lib/prisma.js");
|
|
40
|
+
|
|
41
|
+
jest.mock("@working-theory/email", () => ({
|
|
42
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
jest.mock("@working-theory/license", () => ({
|
|
46
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
47
|
+
tier: "free",
|
|
48
|
+
plan: null,
|
|
49
|
+
features: [],
|
|
50
|
+
expiresAt: null,
|
|
51
|
+
revoked: false,
|
|
52
|
+
}),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
resolveConsentStatement,
|
|
57
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
58
|
+
} from "@working-theory/validation";
|
|
59
|
+
|
|
60
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
61
|
+
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
62
|
+
import {
|
|
63
|
+
MAX_CONSENT_KEYS, UNRECOVERABLE_CONSENT_STATEMENT } from "../../../services/consent.js";
|
|
64
|
+
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
65
|
+
import { startProjectListingRoute } from "../start.js";
|
|
66
|
+
import { submitProjectListingRoute } from "../submit.js";
|
|
67
|
+
|
|
68
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const opt = (value: string, label: string) => ({ value, label });
|
|
71
|
+
|
|
72
|
+
const VALID_QUESTIONS = {
|
|
73
|
+
q2: {
|
|
74
|
+
prompt: "Why are you here?",
|
|
75
|
+
options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
|
|
76
|
+
},
|
|
77
|
+
q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
78
|
+
q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
79
|
+
q5: {
|
|
80
|
+
prompt: "Urgency?",
|
|
81
|
+
options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
|
|
82
|
+
},
|
|
83
|
+
q6: {
|
|
84
|
+
prompt: "Pain?",
|
|
85
|
+
options: [
|
|
86
|
+
opt("low", "Low"),
|
|
87
|
+
opt("medium", "Medium"),
|
|
88
|
+
opt("high", "High"),
|
|
89
|
+
opt("critical", "Critical"),
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
q7: { prompt: "Anything else?" },
|
|
93
|
+
extras: [],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The site is named by its PUBLIC KEY now (#5048 D2) — `siteId` never travels
|
|
98
|
+
* in a request body. `installStore` maps this key to `SITE_ID` the way a
|
|
99
|
+
* `SiteListingConfig.publicKey` row does.
|
|
100
|
+
*/
|
|
101
|
+
const SITE_ID = "site-1";
|
|
102
|
+
const SITE_KEY = "site-1-public-key";
|
|
103
|
+
|
|
104
|
+
const START_PAYLOAD = {
|
|
105
|
+
siteKey: SITE_KEY,
|
|
106
|
+
founderName: "Ada Founder",
|
|
107
|
+
founderEmail: "ada@example.com",
|
|
108
|
+
consent: true,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Everything the ten steps after step one collect. */
|
|
112
|
+
const COMPLETE_APPLICATION = {
|
|
113
|
+
name: "My Project",
|
|
114
|
+
headline: "Ten words or fewer for the tile title here",
|
|
115
|
+
oneLiner: "A short sentence describing what the project does for its people.",
|
|
116
|
+
problem: "The problem is real and this describes it.",
|
|
117
|
+
product: "wireframes",
|
|
118
|
+
commitment: "part-time",
|
|
119
|
+
teamComposition: "co-founders",
|
|
120
|
+
faqs: [
|
|
121
|
+
{ question: "Q one?", answer: "A one." },
|
|
122
|
+
{ question: "Q two?", answer: "A two." },
|
|
123
|
+
{ question: "Q three?", answer: "A three." },
|
|
124
|
+
],
|
|
125
|
+
address: "1 Founder Way, Springfield",
|
|
126
|
+
questions: VALID_QUESTIONS,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ─── The in-memory row store the Prisma mock is backed by ───────────────────
|
|
130
|
+
|
|
131
|
+
type Row = Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
let rows: Row[];
|
|
134
|
+
let nextId: number;
|
|
135
|
+
|
|
136
|
+
function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
137
|
+
return Object.entries(where).every(([key, value]) => {
|
|
138
|
+
if (key === "OR") {
|
|
139
|
+
return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
|
|
140
|
+
}
|
|
141
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
142
|
+
const clause = value as Record<string, unknown>;
|
|
143
|
+
if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
|
|
144
|
+
if ("not" in clause) return row[key] !== clause["not"];
|
|
145
|
+
if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
|
|
146
|
+
if ("startsWith" in clause) {
|
|
147
|
+
return typeof row[key] === "string" && (row[key] as string).startsWith(clause["startsWith"] as string);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return row[key] === value;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function installStore(): void {
|
|
155
|
+
rows = [];
|
|
156
|
+
nextId = 1;
|
|
157
|
+
|
|
158
|
+
(prisma.lead.create as jest.Mock).mockImplementation(() =>
|
|
159
|
+
Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
|
|
160
|
+
);
|
|
161
|
+
// One delegate, two lookups (#5048 D2): `where.publicKey` is the site-key
|
|
162
|
+
// resolution (`siteIdForPublicKey`), `where.siteId` the config read
|
|
163
|
+
// (`listingConfigFor`). No config row for the latter, so the platform
|
|
164
|
+
// defaults apply — the behaviour every test below was written against.
|
|
165
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
|
|
166
|
+
({ where }: { where: { siteId?: string; publicKey?: string } }) => {
|
|
167
|
+
if (where.publicKey !== undefined) {
|
|
168
|
+
return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
|
|
169
|
+
}
|
|
170
|
+
return Promise.resolve(null);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
175
|
+
// transaction; the mock hands the same store-backed client through.
|
|
176
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
177
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
178
|
+
);
|
|
179
|
+
(prisma.projectListing.create as jest.Mock).mockImplementation(
|
|
180
|
+
({ data }: { data: Row }) => {
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const row: Row = {
|
|
183
|
+
id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
|
|
184
|
+
slug: null,
|
|
185
|
+
name: null,
|
|
186
|
+
oneLiner: null,
|
|
187
|
+
problem: null,
|
|
188
|
+
url: null,
|
|
189
|
+
product: null,
|
|
190
|
+
commitment: null,
|
|
191
|
+
teamComposition: null,
|
|
192
|
+
faqs: null,
|
|
193
|
+
anythingElse: null,
|
|
194
|
+
address: null,
|
|
195
|
+
questions: null,
|
|
196
|
+
founderCompany: null,
|
|
197
|
+
leadId: null,
|
|
198
|
+
headline: null,
|
|
199
|
+
logoMarkUrl: null,
|
|
200
|
+
logoWordmarkUrl: null,
|
|
201
|
+
featured: false,
|
|
202
|
+
decidedAt: null,
|
|
203
|
+
decidedBy: null,
|
|
204
|
+
issueUrl: null,
|
|
205
|
+
experimentKey: null,
|
|
206
|
+
windowEndsAt: null,
|
|
207
|
+
createdAt: now,
|
|
208
|
+
updatedAt: now,
|
|
209
|
+
...data,
|
|
210
|
+
};
|
|
211
|
+
rows.push(row);
|
|
212
|
+
return Promise.resolve(row);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
(prisma.projectListing.findFirst as jest.Mock).mockImplementation(
|
|
217
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
218
|
+
Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
222
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
223
|
+
Promise.resolve(rows.filter((row) => matches(row, where))),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
(prisma.projectListing.update as jest.Mock).mockImplementation(
|
|
227
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
228
|
+
const row = rows.find((r) => matches(r, where));
|
|
229
|
+
Object.assign(row!, data, { updatedAt: new Date() });
|
|
230
|
+
return Promise.resolve(row);
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
(prisma.projectListing.updateMany as jest.Mock).mockImplementation(
|
|
235
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
236
|
+
const matched = rows.filter((r) => matches(r, where));
|
|
237
|
+
matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
|
|
238
|
+
return Promise.resolve({ count: matched.length });
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// #5275 — the grant write `start` now makes. Append-only, so the mock only
|
|
243
|
+
// needs to resolve; the start tests below assert on what it was called with.
|
|
244
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
245
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
252
|
+
|
|
253
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
254
|
+
|
|
255
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
256
|
+
const app = Fastify();
|
|
257
|
+
await app.register(startProjectListingRoute);
|
|
258
|
+
await app.register(patchProjectListingDraftRoute);
|
|
259
|
+
await app.register(submitProjectListingRoute);
|
|
260
|
+
await app.ready();
|
|
261
|
+
return app;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function startDraft(
|
|
265
|
+
app: FastifyInstance,
|
|
266
|
+
overrides: Record<string, unknown> = {},
|
|
267
|
+
): Promise<{ id: string; resumeToken: string }> {
|
|
268
|
+
const res = await app.inject({
|
|
269
|
+
method: "POST",
|
|
270
|
+
url: "/v1/project-listings",
|
|
271
|
+
payload: { ...START_PAYLOAD, ...overrides },
|
|
272
|
+
});
|
|
273
|
+
expect(res.statusCode).toBe(201);
|
|
274
|
+
return (res.json() as { ok: true; data: { id: string; resumeToken: string } }).data;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("the draft lifecycle (#5036 D1)", () => {
|
|
280
|
+
setupLeadFileEnv("project-listing-drafts");
|
|
281
|
+
let app: FastifyInstance;
|
|
282
|
+
|
|
283
|
+
beforeEach(async () => {
|
|
284
|
+
jest.clearAllMocks();
|
|
285
|
+
installStore();
|
|
286
|
+
app = await buildApp();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
afterEach(async () => {
|
|
290
|
+
await app.close();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ── start ──
|
|
294
|
+
|
|
295
|
+
it("start: a draft created from name and email alone PERSISTS", async () => {
|
|
296
|
+
const { id, resumeToken } = await startDraft(app);
|
|
297
|
+
|
|
298
|
+
expect(id).toMatch(/^prjl_/);
|
|
299
|
+
expect(resumeToken.length).toBeGreaterThan(0);
|
|
300
|
+
|
|
301
|
+
// The row exists, at `draft`, carrying nothing but the identity — every
|
|
302
|
+
// application field still null. This is the "nullable at rest" invariant
|
|
303
|
+
// seen from the outside: without it the create itself would have failed.
|
|
304
|
+
const row = rows[0]!;
|
|
305
|
+
expect(row["status"]).toBe("draft");
|
|
306
|
+
expect(row["founderEmail"]).toBe("ada@example.com");
|
|
307
|
+
expect(row["name"]).toBeNull();
|
|
308
|
+
expect(row["oneLiner"]).toBeNull();
|
|
309
|
+
expect(row["product"]).toBeNull();
|
|
310
|
+
expect(row["questions"]).toBeNull();
|
|
311
|
+
expect(row["slug"]).toBeNull();
|
|
312
|
+
|
|
313
|
+
// NO Lead at step one. The contact is already captured on this row, and
|
|
314
|
+
// `routeLead` mails the operator — which on an anonymous route would let a
|
|
315
|
+
// stranger fill the operator's inbox by opening drafts they never finish
|
|
316
|
+
// (PR #5042 security review, finding 4). The Lead waits for `submit`.
|
|
317
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
318
|
+
expect(row["leadId"]).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("start: lowercases the founder's email, so the limit cannot be evaded by case", async () => {
|
|
322
|
+
// Postgres text equality is case-sensitive, so without this the
|
|
323
|
+
// concurrent-listing limit is evaded by holding down shift. (Surrounding
|
|
324
|
+
// whitespace needs no test: `z.string().email()` refuses it outright.)
|
|
325
|
+
await startDraft(app, { founderEmail: "Ada@Example.COM" });
|
|
326
|
+
expect(rows[0]!["founderEmail"]).toBe("ada@example.com");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("start: stores a DIGEST of the resume token, never the token", async () => {
|
|
330
|
+
const { resumeToken } = await startDraft(app);
|
|
331
|
+
const stored = rows[0]!["resumeTokenHash"];
|
|
332
|
+
|
|
333
|
+
expect(stored).not.toBe(resumeToken);
|
|
334
|
+
expect(stored).toBe(sha256(resumeToken));
|
|
335
|
+
// 256 bits of CSPRNG output, base64url-encoded.
|
|
336
|
+
expect(Buffer.from(resumeToken, "base64url")).toHaveLength(32);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("start: two drafts get different tokens", async () => {
|
|
340
|
+
const first = await startDraft(app);
|
|
341
|
+
const second = await startDraft(app, { founderEmail: "grace@example.com" });
|
|
342
|
+
expect(first.resumeToken).not.toBe(second.resumeToken);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("start: refuses a payload that declines consent", async () => {
|
|
346
|
+
const res = await app.inject({
|
|
347
|
+
method: "POST",
|
|
348
|
+
url: "/v1/project-listings",
|
|
349
|
+
payload: { ...START_PAYLOAD, consent: false },
|
|
350
|
+
});
|
|
351
|
+
expect(res.statusCode).toBe(400);
|
|
352
|
+
expect((res.json() as { ok: boolean }).ok).toBe(false);
|
|
353
|
+
expect(rows).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── start: the consent grant (#5275 D7/D11) ──
|
|
357
|
+
|
|
358
|
+
it.each([
|
|
359
|
+
["DECLINES the required purpose", { service_terms: false }],
|
|
360
|
+
["is EMPTY beside the literal", {}],
|
|
361
|
+
["OMITS the required purpose", { marketing: true }],
|
|
362
|
+
])(
|
|
363
|
+
"start: a consents map that %s is a 400 and creates no row and no grant",
|
|
364
|
+
async (_label, consents) => {
|
|
365
|
+
const res = await app.inject({
|
|
366
|
+
method: "POST",
|
|
367
|
+
url: "/v1/project-listings",
|
|
368
|
+
payload: { ...START_PAYLOAD, consents, locale: "en" },
|
|
369
|
+
});
|
|
370
|
+
expect(res.statusCode).toBe(400);
|
|
371
|
+
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
372
|
+
expect(body.ok).toBe(false);
|
|
373
|
+
expect(body.error.code).toBe("invalid_input");
|
|
374
|
+
expect(body.error.message).toMatch(/service_terms/);
|
|
375
|
+
expect(rows).toHaveLength(0);
|
|
376
|
+
expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
it("start: a consents map with more than MAX_CONSENT_KEYS purposes is a 400", async () => {
|
|
381
|
+
const consents: Record<string, boolean> = { service_terms: true };
|
|
382
|
+
for (let i = 0; i < MAX_CONSENT_KEYS; i += 1) consents[`junk_${i}`] = true;
|
|
383
|
+
const res = await app.inject({
|
|
384
|
+
method: "POST",
|
|
385
|
+
url: "/v1/project-listings",
|
|
386
|
+
payload: { ...START_PAYLOAD, consents, locale: "en" },
|
|
387
|
+
});
|
|
388
|
+
expect(res.statusCode).toBe(400);
|
|
389
|
+
expect(rows).toHaveLength(0);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("start: a grant write that fails takes the row with it — one transaction, no stranded listing", async () => {
|
|
393
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
394
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => {
|
|
395
|
+
const before = rows.length;
|
|
396
|
+
try {
|
|
397
|
+
return await fn(prisma);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
rows.splice(before); // roll back the in-memory store
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
(prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(new Error("db down"));
|
|
405
|
+
const res = await app.inject({
|
|
406
|
+
method: "POST",
|
|
407
|
+
url: "/v1/project-listings",
|
|
408
|
+
payload: { ...START_PAYLOAD, consents: { service_terms: true }, locale: "en" },
|
|
409
|
+
});
|
|
410
|
+
expect(res.statusCode).toBe(500);
|
|
411
|
+
expect(rows).toHaveLength(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("start: a body carrying consents records one grant per purpose with the DEFAULT statement text", async () => {
|
|
415
|
+
const { id } = await startDraft(app, { consents: { service_terms: true }, locale: "en" });
|
|
416
|
+
|
|
417
|
+
// The expected wording is DERIVED from the registry the server resolves
|
|
418
|
+
// from, never retyped — the whole point is that the row carries the string
|
|
419
|
+
// the renderer showed, not a string this test happens to agree with.
|
|
420
|
+
const purpose = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!;
|
|
421
|
+
const { statement } = resolveConsentStatement(purpose, "en");
|
|
422
|
+
|
|
423
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
424
|
+
const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
|
|
425
|
+
data: Row[];
|
|
426
|
+
};
|
|
427
|
+
expect(data).toHaveLength(1);
|
|
428
|
+
expect(data[0]).toMatchObject({
|
|
429
|
+
siteId: SITE_ID,
|
|
430
|
+
subjectType: "project_listing",
|
|
431
|
+
subjectId: id,
|
|
432
|
+
purposeId: "service_terms",
|
|
433
|
+
granted: true,
|
|
434
|
+
locale: "en",
|
|
435
|
+
statement,
|
|
436
|
+
statementHash: sha256(statement),
|
|
437
|
+
});
|
|
438
|
+
expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
|
|
439
|
+
// The row's own consentAt is untouched by the grant write (spec §2.3).
|
|
440
|
+
expect(rows[0]!["consentAt"]).toBeInstanceOf(Date);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("start: a body carrying only the legacy literal records ONE grant marked unrecoverable", async () => {
|
|
444
|
+
const { id } = await startDraft(app);
|
|
445
|
+
|
|
446
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
447
|
+
const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
|
|
448
|
+
data: Row[];
|
|
449
|
+
};
|
|
450
|
+
expect(data).toHaveLength(1);
|
|
451
|
+
expect(data[0]).toMatchObject({
|
|
452
|
+
siteId: SITE_ID,
|
|
453
|
+
subjectType: "project_listing",
|
|
454
|
+
subjectId: id,
|
|
455
|
+
purposeId: "service_terms",
|
|
456
|
+
granted: true,
|
|
457
|
+
locale: "und",
|
|
458
|
+
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
459
|
+
statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
460
|
+
});
|
|
461
|
+
// Never today's wording for a legacy call — that would fabricate evidence (D11).
|
|
462
|
+
expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── patch ──
|
|
466
|
+
|
|
467
|
+
it("patch: a SINGLE-field patch round-trips", async () => {
|
|
468
|
+
const { id, resumeToken } = await startDraft(app);
|
|
469
|
+
|
|
470
|
+
const res = await app.inject({
|
|
471
|
+
method: "PATCH",
|
|
472
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
473
|
+
headers: bearer(resumeToken),
|
|
474
|
+
payload: { oneLiner: "Just the one field, saved on its own." },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(res.statusCode).toBe(200);
|
|
478
|
+
const body = res.json() as { ok: boolean; data: Record<string, unknown> };
|
|
479
|
+
expect(body.ok).toBe(true);
|
|
480
|
+
expect(body.data["oneLiner"]).toBe("Just the one field, saved on its own.");
|
|
481
|
+
// Still a draft, still incomplete — `patch` asserts no completeness.
|
|
482
|
+
expect(body.data["status"]).toBe("draft");
|
|
483
|
+
expect(body.data["name"]).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("patch: a later patch MERGES rather than replacing", async () => {
|
|
487
|
+
const { id, resumeToken } = await startDraft(app);
|
|
488
|
+
|
|
489
|
+
await app.inject({
|
|
490
|
+
method: "PATCH",
|
|
491
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
492
|
+
headers: bearer(resumeToken),
|
|
493
|
+
payload: { name: "Step three" },
|
|
494
|
+
});
|
|
495
|
+
const res = await app.inject({
|
|
496
|
+
method: "PATCH",
|
|
497
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
498
|
+
headers: bearer(resumeToken),
|
|
499
|
+
payload: { address: "1 Founder Way" },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const body = res.json() as { data: Record<string, unknown> };
|
|
503
|
+
// The earlier step survives the later one — the failure this guards is a
|
|
504
|
+
// patch that blanks the nine fields the founder already filled in.
|
|
505
|
+
expect(body.data["name"]).toBe("Step three");
|
|
506
|
+
expect(body.data["address"]).toBe("1 Founder Way");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("patch: refuses an empty body", async () => {
|
|
510
|
+
const { id, resumeToken } = await startDraft(app);
|
|
511
|
+
const res = await app.inject({
|
|
512
|
+
method: "PATCH",
|
|
513
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
514
|
+
headers: bearer(resumeToken),
|
|
515
|
+
payload: {},
|
|
516
|
+
});
|
|
517
|
+
expect(res.statusCode).toBe(400);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("patch: 401s with no resume token at all", async () => {
|
|
521
|
+
const { id } = await startDraft(app);
|
|
522
|
+
const res = await app.inject({
|
|
523
|
+
method: "PATCH",
|
|
524
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
525
|
+
payload: { name: "No credential" },
|
|
526
|
+
});
|
|
527
|
+
expect(res.statusCode).toBe(401);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ── the token's binding ──
|
|
531
|
+
|
|
532
|
+
it("a resume token grants ONE listing and no other", async () => {
|
|
533
|
+
const first = await startDraft(app);
|
|
534
|
+
const second = await startDraft(app, { founderEmail: "grace@example.com" });
|
|
535
|
+
|
|
536
|
+
// The first founder's token, presented against the second's listing.
|
|
537
|
+
const res = await app.inject({
|
|
538
|
+
method: "PATCH",
|
|
539
|
+
url: `/v1/project-listings/drafts/${second.id}`,
|
|
540
|
+
headers: bearer(first.resumeToken),
|
|
541
|
+
payload: { name: "Not mine to write" },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(res.statusCode).toBe(404);
|
|
545
|
+
// And nothing was written to the row it was aimed at.
|
|
546
|
+
const target = rows.find((r) => r["id"] === second.id)!;
|
|
547
|
+
expect(target["name"]).toBeNull();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("a wrong token is a 404, indistinguishable from an unknown draft", async () => {
|
|
551
|
+
const { id } = await startDraft(app);
|
|
552
|
+
|
|
553
|
+
const wrongToken = await app.inject({
|
|
554
|
+
method: "PATCH",
|
|
555
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
556
|
+
headers: bearer("not-the-token"),
|
|
557
|
+
payload: { name: "x" },
|
|
558
|
+
});
|
|
559
|
+
const unknownDraft = await app.inject({
|
|
560
|
+
method: "PATCH",
|
|
561
|
+
url: "/v1/project-listings/drafts/prjl_00000000-0000-7000-8000-0000000000ff",
|
|
562
|
+
headers: bearer("not-the-token"),
|
|
563
|
+
payload: { name: "x" },
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(wrongToken.statusCode).toBe(404);
|
|
567
|
+
expect(unknownDraft.statusCode).toBe(404);
|
|
568
|
+
expect(wrongToken.json()).toEqual(unknownDraft.json());
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ── submit ──
|
|
572
|
+
|
|
573
|
+
it("submit: an INCOMPLETE draft is refused with the missing fields NAMED", async () => {
|
|
574
|
+
const { id, resumeToken } = await startDraft(app);
|
|
575
|
+
// Two of the eleven required fields filled in; nine still missing.
|
|
576
|
+
await app.inject({
|
|
577
|
+
method: "PATCH",
|
|
578
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
579
|
+
headers: bearer(resumeToken),
|
|
580
|
+
payload: { name: "My Project", address: "1 Founder Way" },
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const res = await app.inject({
|
|
584
|
+
method: "POST",
|
|
585
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
586
|
+
headers: bearer(resumeToken),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect(res.statusCode).toBe(422);
|
|
590
|
+
const body = res.json() as { error: { code: string; missingFields: string[] } };
|
|
591
|
+
expect(body.error.code).toBe("incomplete_application");
|
|
592
|
+
// NAMED, so the form can send the founder to the step that owns each one.
|
|
593
|
+
expect(body.error.missingFields).toEqual(
|
|
594
|
+
expect.arrayContaining([
|
|
595
|
+
"commitment",
|
|
596
|
+
"faqs",
|
|
597
|
+
"headline",
|
|
598
|
+
"oneLiner",
|
|
599
|
+
"problem",
|
|
600
|
+
"product",
|
|
601
|
+
"questions",
|
|
602
|
+
"teamComposition",
|
|
603
|
+
]),
|
|
604
|
+
);
|
|
605
|
+
// The two fields that WERE filled in are not reported missing.
|
|
606
|
+
expect(body.error.missingFields).not.toContain("name");
|
|
607
|
+
expect(body.error.missingFields).not.toContain("address");
|
|
608
|
+
// And the row did not move.
|
|
609
|
+
expect(rows.find((r) => r["id"] === id)!["status"]).toBe("draft");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("submit: a COMPLETE draft transitions to `requested` and returns the derived slug", async () => {
|
|
613
|
+
const { id, resumeToken } = await startDraft(app);
|
|
614
|
+
await app.inject({
|
|
615
|
+
method: "PATCH",
|
|
616
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
617
|
+
headers: bearer(resumeToken),
|
|
618
|
+
payload: COMPLETE_APPLICATION,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const res = await app.inject({
|
|
622
|
+
method: "POST",
|
|
623
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
624
|
+
headers: bearer(resumeToken),
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
expect(res.statusCode).toBe(200);
|
|
628
|
+
expect(res.json()).toEqual({ ok: true, data: { id, slug: "my-project" } });
|
|
629
|
+
|
|
630
|
+
const row = rows.find((r) => r["id"] === id)!;
|
|
631
|
+
expect(row["status"]).toBe("requested");
|
|
632
|
+
// The Lead is written HERE, once, for a completed application.
|
|
633
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
634
|
+
const lead = (prisma.lead.create as jest.Mock).mock.calls[0][0];
|
|
635
|
+
expect(lead.data.source).toBe("projects-apply");
|
|
636
|
+
expect(lead.data.email).toBe("ada@example.com");
|
|
637
|
+
expect(row["leadId"]).not.toBeNull();
|
|
638
|
+
expect(row["slug"]).toBe("my-project");
|
|
639
|
+
// The token is spent: a leak after submission grants nothing.
|
|
640
|
+
expect(row["resumeTokenHash"]).toBeNull();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("submit: a spent token cannot patch or re-submit the listing", async () => {
|
|
644
|
+
const { id, resumeToken } = await startDraft(app);
|
|
645
|
+
await app.inject({
|
|
646
|
+
method: "PATCH",
|
|
647
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
648
|
+
headers: bearer(resumeToken),
|
|
649
|
+
payload: COMPLETE_APPLICATION,
|
|
650
|
+
});
|
|
651
|
+
await app.inject({
|
|
652
|
+
method: "POST",
|
|
653
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
654
|
+
headers: bearer(resumeToken),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const patchAgain = await app.inject({
|
|
658
|
+
method: "PATCH",
|
|
659
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
660
|
+
headers: bearer(resumeToken),
|
|
661
|
+
payload: { name: "Renamed after the fact" },
|
|
662
|
+
});
|
|
663
|
+
const submitAgain = await app.inject({
|
|
664
|
+
method: "POST",
|
|
665
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
666
|
+
headers: bearer(resumeToken),
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
expect(patchAgain.statusCode).toBe(404);
|
|
670
|
+
expect(submitAgain.statusCode).toBe(404);
|
|
671
|
+
expect(rows.find((r) => r["id"] === id)!["name"]).toBe("My Project");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("submit: 401s with no resume token at all", async () => {
|
|
675
|
+
const { id } = await startDraft(app);
|
|
676
|
+
const res = await app.inject({
|
|
677
|
+
method: "POST",
|
|
678
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
679
|
+
});
|
|
680
|
+
expect(res.statusCode).toBe(401);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ── the concurrent-listing limit (#5036 D7) ──
|
|
684
|
+
|
|
685
|
+
it("a third concurrent listing is refused at submit — and one of the two is a DRAFT", async () => {
|
|
686
|
+
// One already ACTIVE listing for this founder…
|
|
687
|
+
const active = await startDraft(app);
|
|
688
|
+
Object.assign(rows.find((r) => r["id"] === active.id)!, {
|
|
689
|
+
status: "active",
|
|
690
|
+
slug: "already-live",
|
|
691
|
+
windowEndsAt: new Date(Date.now() + 86_400_000),
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// …and one still a DRAFT. If drafts did not count, the limit would be
|
|
695
|
+
// evaded by simply never submitting — which is the state a ten-step form
|
|
696
|
+
// leaves a founder in by default.
|
|
697
|
+
await startDraft(app);
|
|
698
|
+
|
|
699
|
+
// The third.
|
|
700
|
+
const third = await startDraft(app);
|
|
701
|
+
await app.inject({
|
|
702
|
+
method: "PATCH",
|
|
703
|
+
url: `/v1/project-listings/drafts/${third.id}`,
|
|
704
|
+
headers: bearer(third.resumeToken),
|
|
705
|
+
payload: COMPLETE_APPLICATION,
|
|
706
|
+
});
|
|
707
|
+
const res = await app.inject({
|
|
708
|
+
method: "POST",
|
|
709
|
+
url: `/v1/project-listings/drafts/${third.id}/submit`,
|
|
710
|
+
headers: bearer(third.resumeToken),
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
expect(res.statusCode).toBe(409);
|
|
714
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe("listing_limit_reached");
|
|
715
|
+
expect(rows.find((r) => r["id"] === third.id)!["status"]).toBe("draft");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("a long-abandoned draft stops holding a slot", async () => {
|
|
719
|
+
// Drafts count — that is D7, and it is what stops the limit being evaded by
|
|
720
|
+
// never submitting. But `founderEmail` is unverified here, so a draft that
|
|
721
|
+
// counted FOREVER would let two anonymous requests lock a named founder out
|
|
722
|
+
// permanently, with no operator remedy (PR #5042 security review, finding
|
|
723
|
+
// 1). The window is what makes that lockout expire.
|
|
724
|
+
for (let i = 0; i < 2; i += 1) {
|
|
725
|
+
const stale = await startDraft(app);
|
|
726
|
+
Object.assign(rows.find((r) => r["id"] === stale.id)!, {
|
|
727
|
+
createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const third = await startDraft(app);
|
|
732
|
+
await app.inject({
|
|
733
|
+
method: "PATCH",
|
|
734
|
+
url: `/v1/project-listings/drafts/${third.id}`,
|
|
735
|
+
headers: bearer(third.resumeToken),
|
|
736
|
+
payload: COMPLETE_APPLICATION,
|
|
737
|
+
});
|
|
738
|
+
const res = await app.inject({
|
|
739
|
+
method: "POST",
|
|
740
|
+
url: `/v1/project-listings/drafts/${third.id}/submit`,
|
|
741
|
+
headers: bearer(third.resumeToken),
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(res.statusCode).toBe(200);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("a FRESH draft still holds a slot", async () => {
|
|
748
|
+
// The other half of the window: two drafts created today do bind, so the
|
|
749
|
+
// limit is not evaded by simply not pressing submit.
|
|
750
|
+
await startDraft(app);
|
|
751
|
+
await startDraft(app);
|
|
752
|
+
|
|
753
|
+
const third = await startDraft(app);
|
|
754
|
+
await app.inject({
|
|
755
|
+
method: "PATCH",
|
|
756
|
+
url: `/v1/project-listings/drafts/${third.id}`,
|
|
757
|
+
headers: bearer(third.resumeToken),
|
|
758
|
+
payload: COMPLETE_APPLICATION,
|
|
759
|
+
});
|
|
760
|
+
const res = await app.inject({
|
|
761
|
+
method: "POST",
|
|
762
|
+
url: `/v1/project-listings/drafts/${third.id}/submit`,
|
|
763
|
+
headers: bearer(third.resumeToken),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
expect(res.statusCode).toBe(409);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("a TERMINAL listing does not count toward the limit", async () => {
|
|
770
|
+
// Two listings, but both terminal: one rejected, one whose window elapsed
|
|
771
|
+
// (which reads `closed`, computed, with no job having run).
|
|
772
|
+
const rejected = await startDraft(app);
|
|
773
|
+
Object.assign(rows.find((r) => r["id"] === rejected.id)!, {
|
|
774
|
+
status: "rejected",
|
|
775
|
+
slug: "turned-down",
|
|
776
|
+
});
|
|
777
|
+
const expired = await startDraft(app);
|
|
778
|
+
Object.assign(rows.find((r) => r["id"] === expired.id)!, {
|
|
779
|
+
status: "active",
|
|
780
|
+
slug: "ran-its-course",
|
|
781
|
+
windowEndsAt: new Date(Date.now() - 86_400_000),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const third = await startDraft(app);
|
|
785
|
+
await app.inject({
|
|
786
|
+
method: "PATCH",
|
|
787
|
+
url: `/v1/project-listings/drafts/${third.id}`,
|
|
788
|
+
headers: bearer(third.resumeToken),
|
|
789
|
+
payload: COMPLETE_APPLICATION,
|
|
790
|
+
});
|
|
791
|
+
const res = await app.inject({
|
|
792
|
+
method: "POST",
|
|
793
|
+
url: `/v1/project-listings/drafts/${third.id}/submit`,
|
|
794
|
+
headers: bearer(third.resumeToken),
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
expect(res.statusCode).toBe(200);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("the limit is scoped to one founder — another founder's listings do not count", async () => {
|
|
801
|
+
for (const email of ["grace@example.com", "alan@example.com"]) {
|
|
802
|
+
const other = await startDraft(app, { founderEmail: email });
|
|
803
|
+
Object.assign(rows.find((r) => r["id"] === other.id)!, {
|
|
804
|
+
status: "active",
|
|
805
|
+
slug: `live-${email.split("@")[0]}`,
|
|
806
|
+
windowEndsAt: new Date(Date.now() + 86_400_000),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const mine = await startDraft(app);
|
|
811
|
+
await app.inject({
|
|
812
|
+
method: "PATCH",
|
|
813
|
+
url: `/v1/project-listings/drafts/${mine.id}`,
|
|
814
|
+
headers: bearer(mine.resumeToken),
|
|
815
|
+
payload: COMPLETE_APPLICATION,
|
|
816
|
+
});
|
|
817
|
+
const res = await app.inject({
|
|
818
|
+
method: "POST",
|
|
819
|
+
url: `/v1/project-listings/drafts/${mine.id}/submit`,
|
|
820
|
+
headers: bearer(mine.resumeToken),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
expect(res.statusCode).toBe(200);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ── logging ──
|
|
827
|
+
|
|
828
|
+
it("no founder PII and no resume token reaches the route's own log line", async () => {
|
|
829
|
+
// Scoped to the log statements THIS route makes. Fastify's built-in
|
|
830
|
+
// request/response logging serializes the raw request (body included) and
|
|
831
|
+
// `inject`'s own harness stashes the response payload on it — neither is
|
|
832
|
+
// this route's doing, and neither is what the PII boundary is about. What
|
|
833
|
+
// is this route's doing is the one `request.log.info` call below.
|
|
834
|
+
const calls: Record<string, unknown>[] = [];
|
|
835
|
+
const logging = Fastify({
|
|
836
|
+
logger: {
|
|
837
|
+
level: "info",
|
|
838
|
+
hooks: {
|
|
839
|
+
logMethod(args: unknown[], method: (...a: never[]) => void) {
|
|
840
|
+
if (typeof args[1] === "string" && args[1] === "project listing draft started") {
|
|
841
|
+
calls.push(args[0] as Record<string, unknown>);
|
|
842
|
+
}
|
|
843
|
+
method.apply(this, args as never[]);
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
disableRequestLogging: true,
|
|
848
|
+
});
|
|
849
|
+
await logging.register(startProjectListingRoute);
|
|
850
|
+
await logging.ready();
|
|
851
|
+
|
|
852
|
+
const res = await logging.inject({
|
|
853
|
+
method: "POST",
|
|
854
|
+
url: "/v1/project-listings",
|
|
855
|
+
payload: START_PAYLOAD,
|
|
856
|
+
});
|
|
857
|
+
const { resumeToken } = (res.json() as { data: { resumeToken: string } }).data;
|
|
858
|
+
|
|
859
|
+
expect(calls).toHaveLength(1);
|
|
860
|
+
const serialized = JSON.stringify(calls[0]);
|
|
861
|
+
expect(serialized).not.toContain("ada@example.com");
|
|
862
|
+
expect(serialized).not.toContain("Ada Founder");
|
|
863
|
+
expect(serialized).not.toContain(resumeToken);
|
|
864
|
+
// Routing metadata only.
|
|
865
|
+
expect(Object.keys(calls[0]!).sort()).toEqual(["ip", "listingId", "siteId"]);
|
|
866
|
+
|
|
867
|
+
await logging.close();
|
|
868
|
+
});
|
|
869
|
+
});
|