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,2840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-engine.test.ts
|
|
3
|
+
* @description Unit tests for the flow-engine service (#3956 D3).
|
|
4
|
+
*
|
|
5
|
+
* External dependency boundaries mocked:
|
|
6
|
+
* - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
|
|
7
|
+
* - @working-theory/email sendLeadNotification (transitively, via lead-routing.ts's routeLead)
|
|
8
|
+
* + sendDeliverableEmail (transitively, via ../deliverable-fulfillment.js's fulfillDeliverable, #3961)
|
|
9
|
+
*
|
|
10
|
+
* Internal modules run real:
|
|
11
|
+
* - ../lead-routing (routeLead) — real, exercising the actual completion composition (AC4)
|
|
12
|
+
* - ../deliverable-fulfillment (fulfillDeliverable) — real, exercising the actual D4 completion
|
|
13
|
+
* seam; its own resolution/grant/email logic is covered directly by
|
|
14
|
+
* deliverable-fulfillment.test.ts, so this file asserts only the CALL — that the
|
|
15
|
+
* completion branch reaches the real service, with the request-local segment, under
|
|
16
|
+
* the "no block → no call" gate, and that its failures never fail the submission
|
|
17
|
+
* - apps/api/src/services/enrichment-shim — real; @working-theory/license catches + returns lead unchanged
|
|
18
|
+
* - apps/api/src/lib/file-fallback — real; unused here since prisma.lead.create is mocked to succeed
|
|
19
|
+
* - ../waitlist-scoring (scoreCompletedSignup, #4662 D6) — real, exercising the actual
|
|
20
|
+
* completion-hook composition; its own evaluator/glue logic is covered directly by
|
|
21
|
+
* packages/validation's + apps/api's own waitlist-scoring.test.ts, so this file
|
|
22
|
+
* asserts only the CALL — that a snapshot carrying `configSnapshot.experiment`
|
|
23
|
+
* reaches the real service (observed via the mocked prisma.waitlistExperiment /
|
|
24
|
+
* prisma.waitlistScore boundary it reads/writes), that a snapshot without it never
|
|
25
|
+
* touches that boundary, and that its failures never fail the submission
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
29
|
+
jest.mock("../../lib/prisma.js");
|
|
30
|
+
|
|
31
|
+
const mockSendDeliverableEmail = jest.fn().mockResolvedValue({
|
|
32
|
+
ok: true,
|
|
33
|
+
messageId: "msg-deliverable-test",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
jest.mock("@working-theory/email", () => ({
|
|
37
|
+
sendLeadNotification: jest.fn().mockResolvedValue({
|
|
38
|
+
ok: true,
|
|
39
|
+
messageId: "msg-test",
|
|
40
|
+
}),
|
|
41
|
+
sendDeliverableEmail: (...args: unknown[]) =>
|
|
42
|
+
mockSendDeliverableEmail(...args),
|
|
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
|
+
// #4663 D6 — mock the A4 score service so AC6 exercises the engine's own
|
|
56
|
+
// trigger/persist orchestration without a real crawl. aeo-score.ts wraps
|
|
57
|
+
// the SSRF-guarded external-URL crawler (a network boundary), the same
|
|
58
|
+
// class of boundary the prisma.js mock above keeps this file off of.
|
|
59
|
+
const mockScoreUrl = jest.fn();
|
|
60
|
+
const mockLoadExampleScore = jest.fn();
|
|
61
|
+
// eslint-disable-next-line no-restricted-syntax -- aeo-score.ts is a network boundary (external crawl), same class as the DB mock above
|
|
62
|
+
jest.mock("../aeo-score.js", () => ({
|
|
63
|
+
scoreUrl: (...args: unknown[]) => mockScoreUrl(...args),
|
|
64
|
+
loadExampleScore: (...args: unknown[]) => mockLoadExampleScore(...args),
|
|
65
|
+
resolveScoreBudgetMs: (raw: string | undefined) => {
|
|
66
|
+
const parsed = Number(raw);
|
|
67
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 6000;
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
import { Prisma } from "@working-theory/database";
|
|
72
|
+
import { FLOW_PRESETS } from "@working-theory/database/flows/presets";
|
|
73
|
+
import { flowConfigSchema } from "@working-theory/validation";
|
|
74
|
+
|
|
75
|
+
import { prisma } from "../../lib/prisma.js";
|
|
76
|
+
import {
|
|
77
|
+
serveFlow,
|
|
78
|
+
submitStep,
|
|
79
|
+
seedFlowPresets,
|
|
80
|
+
upsertFlowDefinition,
|
|
81
|
+
cleanupAbandonedSessions,
|
|
82
|
+
} from "../flow-engine";
|
|
83
|
+
|
|
84
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const en = (s: string) => ({ en: s });
|
|
87
|
+
|
|
88
|
+
const sourceMeta = {
|
|
89
|
+
ip: "127.0.0.1",
|
|
90
|
+
userAgent: "jest",
|
|
91
|
+
receivedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function contactFirstFlow() {
|
|
95
|
+
return {
|
|
96
|
+
slug: "get-a-quote",
|
|
97
|
+
defaultLocale: "en",
|
|
98
|
+
steps: [
|
|
99
|
+
{
|
|
100
|
+
key: "contact",
|
|
101
|
+
type: "field" as const,
|
|
102
|
+
copy: { title: en("Let's get started") },
|
|
103
|
+
fields: [
|
|
104
|
+
{
|
|
105
|
+
name: "email",
|
|
106
|
+
inputType: "email" as const,
|
|
107
|
+
required: true,
|
|
108
|
+
mapsTo: "email" as const,
|
|
109
|
+
copy: { label: en("Email") },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "company",
|
|
113
|
+
inputType: "text" as const,
|
|
114
|
+
copy: { label: en("Company") },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: "budget",
|
|
120
|
+
type: "segmentation" as const,
|
|
121
|
+
copy: { title: en("What's your budget?") },
|
|
122
|
+
questions: [
|
|
123
|
+
{
|
|
124
|
+
name: "budget",
|
|
125
|
+
options: [
|
|
126
|
+
{ value: "small", label: en("Under $10k") },
|
|
127
|
+
{ value: "large", label: en("$10k+") },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "thanks",
|
|
134
|
+
type: "message" as const,
|
|
135
|
+
copy: { title: en("Thanks!") },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// contactFirstFlow() extended with an #4661 D4 experiment binding — the A2
|
|
142
|
+
// (#4662 D6) scoring hook's opt-in gate.
|
|
143
|
+
function contactFirstFlowWithExperiment() {
|
|
144
|
+
const flow = contactFirstFlow();
|
|
145
|
+
return {
|
|
146
|
+
...flow,
|
|
147
|
+
experiment: { key: "ailk-founder-diagnostic", waitlist: "founder_diagnostic" },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The `WaitlistExperiment.config` row scoreCompletedSignup (#4662 D6) loads
|
|
152
|
+
// for the `ailk-founder-diagnostic` / `founder_diagnostic` binding above —
|
|
153
|
+
// its questionSet mirrors contactFirstFlow()'s "budget" question exactly, so
|
|
154
|
+
// the real evaluator scores the same payload the flow assembles. tier "hot"
|
|
155
|
+
// at budget_points ≥ 5 (large=10, small=1) is a real, minimal WaitlistScoring
|
|
156
|
+
// register — not a mock of the evaluator itself.
|
|
157
|
+
function waitlistExperimentConfigForFixture() {
|
|
158
|
+
return {
|
|
159
|
+
key: "ailk-founder-diagnostic",
|
|
160
|
+
mode: "qualifying" as const,
|
|
161
|
+
status: "draft" as const,
|
|
162
|
+
waitlists: [{ key: "founder_diagnostic", flowSlug: "get-a-quote" }],
|
|
163
|
+
questionSet: {
|
|
164
|
+
version: 1 as const,
|
|
165
|
+
questions: [
|
|
166
|
+
{
|
|
167
|
+
id: "email",
|
|
168
|
+
prompt: en("Your email"),
|
|
169
|
+
answerType: "email" as const,
|
|
170
|
+
mapsTo: "email" as const,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "budget",
|
|
174
|
+
prompt: en("What's your budget?"),
|
|
175
|
+
answerType: "single" as const,
|
|
176
|
+
options: [
|
|
177
|
+
{ value: "small", label: en("Under $10k") },
|
|
178
|
+
{ value: "large", label: en("$10k+") },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
scoring: {
|
|
184
|
+
version: 1 as const,
|
|
185
|
+
points: [
|
|
186
|
+
{
|
|
187
|
+
name: "budget_points",
|
|
188
|
+
question: "budget",
|
|
189
|
+
kind: "optionWeights" as const,
|
|
190
|
+
weights: { small: 1, large: 10 },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
scores: { total: { sum: ["budget_points"] } },
|
|
194
|
+
tiers: [
|
|
195
|
+
{ key: "hot", when: { score: "total", gte: 5 } },
|
|
196
|
+
// Never matches its own `when` — reachable only via the default
|
|
197
|
+
// fall-through, so "small" (total 1) is distinguishable from
|
|
198
|
+
// "large" (total 10) in the persisted tier.
|
|
199
|
+
{ key: "cold", when: { score: "total", lte: -1 } },
|
|
200
|
+
],
|
|
201
|
+
default: "cold",
|
|
202
|
+
},
|
|
203
|
+
tracking: {
|
|
204
|
+
version: 1 as const,
|
|
205
|
+
utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
|
|
206
|
+
waitlists: { founder_diagnostic: { campaign: "launch", channels: ["organic"] } },
|
|
207
|
+
},
|
|
208
|
+
stripeConfigRef: null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// contactFirstFlow() extended with a segments band map on the "budget"
|
|
213
|
+
// question (#3959 D1) — small -> "starter", large -> "enterprise". A
|
|
214
|
+
// "thanks" message step still follows, so landing "budget" alone does not
|
|
215
|
+
// complete the session.
|
|
216
|
+
function contactFirstFlowWithSegments() {
|
|
217
|
+
const flow = contactFirstFlow();
|
|
218
|
+
const budgetStep = flow.steps.find((s) => s.key === "budget")!;
|
|
219
|
+
(budgetStep as typeof budgetStep & { segments?: unknown }).segments = {
|
|
220
|
+
question: "budget",
|
|
221
|
+
bands: [
|
|
222
|
+
{ key: "starter", values: ["small"] },
|
|
223
|
+
{ key: "enterprise", values: ["large"] },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
return flow;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The #3959 D3 terminal-segmentation-step fixture: segmentation is the
|
|
230
|
+
// LAST step (no trailing message step), so ONE submission both lands the
|
|
231
|
+
// band map AND completes the session — the exact shape that would surface
|
|
232
|
+
// a stale-`session.segment` bug if completion read the row instead of the
|
|
233
|
+
// request-local derived value.
|
|
234
|
+
function contactThenBudgetOnlyFlowWithSegments() {
|
|
235
|
+
return {
|
|
236
|
+
slug: "get-a-quote",
|
|
237
|
+
defaultLocale: "en",
|
|
238
|
+
steps: [
|
|
239
|
+
{
|
|
240
|
+
key: "contact",
|
|
241
|
+
type: "field" as const,
|
|
242
|
+
copy: { title: en("Let's get started") },
|
|
243
|
+
fields: [
|
|
244
|
+
{
|
|
245
|
+
name: "email",
|
|
246
|
+
inputType: "email" as const,
|
|
247
|
+
required: true,
|
|
248
|
+
mapsTo: "email" as const,
|
|
249
|
+
copy: { label: en("Email") },
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "budget",
|
|
255
|
+
type: "segmentation" as const,
|
|
256
|
+
copy: { title: en("What's your budget?") },
|
|
257
|
+
questions: [
|
|
258
|
+
{
|
|
259
|
+
name: "budget",
|
|
260
|
+
options: [
|
|
261
|
+
{ value: "small", label: en("Under $10k") },
|
|
262
|
+
{ value: "large", label: en("$10k+") },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
segments: {
|
|
267
|
+
question: "budget",
|
|
268
|
+
bands: [
|
|
269
|
+
{ key: "starter", values: ["small"] },
|
|
270
|
+
{ key: "enterprise", values: ["large"] },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// #3961 D4 — contactThenBudgetOnlyFlowWithSegments() extended with a
|
|
279
|
+
// deliverables asset map keyed on the same "starter"/"enterprise" bands —
|
|
280
|
+
// the segmentation-last single-submission fixture, so landing "budget"
|
|
281
|
+
// both derives the segment AND fires fulfillment in the same request.
|
|
282
|
+
function contactThenBudgetOnlyFlowWithDeliverables() {
|
|
283
|
+
const flow = contactThenBudgetOnlyFlowWithSegments();
|
|
284
|
+
return {
|
|
285
|
+
...flow,
|
|
286
|
+
deliverables: {
|
|
287
|
+
assets: [
|
|
288
|
+
{ segment: "starter", assetKey: "guide-starter" },
|
|
289
|
+
{ segment: "enterprise", assetKey: "guide-enterprise" },
|
|
290
|
+
],
|
|
291
|
+
copy: {
|
|
292
|
+
subject: en("Here's your guide"),
|
|
293
|
+
body: en("Thanks — your download is attached."),
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// #4769 AC4 — a SERVED `mode: "recommending"` flow: one segmentation step
|
|
300
|
+
// (no `mapsTo` field, mirroring `noEmailFlow()`'s D9 shape below) plus the
|
|
301
|
+
// register's `offers`/`mode`/`recommendation` marker, exactly what
|
|
302
|
+
// `compileWaitlistFlow` (packages/validation) carries verbatim under the
|
|
303
|
+
// `mode === "recommending"` gate. Exercises the SERVED shape directly since
|
|
304
|
+
// `flow-engine.ts` needs no production change (D1/D1a) — `locateStep`
|
|
305
|
+
// already refuses an unknown `stepKey`, and `completeSubmission` already
|
|
306
|
+
// completes without a Lead row when no field maps to email.
|
|
307
|
+
function recommendingServedFlow() {
|
|
308
|
+
return {
|
|
309
|
+
slug: "ailk-recommender",
|
|
310
|
+
defaultLocale: "en",
|
|
311
|
+
steps: [
|
|
312
|
+
{
|
|
313
|
+
key: "q_team",
|
|
314
|
+
type: "segmentation" as const,
|
|
315
|
+
copy: { title: en("What best describes your team?") },
|
|
316
|
+
questions: [
|
|
317
|
+
{
|
|
318
|
+
name: "q_team",
|
|
319
|
+
options: [
|
|
320
|
+
{ value: "solo", label: en("Just me") },
|
|
321
|
+
{ value: "small_team", label: en("A small team") },
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
resultsReveal: true as const,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
mode: "recommending" as const,
|
|
329
|
+
offers: [
|
|
330
|
+
{
|
|
331
|
+
key: "free_oss",
|
|
332
|
+
copy: { name: en("Free (OSS)") },
|
|
333
|
+
price: { kind: "label" as const, label: en("Free — self-hosted") },
|
|
334
|
+
rules: [
|
|
335
|
+
{
|
|
336
|
+
when: { answer: "q_team", eq: "solo" },
|
|
337
|
+
reason: en("Solo builders get the substrate free."),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
recommendation: {
|
|
343
|
+
key: "recommendation",
|
|
344
|
+
copy: { title: en("Your recommendation") },
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A flow with no mapsTo:'email' field (D9's legal-but-unused path).
|
|
350
|
+
function noEmailFlow() {
|
|
351
|
+
return {
|
|
352
|
+
slug: "feedback",
|
|
353
|
+
defaultLocale: "en",
|
|
354
|
+
steps: [
|
|
355
|
+
{
|
|
356
|
+
key: "rating",
|
|
357
|
+
type: "segmentation" as const,
|
|
358
|
+
copy: { title: en("How was it?") },
|
|
359
|
+
questions: [
|
|
360
|
+
{
|
|
361
|
+
name: "rating",
|
|
362
|
+
options: [
|
|
363
|
+
{ value: "good", label: en("Good") },
|
|
364
|
+
{ value: "bad", label: en("Bad") },
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const SITE_ID = "site-abc";
|
|
374
|
+
const SESSION_ID = "flsess_00000000-0000-7000-8000-0000000000c1";
|
|
375
|
+
const DEFINITION_ID = "flow_00000000-0000-7000-8000-0000000000d1";
|
|
376
|
+
|
|
377
|
+
describe("flow-engine", () => {
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
jest.clearAllMocks();
|
|
380
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
delete process.env.DATABASE_URL;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ── D5 — DB required ────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe("flows_not_configured (D5)", () => {
|
|
390
|
+
it("serveFlow returns flows_not_configured when DATABASE_URL is unset", async () => {
|
|
391
|
+
delete process.env.DATABASE_URL;
|
|
392
|
+
const result = await serveFlow(SITE_ID, "get-a-quote");
|
|
393
|
+
expect(result).toEqual({
|
|
394
|
+
ok: false,
|
|
395
|
+
error: { kind: "flows_not_configured", message: expect.any(String) },
|
|
396
|
+
});
|
|
397
|
+
expect(prisma.flowDefinition.findUnique).not.toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("submitStep returns flows_not_configured when DATABASE_URL is unset", async () => {
|
|
401
|
+
delete process.env.DATABASE_URL;
|
|
402
|
+
const result = await submitStep(
|
|
403
|
+
{
|
|
404
|
+
siteId: SITE_ID,
|
|
405
|
+
slug: "get-a-quote",
|
|
406
|
+
stepKey: "contact",
|
|
407
|
+
values: {},
|
|
408
|
+
},
|
|
409
|
+
sourceMeta,
|
|
410
|
+
);
|
|
411
|
+
expect(result).toEqual({
|
|
412
|
+
ok: false,
|
|
413
|
+
error: { kind: "flows_not_configured", message: expect.any(String) },
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ── serveFlow ────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
describe("serveFlow", () => {
|
|
421
|
+
it("returns the validated FlowConfig for a stored definition", async () => {
|
|
422
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
423
|
+
config: contactFirstFlow(),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await serveFlow(SITE_ID, "get-a-quote");
|
|
427
|
+
|
|
428
|
+
expect(result.ok).toBe(true);
|
|
429
|
+
if (result.ok) {
|
|
430
|
+
expect(result.config.slug).toBe("get-a-quote");
|
|
431
|
+
expect(result.config.steps.map((s) => s.type)).toEqual([
|
|
432
|
+
"field",
|
|
433
|
+
"segmentation",
|
|
434
|
+
"message",
|
|
435
|
+
]);
|
|
436
|
+
}
|
|
437
|
+
expect(prisma.flowDefinition.findUnique).toHaveBeenCalledWith({
|
|
438
|
+
where: { siteId_slug: { siteId: SITE_ID, slug: "get-a-quote" } },
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("returns flow_not_found when no definition exists for the slug", async () => {
|
|
443
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue(null);
|
|
444
|
+
|
|
445
|
+
const result = await serveFlow(SITE_ID, "missing");
|
|
446
|
+
|
|
447
|
+
expect(result).toEqual({
|
|
448
|
+
ok: false,
|
|
449
|
+
error: { kind: "flow_not_found", message: expect.any(String) },
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ── upsertFlowDefinition (D6) ────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
describe("upsertFlowDefinition", () => {
|
|
457
|
+
it("validates + upserts a well-formed config", async () => {
|
|
458
|
+
(prisma.flowDefinition.upsert as jest.Mock).mockResolvedValue({
|
|
459
|
+
id: DEFINITION_ID,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const result = await upsertFlowDefinition(SITE_ID, contactFirstFlow());
|
|
463
|
+
|
|
464
|
+
expect(result).toEqual({ ok: true, id: DEFINITION_ID });
|
|
465
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenCalledWith(
|
|
466
|
+
expect.objectContaining({
|
|
467
|
+
where: { siteId_slug: { siteId: SITE_ID, slug: "get-a-quote" } },
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("rejects an invalid config without touching prisma", async () => {
|
|
473
|
+
const result = await upsertFlowDefinition(SITE_ID, { not: "a flow" });
|
|
474
|
+
|
|
475
|
+
expect(result.ok).toBe(false);
|
|
476
|
+
expect(prisma.flowDefinition.upsert).not.toHaveBeenCalled();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ── seedFlowPresets (#3958 §3.3 — reuse, do not duplicate) ──────────────
|
|
481
|
+
|
|
482
|
+
describe("seedFlowPresets", () => {
|
|
483
|
+
it("upserts each preset once, in order, through upsertFlowDefinition", async () => {
|
|
484
|
+
(prisma.flowDefinition.upsert as jest.Mock)
|
|
485
|
+
.mockResolvedValueOnce({ id: "flow_a" })
|
|
486
|
+
.mockResolvedValueOnce({ id: "flow_b" });
|
|
487
|
+
|
|
488
|
+
const presetA = { ...contactFirstFlow(), slug: "form" };
|
|
489
|
+
const presetB = { ...contactFirstFlow(), slug: "waitlist" };
|
|
490
|
+
|
|
491
|
+
const result = await seedFlowPresets(SITE_ID, [presetA, presetB]);
|
|
492
|
+
|
|
493
|
+
expect(result).toEqual({ ok: true, seeded: ["form", "waitlist"] });
|
|
494
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(2);
|
|
495
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenNthCalledWith(
|
|
496
|
+
1,
|
|
497
|
+
expect.objectContaining({
|
|
498
|
+
where: { siteId_slug: { siteId: SITE_ID, slug: "form" } },
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenNthCalledWith(
|
|
502
|
+
2,
|
|
503
|
+
expect.objectContaining({
|
|
504
|
+
where: { siteId_slug: { siteId: SITE_ID, slug: "waitlist" } },
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("stops at the first invalid preset and reports what seeded before it — never a silent partial success", async () => {
|
|
510
|
+
(prisma.flowDefinition.upsert as jest.Mock).mockResolvedValueOnce({
|
|
511
|
+
id: "flow_a",
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const presetA = { ...contactFirstFlow(), slug: "form" };
|
|
515
|
+
const invalid = { not: "a flow" };
|
|
516
|
+
|
|
517
|
+
const result = await seedFlowPresets(SITE_ID, [presetA, invalid]);
|
|
518
|
+
|
|
519
|
+
expect(result.ok).toBe(false);
|
|
520
|
+
if (!result.ok) {
|
|
521
|
+
expect(result.seeded).toEqual(["form"]);
|
|
522
|
+
expect(result.error.kind).toBe("invalid_input");
|
|
523
|
+
}
|
|
524
|
+
// Only presetA's upsert fires — the invalid second preset stops the
|
|
525
|
+
// loop before a second (rejected) call is ever attempted.
|
|
526
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(1);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("is idempotent — re-running upserts the same slug again rather than duplicating", async () => {
|
|
530
|
+
(prisma.flowDefinition.upsert as jest.Mock)
|
|
531
|
+
.mockResolvedValueOnce({ id: "flow_a" })
|
|
532
|
+
.mockResolvedValueOnce({ id: "flow_a" });
|
|
533
|
+
|
|
534
|
+
const preset = { ...contactFirstFlow(), slug: "form" };
|
|
535
|
+
|
|
536
|
+
const first = await seedFlowPresets(SITE_ID, [preset]);
|
|
537
|
+
const second = await seedFlowPresets(SITE_ID, [preset]);
|
|
538
|
+
|
|
539
|
+
expect(first).toEqual({ ok: true, seeded: ["form"] });
|
|
540
|
+
expect(second).toEqual({ ok: true, seeded: ["form"] });
|
|
541
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(2);
|
|
542
|
+
// Both calls target the SAME siteId_slug key — an upsert on re-run,
|
|
543
|
+
// never a second row for the same preset.
|
|
544
|
+
for (const call of (prisma.flowDefinition.upsert as jest.Mock).mock
|
|
545
|
+
.calls) {
|
|
546
|
+
expect(call[0]).toEqual(
|
|
547
|
+
expect.objectContaining({
|
|
548
|
+
where: { siteId_slug: { siteId: SITE_ID, slug: "form" } },
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("the real FLOW_PRESETS array seeds cleanly end to end (every declared preset, whatever the current set is)", async () => {
|
|
555
|
+
// Derived from FLOW_PRESETS itself (DEF-007) — never a hardcoded
|
|
556
|
+
// enumeration of preset slugs, so adding a new preset (e.g. #3963's
|
|
557
|
+
// "roadmap") can't re-break this assertion the way a fixed
|
|
558
|
+
// ["form", "waitlist", "lead-magnet"] literal did (#4008 CI red).
|
|
559
|
+
(prisma.flowDefinition.upsert as jest.Mock).mockImplementation(
|
|
560
|
+
(args: { where: { siteId_slug: { slug: string } } }) =>
|
|
561
|
+
Promise.resolve({
|
|
562
|
+
id: `flow_${args.where.siteId_slug.slug.replace(/-/g, "_")}`,
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const result = await seedFlowPresets(SITE_ID, FLOW_PRESETS);
|
|
567
|
+
|
|
568
|
+
expect(result).toEqual({
|
|
569
|
+
ok: true,
|
|
570
|
+
seeded: FLOW_PRESETS.map((p) => p.slug),
|
|
571
|
+
});
|
|
572
|
+
expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(
|
|
573
|
+
FLOW_PRESETS.length,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ── submitStep — session creation + per-step persistence ────────────────
|
|
579
|
+
|
|
580
|
+
describe("submitStep — session lifecycle", () => {
|
|
581
|
+
it("creates a session on the first submission (no sessionId)", async () => {
|
|
582
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
583
|
+
id: DEFINITION_ID,
|
|
584
|
+
config: contactFirstFlow(),
|
|
585
|
+
});
|
|
586
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
587
|
+
id: SESSION_ID,
|
|
588
|
+
siteId: SITE_ID,
|
|
589
|
+
flowId: DEFINITION_ID,
|
|
590
|
+
flowSlug: "get-a-quote",
|
|
591
|
+
configSnapshot: contactFirstFlow(),
|
|
592
|
+
state: {},
|
|
593
|
+
email: null,
|
|
594
|
+
leadId: null,
|
|
595
|
+
completedAt: null,
|
|
596
|
+
});
|
|
597
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
598
|
+
|
|
599
|
+
const result = await submitStep(
|
|
600
|
+
{
|
|
601
|
+
siteId: SITE_ID,
|
|
602
|
+
slug: "get-a-quote",
|
|
603
|
+
stepKey: "contact",
|
|
604
|
+
values: { email: "lead@example.com" },
|
|
605
|
+
},
|
|
606
|
+
sourceMeta,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
expect(result).toMatchObject({
|
|
610
|
+
ok: true,
|
|
611
|
+
sessionId: SESSION_ID,
|
|
612
|
+
landed: "contact",
|
|
613
|
+
nextStepKey: "budget",
|
|
614
|
+
completed: false,
|
|
615
|
+
});
|
|
616
|
+
expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
|
|
617
|
+
// The partial-state capture — persisted immediately, not batched.
|
|
618
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
619
|
+
expect.objectContaining({
|
|
620
|
+
where: { id: SESSION_ID },
|
|
621
|
+
data: expect.objectContaining({
|
|
622
|
+
state: { contact: { email: "lead@example.com" } },
|
|
623
|
+
email: "lead@example.com",
|
|
624
|
+
}),
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("returns flow_not_found when no sessionId AND no definition exists for the slug", async () => {
|
|
630
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue(null);
|
|
631
|
+
|
|
632
|
+
const result = await submitStep(
|
|
633
|
+
{ siteId: SITE_ID, slug: "missing", stepKey: "contact", values: {} },
|
|
634
|
+
sourceMeta,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(result).toEqual({
|
|
638
|
+
ok: false,
|
|
639
|
+
error: { kind: "flow_not_found", message: expect.any(String) },
|
|
640
|
+
});
|
|
641
|
+
expect(prisma.flowSession.create).not.toHaveBeenCalled();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("reuses an in-progress session when sessionId is supplied", async () => {
|
|
645
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
646
|
+
id: SESSION_ID,
|
|
647
|
+
siteId: SITE_ID,
|
|
648
|
+
flowId: DEFINITION_ID,
|
|
649
|
+
flowSlug: "get-a-quote",
|
|
650
|
+
configSnapshot: contactFirstFlow(),
|
|
651
|
+
state: { contact: { email: "lead@example.com" } },
|
|
652
|
+
email: "lead@example.com",
|
|
653
|
+
leadId: null,
|
|
654
|
+
completedAt: null,
|
|
655
|
+
});
|
|
656
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
657
|
+
|
|
658
|
+
const result = await submitStep(
|
|
659
|
+
{
|
|
660
|
+
siteId: SITE_ID,
|
|
661
|
+
slug: "get-a-quote",
|
|
662
|
+
sessionId: SESSION_ID,
|
|
663
|
+
stepKey: "budget",
|
|
664
|
+
values: { budget: "small" },
|
|
665
|
+
},
|
|
666
|
+
sourceMeta,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
expect(result).toMatchObject({
|
|
670
|
+
ok: true,
|
|
671
|
+
sessionId: SESSION_ID,
|
|
672
|
+
landed: "budget",
|
|
673
|
+
nextStepKey: "thanks",
|
|
674
|
+
completed: false,
|
|
675
|
+
});
|
|
676
|
+
expect(prisma.flowDefinition.findUnique).not.toHaveBeenCalled();
|
|
677
|
+
expect(prisma.flowSession.create).not.toHaveBeenCalled();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("returns step_conflict for an unknown sessionId", async () => {
|
|
681
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(null);
|
|
682
|
+
|
|
683
|
+
const result = await submitStep(
|
|
684
|
+
{
|
|
685
|
+
siteId: SITE_ID,
|
|
686
|
+
slug: "get-a-quote",
|
|
687
|
+
sessionId: "flsess_does-not-exist",
|
|
688
|
+
stepKey: "budget",
|
|
689
|
+
values: {},
|
|
690
|
+
},
|
|
691
|
+
sourceMeta,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
expect(result).toEqual({
|
|
695
|
+
ok: false,
|
|
696
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("returns step_conflict when sessionId belongs to a different flow (flowSlug mismatch)", async () => {
|
|
701
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
702
|
+
id: SESSION_ID,
|
|
703
|
+
siteId: SITE_ID,
|
|
704
|
+
flowId: DEFINITION_ID,
|
|
705
|
+
flowSlug: "feedback", // a DIFFERENT flow than the one being submitted to
|
|
706
|
+
configSnapshot: noEmailFlow(),
|
|
707
|
+
state: {},
|
|
708
|
+
email: null,
|
|
709
|
+
leadId: null,
|
|
710
|
+
completedAt: null,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const result = await submitStep(
|
|
714
|
+
{
|
|
715
|
+
siteId: SITE_ID,
|
|
716
|
+
slug: "get-a-quote",
|
|
717
|
+
sessionId: SESSION_ID,
|
|
718
|
+
stepKey: "budget",
|
|
719
|
+
values: {},
|
|
720
|
+
},
|
|
721
|
+
sourceMeta,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
expect(result).toEqual({
|
|
725
|
+
ok: false,
|
|
726
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
727
|
+
});
|
|
728
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("returns step_conflict when resubmitting to an already-completed session", async () => {
|
|
732
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
733
|
+
id: SESSION_ID,
|
|
734
|
+
siteId: SITE_ID,
|
|
735
|
+
flowId: DEFINITION_ID,
|
|
736
|
+
flowSlug: "get-a-quote",
|
|
737
|
+
configSnapshot: contactFirstFlow(),
|
|
738
|
+
state: {},
|
|
739
|
+
email: "lead@example.com",
|
|
740
|
+
leadId: "lead_x",
|
|
741
|
+
completedAt: new Date(),
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const result = await submitStep(
|
|
745
|
+
{
|
|
746
|
+
siteId: SITE_ID,
|
|
747
|
+
slug: "get-a-quote",
|
|
748
|
+
sessionId: SESSION_ID,
|
|
749
|
+
stepKey: "budget",
|
|
750
|
+
values: { budget: "small" },
|
|
751
|
+
},
|
|
752
|
+
sourceMeta,
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
expect(result).toEqual({
|
|
756
|
+
ok: false,
|
|
757
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("returns step_conflict for an unknown stepKey in the snapshot", async () => {
|
|
762
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
763
|
+
id: SESSION_ID,
|
|
764
|
+
siteId: SITE_ID,
|
|
765
|
+
flowId: DEFINITION_ID,
|
|
766
|
+
flowSlug: "get-a-quote",
|
|
767
|
+
configSnapshot: contactFirstFlow(),
|
|
768
|
+
state: {},
|
|
769
|
+
email: null,
|
|
770
|
+
leadId: null,
|
|
771
|
+
completedAt: null,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const result = await submitStep(
|
|
775
|
+
{
|
|
776
|
+
siteId: SITE_ID,
|
|
777
|
+
slug: "get-a-quote",
|
|
778
|
+
sessionId: SESSION_ID,
|
|
779
|
+
stepKey: "does_not_exist",
|
|
780
|
+
values: {},
|
|
781
|
+
},
|
|
782
|
+
sourceMeta,
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
expect(result).toEqual({
|
|
786
|
+
ok: false,
|
|
787
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// ── submitStep — recommending mode: no-PII completion + unsubmittable
|
|
793
|
+
// recommendation marker (#4769 AC4) ──────────────────────────────────────
|
|
794
|
+
|
|
795
|
+
describe("submitStep — recommending mode (#4769 AC4)", () => {
|
|
796
|
+
it("completes through submitStep on question answers alone, with no field mapping to email/name/phone submitted and no Lead row created", async () => {
|
|
797
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
798
|
+
id: SESSION_ID,
|
|
799
|
+
siteId: SITE_ID,
|
|
800
|
+
flowSlug: "ailk-recommender",
|
|
801
|
+
configSnapshot: recommendingServedFlow(),
|
|
802
|
+
state: {},
|
|
803
|
+
email: null,
|
|
804
|
+
leadId: null,
|
|
805
|
+
completedAt: null,
|
|
806
|
+
});
|
|
807
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
808
|
+
|
|
809
|
+
const result = await submitStep(
|
|
810
|
+
{
|
|
811
|
+
siteId: SITE_ID,
|
|
812
|
+
slug: "ailk-recommender",
|
|
813
|
+
sessionId: SESSION_ID,
|
|
814
|
+
stepKey: "q_team",
|
|
815
|
+
values: { q_team: "solo" },
|
|
816
|
+
},
|
|
817
|
+
sourceMeta,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
821
|
+
expect(result.ok && result.leadId).toBeUndefined();
|
|
822
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
823
|
+
expect(prisma.flowSession.update).toHaveBeenLastCalledWith(
|
|
824
|
+
expect.objectContaining({
|
|
825
|
+
where: { id: SESSION_ID },
|
|
826
|
+
data: { completedAt: expect.any(Date) },
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("returns step_conflict, without throwing, when the recommendation marker's key is submitted", async () => {
|
|
832
|
+
const flow = recommendingServedFlow();
|
|
833
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
834
|
+
id: SESSION_ID,
|
|
835
|
+
siteId: SITE_ID,
|
|
836
|
+
flowSlug: "ailk-recommender",
|
|
837
|
+
configSnapshot: flow,
|
|
838
|
+
state: {},
|
|
839
|
+
email: null,
|
|
840
|
+
leadId: null,
|
|
841
|
+
completedAt: null,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const result = await submitStep(
|
|
845
|
+
{
|
|
846
|
+
siteId: SITE_ID,
|
|
847
|
+
slug: "ailk-recommender",
|
|
848
|
+
sessionId: SESSION_ID,
|
|
849
|
+
stepKey: flow.recommendation.key,
|
|
850
|
+
values: {},
|
|
851
|
+
},
|
|
852
|
+
sourceMeta,
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
expect(result).toEqual({
|
|
856
|
+
ok: false,
|
|
857
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
858
|
+
});
|
|
859
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
860
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// ── submitStep — session locale persistence (#3962 D3) ──────────────────
|
|
865
|
+
|
|
866
|
+
describe("submitStep — session locale persistence (#3962 D3)", () => {
|
|
867
|
+
it("persists the explicit locale on session create", async () => {
|
|
868
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
869
|
+
id: DEFINITION_ID,
|
|
870
|
+
config: contactFirstFlow(),
|
|
871
|
+
});
|
|
872
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
873
|
+
id: SESSION_ID,
|
|
874
|
+
siteId: SITE_ID,
|
|
875
|
+
flowId: DEFINITION_ID,
|
|
876
|
+
flowSlug: "get-a-quote",
|
|
877
|
+
configSnapshot: contactFirstFlow(),
|
|
878
|
+
state: {},
|
|
879
|
+
email: null,
|
|
880
|
+
leadId: null,
|
|
881
|
+
completedAt: null,
|
|
882
|
+
});
|
|
883
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
884
|
+
|
|
885
|
+
await submitStep(
|
|
886
|
+
{
|
|
887
|
+
siteId: SITE_ID,
|
|
888
|
+
slug: "get-a-quote",
|
|
889
|
+
stepKey: "contact",
|
|
890
|
+
values: { email: "lead@example.com" },
|
|
891
|
+
locale: "pt-BR",
|
|
892
|
+
},
|
|
893
|
+
sourceMeta,
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
expect(prisma.flowSession.create).toHaveBeenCalledWith(
|
|
897
|
+
expect.objectContaining({
|
|
898
|
+
data: expect.objectContaining({ locale: "pt-BR" }),
|
|
899
|
+
}),
|
|
900
|
+
);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("falls back to the config's defaultLocale when no locale is submitted", async () => {
|
|
904
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
905
|
+
id: DEFINITION_ID,
|
|
906
|
+
config: contactFirstFlow(), // defaultLocale: "en"
|
|
907
|
+
});
|
|
908
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
909
|
+
id: SESSION_ID,
|
|
910
|
+
siteId: SITE_ID,
|
|
911
|
+
flowId: DEFINITION_ID,
|
|
912
|
+
flowSlug: "get-a-quote",
|
|
913
|
+
configSnapshot: contactFirstFlow(),
|
|
914
|
+
state: {},
|
|
915
|
+
email: null,
|
|
916
|
+
leadId: null,
|
|
917
|
+
completedAt: null,
|
|
918
|
+
});
|
|
919
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
920
|
+
|
|
921
|
+
await submitStep(
|
|
922
|
+
{
|
|
923
|
+
siteId: SITE_ID,
|
|
924
|
+
slug: "get-a-quote",
|
|
925
|
+
stepKey: "contact",
|
|
926
|
+
values: { email: "lead@example.com" },
|
|
927
|
+
},
|
|
928
|
+
sourceMeta,
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
expect(prisma.flowSession.create).toHaveBeenCalledWith(
|
|
932
|
+
expect.objectContaining({
|
|
933
|
+
data: expect.objectContaining({ locale: "en" }),
|
|
934
|
+
}),
|
|
935
|
+
);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("does not update locale when resuming an existing session (set once at create)", async () => {
|
|
939
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
940
|
+
id: SESSION_ID,
|
|
941
|
+
siteId: SITE_ID,
|
|
942
|
+
flowId: DEFINITION_ID,
|
|
943
|
+
flowSlug: "get-a-quote",
|
|
944
|
+
configSnapshot: contactFirstFlow(),
|
|
945
|
+
state: { contact: { email: "lead@example.com" } },
|
|
946
|
+
email: "lead@example.com",
|
|
947
|
+
leadId: null,
|
|
948
|
+
completedAt: null,
|
|
949
|
+
});
|
|
950
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
951
|
+
|
|
952
|
+
await submitStep(
|
|
953
|
+
{
|
|
954
|
+
siteId: SITE_ID,
|
|
955
|
+
slug: "get-a-quote",
|
|
956
|
+
sessionId: SESSION_ID,
|
|
957
|
+
stepKey: "budget",
|
|
958
|
+
values: { budget: "small" },
|
|
959
|
+
locale: "es",
|
|
960
|
+
},
|
|
961
|
+
sourceMeta,
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
expect(prisma.flowSession.create).not.toHaveBeenCalled();
|
|
965
|
+
for (const call of (prisma.flowSession.update as jest.Mock).mock.calls) {
|
|
966
|
+
expect(call[0].data).not.toHaveProperty("locale");
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ── submitStep — value validation ───────────────────────────────────────
|
|
972
|
+
|
|
973
|
+
describe("submitStep — required fields + select/segmentation membership (D3)", () => {
|
|
974
|
+
it("rejects a missing required field", async () => {
|
|
975
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
976
|
+
id: DEFINITION_ID,
|
|
977
|
+
config: contactFirstFlow(),
|
|
978
|
+
});
|
|
979
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
980
|
+
id: SESSION_ID,
|
|
981
|
+
siteId: SITE_ID,
|
|
982
|
+
state: {},
|
|
983
|
+
email: null,
|
|
984
|
+
completedAt: null,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
const result = await submitStep(
|
|
988
|
+
{
|
|
989
|
+
siteId: SITE_ID,
|
|
990
|
+
slug: "get-a-quote",
|
|
991
|
+
stepKey: "contact",
|
|
992
|
+
values: {},
|
|
993
|
+
},
|
|
994
|
+
sourceMeta,
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
expect(result).toEqual({
|
|
998
|
+
ok: false,
|
|
999
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1000
|
+
});
|
|
1001
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("rejects a malformed value for an email-typed field (so it can never fail routeLead silently at completion)", async () => {
|
|
1005
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1006
|
+
id: DEFINITION_ID,
|
|
1007
|
+
config: contactFirstFlow(),
|
|
1008
|
+
});
|
|
1009
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1010
|
+
id: SESSION_ID,
|
|
1011
|
+
siteId: SITE_ID,
|
|
1012
|
+
state: {},
|
|
1013
|
+
email: null,
|
|
1014
|
+
completedAt: null,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
const result = await submitStep(
|
|
1018
|
+
{
|
|
1019
|
+
siteId: SITE_ID,
|
|
1020
|
+
slug: "get-a-quote",
|
|
1021
|
+
stepKey: "contact",
|
|
1022
|
+
values: { email: "not-an-email" },
|
|
1023
|
+
},
|
|
1024
|
+
sourceMeta,
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
expect(result).toEqual({
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1030
|
+
});
|
|
1031
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("rejects an over-length email value (>254, leadSubmitSchema's own bound) rather than letting it fail routeLead silently at completion", async () => {
|
|
1035
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1036
|
+
id: DEFINITION_ID,
|
|
1037
|
+
config: contactFirstFlow(),
|
|
1038
|
+
});
|
|
1039
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1040
|
+
id: SESSION_ID,
|
|
1041
|
+
siteId: SITE_ID,
|
|
1042
|
+
state: {},
|
|
1043
|
+
email: null,
|
|
1044
|
+
completedAt: null,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
const overLengthEmail = `${"a".repeat(250)}@example.com`; // > 254 chars
|
|
1048
|
+
const result = await submitStep(
|
|
1049
|
+
{
|
|
1050
|
+
siteId: SITE_ID,
|
|
1051
|
+
slug: "get-a-quote",
|
|
1052
|
+
stepKey: "contact",
|
|
1053
|
+
values: { email: overLengthEmail },
|
|
1054
|
+
},
|
|
1055
|
+
sourceMeta,
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
expect(result).toEqual({
|
|
1059
|
+
ok: false,
|
|
1060
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1061
|
+
});
|
|
1062
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it("accepts a normal-length text-field value", async () => {
|
|
1066
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1067
|
+
id: DEFINITION_ID,
|
|
1068
|
+
config: contactFirstFlow(),
|
|
1069
|
+
});
|
|
1070
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1071
|
+
id: SESSION_ID,
|
|
1072
|
+
siteId: SITE_ID,
|
|
1073
|
+
state: {},
|
|
1074
|
+
email: null,
|
|
1075
|
+
completedAt: null,
|
|
1076
|
+
});
|
|
1077
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1078
|
+
|
|
1079
|
+
const result = await submitStep(
|
|
1080
|
+
{
|
|
1081
|
+
siteId: SITE_ID,
|
|
1082
|
+
slug: "get-a-quote",
|
|
1083
|
+
stepKey: "contact",
|
|
1084
|
+
values: { email: "lead@example.com", company: "Acme Inc." },
|
|
1085
|
+
},
|
|
1086
|
+
sourceMeta,
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
expect(result).toMatchObject({ ok: true });
|
|
1090
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
1091
|
+
expect.objectContaining({
|
|
1092
|
+
data: expect.objectContaining({
|
|
1093
|
+
state: {
|
|
1094
|
+
contact: { email: "lead@example.com", company: "Acme Inc." },
|
|
1095
|
+
},
|
|
1096
|
+
}),
|
|
1097
|
+
}),
|
|
1098
|
+
);
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("rejects an over-length text-field value (>256) — security-review finding: unbounded text values", async () => {
|
|
1102
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1103
|
+
id: DEFINITION_ID,
|
|
1104
|
+
config: contactFirstFlow(),
|
|
1105
|
+
});
|
|
1106
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1107
|
+
id: SESSION_ID,
|
|
1108
|
+
siteId: SITE_ID,
|
|
1109
|
+
state: {},
|
|
1110
|
+
email: null,
|
|
1111
|
+
completedAt: null,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const result = await submitStep(
|
|
1115
|
+
{
|
|
1116
|
+
siteId: SITE_ID,
|
|
1117
|
+
slug: "get-a-quote",
|
|
1118
|
+
stepKey: "contact",
|
|
1119
|
+
values: {
|
|
1120
|
+
email: "lead@example.com",
|
|
1121
|
+
company: "a".repeat(257),
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
sourceMeta,
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
expect(result).toEqual({
|
|
1128
|
+
ok: false,
|
|
1129
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1130
|
+
});
|
|
1131
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("rejects a non-string value for a text field — security-review finding: unbounded/untyped values", async () => {
|
|
1135
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1136
|
+
id: DEFINITION_ID,
|
|
1137
|
+
config: contactFirstFlow(),
|
|
1138
|
+
});
|
|
1139
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1140
|
+
id: SESSION_ID,
|
|
1141
|
+
siteId: SITE_ID,
|
|
1142
|
+
state: {},
|
|
1143
|
+
email: null,
|
|
1144
|
+
completedAt: null,
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
const result = await submitStep(
|
|
1148
|
+
{
|
|
1149
|
+
siteId: SITE_ID,
|
|
1150
|
+
slug: "get-a-quote",
|
|
1151
|
+
stepKey: "contact",
|
|
1152
|
+
values: { email: "lead@example.com", company: { nested: "object" } },
|
|
1153
|
+
},
|
|
1154
|
+
sourceMeta,
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
expect(result).toEqual({
|
|
1158
|
+
ok: false,
|
|
1159
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1160
|
+
});
|
|
1161
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it("rejects a segmentation answer that is not one of the declared options", async () => {
|
|
1165
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1166
|
+
id: SESSION_ID,
|
|
1167
|
+
siteId: SITE_ID,
|
|
1168
|
+
flowSlug: "get-a-quote",
|
|
1169
|
+
configSnapshot: contactFirstFlow(),
|
|
1170
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1171
|
+
email: "lead@example.com",
|
|
1172
|
+
completedAt: null,
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
const result = await submitStep(
|
|
1176
|
+
{
|
|
1177
|
+
siteId: SITE_ID,
|
|
1178
|
+
slug: "get-a-quote",
|
|
1179
|
+
sessionId: SESSION_ID,
|
|
1180
|
+
stepKey: "budget",
|
|
1181
|
+
values: { budget: "not-a-real-option" },
|
|
1182
|
+
},
|
|
1183
|
+
sourceMeta,
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
expect(result).toEqual({
|
|
1187
|
+
ok: false,
|
|
1188
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("drops an undeclared key from values rather than persisting it", async () => {
|
|
1193
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1194
|
+
id: DEFINITION_ID,
|
|
1195
|
+
config: contactFirstFlow(),
|
|
1196
|
+
});
|
|
1197
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1198
|
+
id: SESSION_ID,
|
|
1199
|
+
siteId: SITE_ID,
|
|
1200
|
+
state: {},
|
|
1201
|
+
email: null,
|
|
1202
|
+
completedAt: null,
|
|
1203
|
+
});
|
|
1204
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1205
|
+
|
|
1206
|
+
await submitStep(
|
|
1207
|
+
{
|
|
1208
|
+
siteId: SITE_ID,
|
|
1209
|
+
slug: "get-a-quote",
|
|
1210
|
+
stepKey: "contact",
|
|
1211
|
+
values: { email: "lead@example.com", attackerField: "smuggled" },
|
|
1212
|
+
},
|
|
1213
|
+
sourceMeta,
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
1217
|
+
expect.objectContaining({
|
|
1218
|
+
data: expect.objectContaining({
|
|
1219
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1220
|
+
}),
|
|
1221
|
+
}),
|
|
1222
|
+
);
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
// ── AC2 — the abandon-after-email partial lead ──────────────────────────
|
|
1227
|
+
|
|
1228
|
+
describe("AC2 — abandon after the email step persists a partial lead", () => {
|
|
1229
|
+
it("landing the contact step denormalizes session.email and persists state; the session never completes on its own", async () => {
|
|
1230
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
1231
|
+
id: DEFINITION_ID,
|
|
1232
|
+
config: contactFirstFlow(),
|
|
1233
|
+
});
|
|
1234
|
+
(prisma.flowSession.create as jest.Mock).mockResolvedValue({
|
|
1235
|
+
id: SESSION_ID,
|
|
1236
|
+
siteId: SITE_ID,
|
|
1237
|
+
state: {},
|
|
1238
|
+
email: null,
|
|
1239
|
+
leadId: null,
|
|
1240
|
+
completedAt: null,
|
|
1241
|
+
});
|
|
1242
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1243
|
+
|
|
1244
|
+
const result = await submitStep(
|
|
1245
|
+
{
|
|
1246
|
+
siteId: SITE_ID,
|
|
1247
|
+
slug: "get-a-quote",
|
|
1248
|
+
stepKey: "contact",
|
|
1249
|
+
values: { email: "abandoner@example.com" },
|
|
1250
|
+
},
|
|
1251
|
+
sourceMeta,
|
|
1252
|
+
);
|
|
1253
|
+
|
|
1254
|
+
expect(result).toMatchObject({ ok: true, completed: false });
|
|
1255
|
+
// Exactly one persist for this one landed step — the partial-state
|
|
1256
|
+
// capture happens on landing, not batched at the (never-reached) end.
|
|
1257
|
+
expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
|
|
1258
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith({
|
|
1259
|
+
where: { id: SESSION_ID },
|
|
1260
|
+
data: {
|
|
1261
|
+
state: { contact: { email: "abandoner@example.com" } },
|
|
1262
|
+
// #4663 D1/D2 — flags is re-derived + persisted on EVERY landing;
|
|
1263
|
+
// a flow declaring no `flags` always re-derives `{}`.
|
|
1264
|
+
flags: {},
|
|
1265
|
+
email: "abandoner@example.com",
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
// completedAt is never touched — routeLead/completion is never reached.
|
|
1269
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
// ── AC4 — completion composes routeLead ─────────────────────────────────
|
|
1274
|
+
|
|
1275
|
+
describe("AC4 — completion composes routeLead", () => {
|
|
1276
|
+
beforeEach(() => {
|
|
1277
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1278
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
afterEach(() => {
|
|
1282
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1283
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it("completing the final step creates a Lead via routeLead and stamps completedAt + leadId", async () => {
|
|
1287
|
+
const leadRowId = "lead_00000000-0000-7000-8000-0000000000e1";
|
|
1288
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1289
|
+
id: SESSION_ID,
|
|
1290
|
+
siteId: SITE_ID,
|
|
1291
|
+
flowSlug: "get-a-quote",
|
|
1292
|
+
configSnapshot: contactFirstFlow(),
|
|
1293
|
+
state: {
|
|
1294
|
+
contact: { email: "complete@example.com" },
|
|
1295
|
+
budget: { budget: "large" },
|
|
1296
|
+
},
|
|
1297
|
+
email: "complete@example.com",
|
|
1298
|
+
leadId: null,
|
|
1299
|
+
completedAt: null,
|
|
1300
|
+
});
|
|
1301
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1302
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: leadRowId });
|
|
1303
|
+
|
|
1304
|
+
const result = await submitStep(
|
|
1305
|
+
{
|
|
1306
|
+
siteId: SITE_ID,
|
|
1307
|
+
slug: "get-a-quote",
|
|
1308
|
+
sessionId: SESSION_ID,
|
|
1309
|
+
stepKey: "thanks",
|
|
1310
|
+
values: {},
|
|
1311
|
+
},
|
|
1312
|
+
sourceMeta,
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
expect(result).toMatchObject({
|
|
1316
|
+
ok: true,
|
|
1317
|
+
landed: "thanks",
|
|
1318
|
+
nextStepKey: null,
|
|
1319
|
+
completed: true,
|
|
1320
|
+
leadId: leadRowId,
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// routeLead composition (source-code contract, not a mock — the real
|
|
1324
|
+
// service ran and persisted via prisma.lead.create).
|
|
1325
|
+
expect(prisma.lead.create).toHaveBeenCalledWith(
|
|
1326
|
+
expect.objectContaining({
|
|
1327
|
+
data: expect.objectContaining({
|
|
1328
|
+
siteId: SITE_ID,
|
|
1329
|
+
source: "flow:get-a-quote",
|
|
1330
|
+
email: "complete@example.com",
|
|
1331
|
+
payload: expect.objectContaining({
|
|
1332
|
+
email: "complete@example.com",
|
|
1333
|
+
budget: "large",
|
|
1334
|
+
flowSlug: "get-a-quote",
|
|
1335
|
+
locale: "en",
|
|
1336
|
+
}),
|
|
1337
|
+
}),
|
|
1338
|
+
}),
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
// The completion stamp is a SECOND, distinct update from the per-step
|
|
1342
|
+
// state persist (the two writes D3 describes: land, then complete).
|
|
1343
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
1344
|
+
expect.objectContaining({
|
|
1345
|
+
where: { id: SESSION_ID },
|
|
1346
|
+
data: expect.objectContaining({
|
|
1347
|
+
completedAt: expect.any(Date),
|
|
1348
|
+
leadId: leadRowId,
|
|
1349
|
+
}),
|
|
1350
|
+
}),
|
|
1351
|
+
);
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("D9 — a flow with no email-mapped field completes without a Lead row (the session is the record)", async () => {
|
|
1355
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1356
|
+
id: SESSION_ID,
|
|
1357
|
+
siteId: SITE_ID,
|
|
1358
|
+
flowSlug: "feedback",
|
|
1359
|
+
configSnapshot: noEmailFlow(),
|
|
1360
|
+
state: {},
|
|
1361
|
+
email: null,
|
|
1362
|
+
leadId: null,
|
|
1363
|
+
completedAt: null,
|
|
1364
|
+
});
|
|
1365
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1366
|
+
|
|
1367
|
+
const result = await submitStep(
|
|
1368
|
+
{
|
|
1369
|
+
siteId: SITE_ID,
|
|
1370
|
+
slug: "feedback",
|
|
1371
|
+
sessionId: SESSION_ID,
|
|
1372
|
+
stepKey: "rating",
|
|
1373
|
+
values: { rating: "good" },
|
|
1374
|
+
},
|
|
1375
|
+
sourceMeta,
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1379
|
+
expect(result.ok && result.leadId).toBeUndefined();
|
|
1380
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1381
|
+
expect(prisma.flowSession.update).toHaveBeenLastCalledWith(
|
|
1382
|
+
expect.objectContaining({
|
|
1383
|
+
where: { id: SESSION_ID },
|
|
1384
|
+
data: { completedAt: expect.any(Date) },
|
|
1385
|
+
}),
|
|
1386
|
+
);
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// ── #3961 D4 — deliverable fulfillment call ──────────────────────────────
|
|
1391
|
+
|
|
1392
|
+
describe("submitStep — deliverable fulfillment call (#3961 D4)", () => {
|
|
1393
|
+
beforeEach(() => {
|
|
1394
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "deliverable@example.com";
|
|
1395
|
+
process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
|
|
1396
|
+
(prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
|
|
1397
|
+
id: "dasset_00000000-0000-7000-8000-0000000000a1",
|
|
1398
|
+
siteId: SITE_ID,
|
|
1399
|
+
key: "guide-starter",
|
|
1400
|
+
});
|
|
1401
|
+
(prisma.deliverableGrant.create as jest.Mock).mockResolvedValue({
|
|
1402
|
+
id: "dgrant_00000000-0000-7000-8000-0000000000g1",
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
afterEach(() => {
|
|
1407
|
+
delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
|
|
1408
|
+
delete process.env.AILK_PUBLIC_API_URL;
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("calls the real fulfillDeliverable once with the request-local segment on the leadResult.ok completion path", async () => {
|
|
1412
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1413
|
+
id: SESSION_ID,
|
|
1414
|
+
siteId: SITE_ID,
|
|
1415
|
+
flowId: DEFINITION_ID,
|
|
1416
|
+
flowSlug: "get-a-quote",
|
|
1417
|
+
configSnapshot: contactThenBudgetOnlyFlowWithDeliverables(),
|
|
1418
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1419
|
+
email: "lead@example.com",
|
|
1420
|
+
segment: null,
|
|
1421
|
+
leadId: null,
|
|
1422
|
+
completedAt: null,
|
|
1423
|
+
});
|
|
1424
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1425
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1426
|
+
id: "lead_00000000-0000-7000-8000-0000000000e2",
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
const result = await submitStep(
|
|
1430
|
+
{
|
|
1431
|
+
siteId: SITE_ID,
|
|
1432
|
+
slug: "get-a-quote",
|
|
1433
|
+
sessionId: SESSION_ID,
|
|
1434
|
+
stepKey: "budget",
|
|
1435
|
+
values: { budget: "small" },
|
|
1436
|
+
},
|
|
1437
|
+
sourceMeta,
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
expect(result).toMatchObject({
|
|
1441
|
+
ok: true,
|
|
1442
|
+
completed: true,
|
|
1443
|
+
segment: "starter",
|
|
1444
|
+
});
|
|
1445
|
+
// The real fulfillDeliverable resolved "starter"'s entry (guide-starter),
|
|
1446
|
+
// minted a grant for it, and sent via the real (email-package-mocked)
|
|
1447
|
+
// sendDeliverableEmail — the observable trail through its own real
|
|
1448
|
+
// boundaries (prisma, @working-theory/email), never a mock of the
|
|
1449
|
+
// service itself.
|
|
1450
|
+
expect(prisma.deliverableAsset.findUnique).toHaveBeenCalledWith(
|
|
1451
|
+
expect.objectContaining({
|
|
1452
|
+
where: {
|
|
1453
|
+
siteId_key: { siteId: SITE_ID, key: "guide-starter" },
|
|
1454
|
+
},
|
|
1455
|
+
}),
|
|
1456
|
+
);
|
|
1457
|
+
expect(prisma.deliverableGrant.create).toHaveBeenCalledWith(
|
|
1458
|
+
expect.objectContaining({
|
|
1459
|
+
data: expect.objectContaining({
|
|
1460
|
+
siteId: SITE_ID,
|
|
1461
|
+
assetId: "dasset_00000000-0000-7000-8000-0000000000a1",
|
|
1462
|
+
leadId: "lead_00000000-0000-7000-8000-0000000000e2",
|
|
1463
|
+
sessionId: SESSION_ID,
|
|
1464
|
+
}),
|
|
1465
|
+
}),
|
|
1466
|
+
);
|
|
1467
|
+
expect(mockSendDeliverableEmail).toHaveBeenCalledWith(
|
|
1468
|
+
expect.objectContaining({
|
|
1469
|
+
to: "lead@example.com",
|
|
1470
|
+
downloadLink:
|
|
1471
|
+
"https://api.example.com/v1/deliverables/dgrant_00000000-0000-7000-8000-0000000000g1",
|
|
1472
|
+
}),
|
|
1473
|
+
);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it("no deliverables block → no call (AC4 regression floor)", async () => {
|
|
1477
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1478
|
+
id: SESSION_ID,
|
|
1479
|
+
siteId: SITE_ID,
|
|
1480
|
+
flowId: DEFINITION_ID,
|
|
1481
|
+
flowSlug: "get-a-quote",
|
|
1482
|
+
configSnapshot: contactFirstFlow(),
|
|
1483
|
+
state: {
|
|
1484
|
+
contact: { email: "complete@example.com" },
|
|
1485
|
+
budget: { budget: "large" },
|
|
1486
|
+
},
|
|
1487
|
+
email: "complete@example.com",
|
|
1488
|
+
leadId: null,
|
|
1489
|
+
completedAt: null,
|
|
1490
|
+
});
|
|
1491
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1492
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1493
|
+
id: "lead_00000000-0000-7000-8000-0000000000e3",
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
const result = await submitStep(
|
|
1497
|
+
{
|
|
1498
|
+
siteId: SITE_ID,
|
|
1499
|
+
slug: "get-a-quote",
|
|
1500
|
+
sessionId: SESSION_ID,
|
|
1501
|
+
stepKey: "thanks",
|
|
1502
|
+
values: {},
|
|
1503
|
+
},
|
|
1504
|
+
sourceMeta,
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1508
|
+
expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
|
|
1509
|
+
expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
|
|
1510
|
+
expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it("a genuine fulfillDeliverable failure (a DB boundary throw) does not fail the submission", async () => {
|
|
1514
|
+
// A real, unexpected exception escaping fulfillDeliverable's own
|
|
1515
|
+
// internal log-and-skip postures (which cover expected business
|
|
1516
|
+
// misses, not exceptions) — the residual case flow-engine.ts's
|
|
1517
|
+
// try/catch exists for (D4).
|
|
1518
|
+
(prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(
|
|
1519
|
+
new Error("boom"),
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1523
|
+
id: SESSION_ID,
|
|
1524
|
+
siteId: SITE_ID,
|
|
1525
|
+
flowId: DEFINITION_ID,
|
|
1526
|
+
flowSlug: "get-a-quote",
|
|
1527
|
+
configSnapshot: contactThenBudgetOnlyFlowWithDeliverables(),
|
|
1528
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1529
|
+
email: "lead@example.com",
|
|
1530
|
+
segment: null,
|
|
1531
|
+
leadId: null,
|
|
1532
|
+
completedAt: null,
|
|
1533
|
+
});
|
|
1534
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1535
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1536
|
+
id: "lead_00000000-0000-7000-8000-0000000000e4",
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
const result = await submitStep(
|
|
1540
|
+
{
|
|
1541
|
+
siteId: SITE_ID,
|
|
1542
|
+
slug: "get-a-quote",
|
|
1543
|
+
sessionId: SESSION_ID,
|
|
1544
|
+
stepKey: "budget",
|
|
1545
|
+
values: { budget: "small" },
|
|
1546
|
+
},
|
|
1547
|
+
sourceMeta,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1551
|
+
expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
|
|
1552
|
+
expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// ── #4662 D6 — waitlist scoring hook call ────────────────────────────────
|
|
1557
|
+
|
|
1558
|
+
describe("submitStep — waitlist scoring hook call (#4662 D6)", () => {
|
|
1559
|
+
it("evaluates + persists exactly one waitlistScore row on a snapshot carrying `experiment`", async () => {
|
|
1560
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1561
|
+
id: SESSION_ID,
|
|
1562
|
+
siteId: SITE_ID,
|
|
1563
|
+
flowId: DEFINITION_ID,
|
|
1564
|
+
flowSlug: "get-a-quote",
|
|
1565
|
+
configSnapshot: contactFirstFlowWithExperiment(),
|
|
1566
|
+
state: {
|
|
1567
|
+
contact: { email: "lead@example.com" },
|
|
1568
|
+
budget: { budget: "large" },
|
|
1569
|
+
},
|
|
1570
|
+
email: "lead@example.com",
|
|
1571
|
+
leadId: null,
|
|
1572
|
+
completedAt: null,
|
|
1573
|
+
});
|
|
1574
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1575
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1576
|
+
id: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
1577
|
+
});
|
|
1578
|
+
(prisma.waitlistExperiment.findUnique as jest.Mock).mockResolvedValue({
|
|
1579
|
+
config: waitlistExperimentConfigForFixture(),
|
|
1580
|
+
});
|
|
1581
|
+
(prisma.waitlistScore.upsert as jest.Mock).mockResolvedValue({ id: "wlscore_1" });
|
|
1582
|
+
|
|
1583
|
+
const result = await submitStep(
|
|
1584
|
+
{
|
|
1585
|
+
siteId: SITE_ID,
|
|
1586
|
+
slug: "get-a-quote",
|
|
1587
|
+
sessionId: SESSION_ID,
|
|
1588
|
+
stepKey: "thanks",
|
|
1589
|
+
values: {},
|
|
1590
|
+
},
|
|
1591
|
+
sourceMeta,
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1595
|
+
expect(prisma.waitlistExperiment.findUnique).toHaveBeenCalledWith(
|
|
1596
|
+
expect.objectContaining({
|
|
1597
|
+
where: { siteId_key: { siteId: SITE_ID, key: "ailk-founder-diagnostic" } },
|
|
1598
|
+
}),
|
|
1599
|
+
);
|
|
1600
|
+
expect(prisma.waitlistScore.upsert).toHaveBeenCalledTimes(1);
|
|
1601
|
+
expect(prisma.waitlistScore.upsert).toHaveBeenCalledWith(
|
|
1602
|
+
expect.objectContaining({
|
|
1603
|
+
// The composite key since #5036 D6d — one row per (lead, EXPERIMENT),
|
|
1604
|
+
// not per lead, so a visitor who joins two founders' waitlists keeps
|
|
1605
|
+
// both scores.
|
|
1606
|
+
where: {
|
|
1607
|
+
leadId_experimentKey: {
|
|
1608
|
+
leadId: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
1609
|
+
experimentKey: "ailk-founder-diagnostic",
|
|
1610
|
+
},
|
|
1611
|
+
},
|
|
1612
|
+
create: expect.objectContaining({
|
|
1613
|
+
siteId: SITE_ID,
|
|
1614
|
+
leadId: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
1615
|
+
experimentKey: "ailk-founder-diagnostic",
|
|
1616
|
+
waitlistKey: "founder_diagnostic",
|
|
1617
|
+
tier: "hot",
|
|
1618
|
+
score: 10,
|
|
1619
|
+
categoryScores: { total: 10 },
|
|
1620
|
+
}),
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it("a thrown scoring error (a genuine DB boundary failure) still completes the submission with leadId", async () => {
|
|
1626
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1627
|
+
id: SESSION_ID,
|
|
1628
|
+
siteId: SITE_ID,
|
|
1629
|
+
flowId: DEFINITION_ID,
|
|
1630
|
+
flowSlug: "get-a-quote",
|
|
1631
|
+
configSnapshot: contactFirstFlowWithExperiment(),
|
|
1632
|
+
state: {
|
|
1633
|
+
contact: { email: "lead2@example.com" },
|
|
1634
|
+
budget: { budget: "small" },
|
|
1635
|
+
},
|
|
1636
|
+
email: "lead2@example.com",
|
|
1637
|
+
leadId: null,
|
|
1638
|
+
completedAt: null,
|
|
1639
|
+
});
|
|
1640
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1641
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1642
|
+
id: "lead_00000000-0000-7000-8000-0000000000f2",
|
|
1643
|
+
});
|
|
1644
|
+
(prisma.waitlistExperiment.findUnique as jest.Mock).mockRejectedValue(
|
|
1645
|
+
new Error("boom"),
|
|
1646
|
+
);
|
|
1647
|
+
|
|
1648
|
+
const result = await submitStep(
|
|
1649
|
+
{
|
|
1650
|
+
siteId: SITE_ID,
|
|
1651
|
+
slug: "get-a-quote",
|
|
1652
|
+
sessionId: SESSION_ID,
|
|
1653
|
+
stepKey: "thanks",
|
|
1654
|
+
values: {},
|
|
1655
|
+
},
|
|
1656
|
+
sourceMeta,
|
|
1657
|
+
);
|
|
1658
|
+
|
|
1659
|
+
expect(result).toMatchObject({
|
|
1660
|
+
ok: true,
|
|
1661
|
+
completed: true,
|
|
1662
|
+
leadId: "lead_00000000-0000-7000-8000-0000000000f2",
|
|
1663
|
+
});
|
|
1664
|
+
expect(prisma.waitlistScore.upsert).not.toHaveBeenCalled();
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it("a snapshot without `experiment` never touches waitlistExperiment/waitlistScore and yields a payload byte-identical to today (AC4 regression floor)", async () => {
|
|
1668
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1669
|
+
id: SESSION_ID,
|
|
1670
|
+
siteId: SITE_ID,
|
|
1671
|
+
flowId: DEFINITION_ID,
|
|
1672
|
+
flowSlug: "get-a-quote",
|
|
1673
|
+
configSnapshot: contactFirstFlow(),
|
|
1674
|
+
state: {
|
|
1675
|
+
contact: { email: "lead3@example.com" },
|
|
1676
|
+
budget: { budget: "large" },
|
|
1677
|
+
},
|
|
1678
|
+
email: "lead3@example.com",
|
|
1679
|
+
leadId: null,
|
|
1680
|
+
completedAt: null,
|
|
1681
|
+
});
|
|
1682
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1683
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1684
|
+
id: "lead_00000000-0000-7000-8000-0000000000f3",
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
const result = await submitStep(
|
|
1688
|
+
{
|
|
1689
|
+
siteId: SITE_ID,
|
|
1690
|
+
slug: "get-a-quote",
|
|
1691
|
+
sessionId: SESSION_ID,
|
|
1692
|
+
stepKey: "thanks",
|
|
1693
|
+
values: {},
|
|
1694
|
+
},
|
|
1695
|
+
sourceMeta,
|
|
1696
|
+
);
|
|
1697
|
+
|
|
1698
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1699
|
+
expect(prisma.waitlistExperiment.findUnique).not.toHaveBeenCalled();
|
|
1700
|
+
expect(prisma.waitlistScore.upsert).not.toHaveBeenCalled();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
it("a smuggled tier submitted on an earlier step never influences the persisted evaluation (declared-names-only extraction, defense in depth)", async () => {
|
|
1704
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1705
|
+
id: SESSION_ID,
|
|
1706
|
+
siteId: SITE_ID,
|
|
1707
|
+
flowId: DEFINITION_ID,
|
|
1708
|
+
flowSlug: "get-a-quote",
|
|
1709
|
+
configSnapshot: contactFirstFlowWithExperiment(),
|
|
1710
|
+
state: { contact: { email: "lead4@example.com" } },
|
|
1711
|
+
email: "lead4@example.com",
|
|
1712
|
+
leadId: null,
|
|
1713
|
+
completedAt: null,
|
|
1714
|
+
});
|
|
1715
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1716
|
+
|
|
1717
|
+
// "budget" is a segmentation step declaring only the "budget"
|
|
1718
|
+
// question — a smuggled "tier" key in the same submission is dropped
|
|
1719
|
+
// by extractStepValues's declared-names-only extraction before it
|
|
1720
|
+
// ever reaches session.state, and therefore before it can reach the
|
|
1721
|
+
// assembled `payload` the scorer receives.
|
|
1722
|
+
const budgetResult = await submitStep(
|
|
1723
|
+
{
|
|
1724
|
+
siteId: SITE_ID,
|
|
1725
|
+
slug: "get-a-quote",
|
|
1726
|
+
sessionId: SESSION_ID,
|
|
1727
|
+
stepKey: "budget",
|
|
1728
|
+
values: { budget: "large", tier: "hot" },
|
|
1729
|
+
},
|
|
1730
|
+
sourceMeta,
|
|
1731
|
+
);
|
|
1732
|
+
expect(budgetResult).toMatchObject({ ok: true, completed: false });
|
|
1733
|
+
const lastUpdateCall = (prisma.flowSession.update as jest.Mock).mock.calls.at(-1)![0] as {
|
|
1734
|
+
data: { state: Record<string, Record<string, unknown>> };
|
|
1735
|
+
};
|
|
1736
|
+
expect(lastUpdateCall.data.state["budget"]).toEqual({ budget: "large" });
|
|
1737
|
+
expect(lastUpdateCall.data.state["budget"]).not.toHaveProperty("tier");
|
|
1738
|
+
|
|
1739
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1740
|
+
id: SESSION_ID,
|
|
1741
|
+
siteId: SITE_ID,
|
|
1742
|
+
flowId: DEFINITION_ID,
|
|
1743
|
+
flowSlug: "get-a-quote",
|
|
1744
|
+
configSnapshot: contactFirstFlowWithExperiment(),
|
|
1745
|
+
state: {
|
|
1746
|
+
contact: { email: "lead4@example.com" },
|
|
1747
|
+
budget: { budget: "large" },
|
|
1748
|
+
},
|
|
1749
|
+
email: "lead4@example.com",
|
|
1750
|
+
leadId: null,
|
|
1751
|
+
completedAt: null,
|
|
1752
|
+
});
|
|
1753
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1754
|
+
id: "lead_00000000-0000-7000-8000-0000000000f4",
|
|
1755
|
+
});
|
|
1756
|
+
(prisma.waitlistExperiment.findUnique as jest.Mock).mockResolvedValue({
|
|
1757
|
+
config: waitlistExperimentConfigForFixture(),
|
|
1758
|
+
});
|
|
1759
|
+
(prisma.waitlistScore.upsert as jest.Mock).mockResolvedValue({ id: "wlscore_2" });
|
|
1760
|
+
|
|
1761
|
+
const result = await submitStep(
|
|
1762
|
+
{
|
|
1763
|
+
siteId: SITE_ID,
|
|
1764
|
+
slug: "get-a-quote",
|
|
1765
|
+
sessionId: SESSION_ID,
|
|
1766
|
+
stepKey: "thanks",
|
|
1767
|
+
values: {},
|
|
1768
|
+
},
|
|
1769
|
+
sourceMeta,
|
|
1770
|
+
);
|
|
1771
|
+
|
|
1772
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
1773
|
+
expect(prisma.waitlistScore.upsert).toHaveBeenCalledTimes(1);
|
|
1774
|
+
const [call] = (prisma.waitlistScore.upsert as jest.Mock).mock.calls;
|
|
1775
|
+
const created = (call![0] as { create: { tier: string } }).create;
|
|
1776
|
+
// "large" alone (no smuggled tier) evaluates to "hot" (total 10 ≥ 5) —
|
|
1777
|
+
// the smuggled "tier":"hot" answer value made no difference because
|
|
1778
|
+
// there is no mechanism that reads it: it is neither a declared field
|
|
1779
|
+
// (dropped before session.state) nor a question the scorer's
|
|
1780
|
+
// answersFromPayload maps to any answer.
|
|
1781
|
+
expect(created.tier).toBe("hot");
|
|
1782
|
+
});
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// ── #3959 D3/D4 — segment derivation ─────────────────────────────────────
|
|
1786
|
+
|
|
1787
|
+
describe("submitStep — segment derivation (#3959 D3)", () => {
|
|
1788
|
+
it("derives + persists the segment in the SAME write as state, on landing a segmentation step declaring a band map", async () => {
|
|
1789
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1790
|
+
id: SESSION_ID,
|
|
1791
|
+
siteId: SITE_ID,
|
|
1792
|
+
flowId: DEFINITION_ID,
|
|
1793
|
+
flowSlug: "get-a-quote",
|
|
1794
|
+
configSnapshot: contactFirstFlowWithSegments(),
|
|
1795
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1796
|
+
email: "lead@example.com",
|
|
1797
|
+
segment: null,
|
|
1798
|
+
leadId: null,
|
|
1799
|
+
completedAt: null,
|
|
1800
|
+
});
|
|
1801
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1802
|
+
|
|
1803
|
+
const result = await submitStep(
|
|
1804
|
+
{
|
|
1805
|
+
siteId: SITE_ID,
|
|
1806
|
+
slug: "get-a-quote",
|
|
1807
|
+
sessionId: SESSION_ID,
|
|
1808
|
+
stepKey: "budget",
|
|
1809
|
+
values: { budget: "small" },
|
|
1810
|
+
},
|
|
1811
|
+
sourceMeta,
|
|
1812
|
+
);
|
|
1813
|
+
|
|
1814
|
+
expect(result).toMatchObject({
|
|
1815
|
+
ok: true,
|
|
1816
|
+
completed: false,
|
|
1817
|
+
segment: "starter",
|
|
1818
|
+
});
|
|
1819
|
+
// Exactly one write — state + segment persisted together, not two
|
|
1820
|
+
// separate updates.
|
|
1821
|
+
expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
|
|
1822
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith({
|
|
1823
|
+
where: { id: SESSION_ID },
|
|
1824
|
+
data: {
|
|
1825
|
+
state: {
|
|
1826
|
+
contact: { email: "lead@example.com" },
|
|
1827
|
+
budget: { budget: "small" },
|
|
1828
|
+
},
|
|
1829
|
+
flags: {},
|
|
1830
|
+
segment: "starter",
|
|
1831
|
+
},
|
|
1832
|
+
});
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
it("an abandoned session (segmentation landed, no trailing step submitted) carries the segment with completedAt still null — only the per-step write ran", async () => {
|
|
1836
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1837
|
+
id: SESSION_ID,
|
|
1838
|
+
siteId: SITE_ID,
|
|
1839
|
+
flowId: DEFINITION_ID,
|
|
1840
|
+
flowSlug: "get-a-quote",
|
|
1841
|
+
configSnapshot: contactFirstFlowWithSegments(),
|
|
1842
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1843
|
+
email: "lead@example.com",
|
|
1844
|
+
segment: null,
|
|
1845
|
+
leadId: null,
|
|
1846
|
+
completedAt: null,
|
|
1847
|
+
});
|
|
1848
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1849
|
+
|
|
1850
|
+
const result = await submitStep(
|
|
1851
|
+
{
|
|
1852
|
+
siteId: SITE_ID,
|
|
1853
|
+
slug: "get-a-quote",
|
|
1854
|
+
sessionId: SESSION_ID,
|
|
1855
|
+
stepKey: "budget",
|
|
1856
|
+
values: { budget: "large" },
|
|
1857
|
+
},
|
|
1858
|
+
sourceMeta,
|
|
1859
|
+
);
|
|
1860
|
+
|
|
1861
|
+
expect(result).toMatchObject({ ok: true, segment: "enterprise" });
|
|
1862
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1863
|
+
// Exactly one write — the per-step persist; no completion-stamping
|
|
1864
|
+
// second write ran (the session never completed).
|
|
1865
|
+
expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
it("no band map on the segmentation step derives nothing and submits unchanged (existing #3956 behavior)", async () => {
|
|
1869
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1870
|
+
id: SESSION_ID,
|
|
1871
|
+
siteId: SITE_ID,
|
|
1872
|
+
flowId: DEFINITION_ID,
|
|
1873
|
+
flowSlug: "get-a-quote",
|
|
1874
|
+
configSnapshot: contactFirstFlow(), // no segments block
|
|
1875
|
+
state: { contact: { email: "lead@example.com" } },
|
|
1876
|
+
email: "lead@example.com",
|
|
1877
|
+
segment: null,
|
|
1878
|
+
leadId: null,
|
|
1879
|
+
completedAt: null,
|
|
1880
|
+
});
|
|
1881
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1882
|
+
|
|
1883
|
+
const result = await submitStep(
|
|
1884
|
+
{
|
|
1885
|
+
siteId: SITE_ID,
|
|
1886
|
+
slug: "get-a-quote",
|
|
1887
|
+
sessionId: SESSION_ID,
|
|
1888
|
+
stepKey: "budget",
|
|
1889
|
+
values: { budget: "small" },
|
|
1890
|
+
},
|
|
1891
|
+
sourceMeta,
|
|
1892
|
+
);
|
|
1893
|
+
|
|
1894
|
+
expect(result.ok && result.segment).toBeUndefined();
|
|
1895
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith({
|
|
1896
|
+
where: { id: SESSION_ID },
|
|
1897
|
+
data: {
|
|
1898
|
+
state: {
|
|
1899
|
+
contact: { email: "lead@example.com" },
|
|
1900
|
+
budget: { budget: "small" },
|
|
1901
|
+
},
|
|
1902
|
+
flags: {},
|
|
1903
|
+
},
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
describe("completion — payload.segment (#3959 D4)", () => {
|
|
1908
|
+
it("writes payload.segment LAST, from the request-local value; a smuggled `values.segment` never reaches state or payload", async () => {
|
|
1909
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1910
|
+
id: SESSION_ID,
|
|
1911
|
+
siteId: SITE_ID,
|
|
1912
|
+
flowId: DEFINITION_ID,
|
|
1913
|
+
flowSlug: "get-a-quote",
|
|
1914
|
+
configSnapshot: contactFirstFlowWithSegments(),
|
|
1915
|
+
state: {
|
|
1916
|
+
contact: { email: "complete@example.com" },
|
|
1917
|
+
budget: { budget: "large" },
|
|
1918
|
+
},
|
|
1919
|
+
email: "complete@example.com",
|
|
1920
|
+
segment: "enterprise",
|
|
1921
|
+
leadId: null,
|
|
1922
|
+
completedAt: null,
|
|
1923
|
+
});
|
|
1924
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1925
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1926
|
+
id: "lead_00000000-0000-7000-8000-0000000000e2",
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
const result = await submitStep(
|
|
1930
|
+
{
|
|
1931
|
+
siteId: SITE_ID,
|
|
1932
|
+
slug: "get-a-quote",
|
|
1933
|
+
sessionId: SESSION_ID,
|
|
1934
|
+
stepKey: "thanks",
|
|
1935
|
+
// "segment" is not a declared field/question on the "thanks"
|
|
1936
|
+
// message step — extractStepValues drops it unconditionally.
|
|
1937
|
+
values: { segment: "attacker-supplied" },
|
|
1938
|
+
},
|
|
1939
|
+
sourceMeta,
|
|
1940
|
+
);
|
|
1941
|
+
|
|
1942
|
+
expect(result).toMatchObject({
|
|
1943
|
+
ok: true,
|
|
1944
|
+
completed: true,
|
|
1945
|
+
segment: "enterprise",
|
|
1946
|
+
});
|
|
1947
|
+
expect(prisma.lead.create).toHaveBeenCalledWith(
|
|
1948
|
+
expect.objectContaining({
|
|
1949
|
+
data: expect.objectContaining({
|
|
1950
|
+
payload: expect.objectContaining({ segment: "enterprise" }),
|
|
1951
|
+
}),
|
|
1952
|
+
}),
|
|
1953
|
+
);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
it("the terminal-segmentation-step case — ONE submission landing segmentation AND completion carries the request-local segment, never the stale pre-update session.segment", async () => {
|
|
1957
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
1958
|
+
id: SESSION_ID,
|
|
1959
|
+
siteId: SITE_ID,
|
|
1960
|
+
flowId: DEFINITION_ID,
|
|
1961
|
+
flowSlug: "get-a-quote",
|
|
1962
|
+
configSnapshot: contactThenBudgetOnlyFlowWithSegments(),
|
|
1963
|
+
state: { contact: { email: "terminal@example.com" } },
|
|
1964
|
+
email: "terminal@example.com",
|
|
1965
|
+
// The row's segment is still null — this request must derive it
|
|
1966
|
+
// FRESH and use the local value, not this stale read.
|
|
1967
|
+
segment: null,
|
|
1968
|
+
leadId: null,
|
|
1969
|
+
completedAt: null,
|
|
1970
|
+
});
|
|
1971
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
1972
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1973
|
+
id: "lead_00000000-0000-7000-8000-0000000000e3",
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
const result = await submitStep(
|
|
1977
|
+
{
|
|
1978
|
+
siteId: SITE_ID,
|
|
1979
|
+
slug: "get-a-quote",
|
|
1980
|
+
sessionId: SESSION_ID,
|
|
1981
|
+
stepKey: "budget",
|
|
1982
|
+
values: { budget: "large" },
|
|
1983
|
+
},
|
|
1984
|
+
sourceMeta,
|
|
1985
|
+
);
|
|
1986
|
+
|
|
1987
|
+
expect(result).toMatchObject({
|
|
1988
|
+
ok: true,
|
|
1989
|
+
completed: true,
|
|
1990
|
+
segment: "enterprise",
|
|
1991
|
+
});
|
|
1992
|
+
expect(prisma.lead.create).toHaveBeenCalledWith(
|
|
1993
|
+
expect.objectContaining({
|
|
1994
|
+
data: expect.objectContaining({
|
|
1995
|
+
payload: expect.objectContaining({ segment: "enterprise" }),
|
|
1996
|
+
}),
|
|
1997
|
+
}),
|
|
1998
|
+
);
|
|
1999
|
+
// The per-step persist wrote segment too — the same write that
|
|
2000
|
+
// landed state.
|
|
2001
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
2002
|
+
expect.objectContaining({
|
|
2003
|
+
where: { id: SESSION_ID },
|
|
2004
|
+
data: expect.objectContaining({ segment: "enterprise" }),
|
|
2005
|
+
}),
|
|
2006
|
+
);
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
it("no band map anywhere in the flow — completion payload carries no segment key", async () => {
|
|
2010
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2011
|
+
id: SESSION_ID,
|
|
2012
|
+
siteId: SITE_ID,
|
|
2013
|
+
flowId: DEFINITION_ID,
|
|
2014
|
+
flowSlug: "get-a-quote",
|
|
2015
|
+
configSnapshot: contactFirstFlow(),
|
|
2016
|
+
state: {
|
|
2017
|
+
contact: { email: "plain@example.com" },
|
|
2018
|
+
budget: { budget: "large" },
|
|
2019
|
+
},
|
|
2020
|
+
email: "plain@example.com",
|
|
2021
|
+
segment: null,
|
|
2022
|
+
leadId: null,
|
|
2023
|
+
completedAt: null,
|
|
2024
|
+
});
|
|
2025
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2026
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
2027
|
+
id: "lead_00000000-0000-7000-8000-0000000000e4",
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
const result = await submitStep(
|
|
2031
|
+
{
|
|
2032
|
+
siteId: SITE_ID,
|
|
2033
|
+
slug: "get-a-quote",
|
|
2034
|
+
sessionId: SESSION_ID,
|
|
2035
|
+
stepKey: "thanks",
|
|
2036
|
+
values: {},
|
|
2037
|
+
},
|
|
2038
|
+
sourceMeta,
|
|
2039
|
+
);
|
|
2040
|
+
|
|
2041
|
+
expect(result.ok && result.segment).toBeUndefined();
|
|
2042
|
+
const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
|
|
2043
|
+
expect(call.data.payload).not.toHaveProperty("segment");
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
// ── #4661 D4 — the experiment binding composes the Lead payload ────────
|
|
2048
|
+
|
|
2049
|
+
describe("completion — payload.experimentKey/waitlistKey (#4661 D4, a Signup composes the Lead)", () => {
|
|
2050
|
+
it("writes experimentKey + waitlistKey LAST (after segment), from the snapshot's experiment block — the same lead row /v1/leads/submit persists", async () => {
|
|
2051
|
+
const flowWithExperiment = {
|
|
2052
|
+
...contactFirstFlowWithSegments(),
|
|
2053
|
+
experiment: { key: "my-experiment", waitlist: "enterprise_list" },
|
|
2054
|
+
};
|
|
2055
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2056
|
+
id: SESSION_ID,
|
|
2057
|
+
siteId: SITE_ID,
|
|
2058
|
+
flowId: DEFINITION_ID,
|
|
2059
|
+
flowSlug: "get-a-quote",
|
|
2060
|
+
configSnapshot: flowWithExperiment,
|
|
2061
|
+
state: {
|
|
2062
|
+
contact: { email: "signup@example.com" },
|
|
2063
|
+
budget: { budget: "large" },
|
|
2064
|
+
},
|
|
2065
|
+
email: "signup@example.com",
|
|
2066
|
+
segment: "enterprise",
|
|
2067
|
+
leadId: null,
|
|
2068
|
+
completedAt: null,
|
|
2069
|
+
});
|
|
2070
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2071
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
2072
|
+
id: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
const result = await submitStep(
|
|
2076
|
+
{
|
|
2077
|
+
siteId: SITE_ID,
|
|
2078
|
+
slug: "get-a-quote",
|
|
2079
|
+
sessionId: SESSION_ID,
|
|
2080
|
+
stepKey: "thanks",
|
|
2081
|
+
values: {},
|
|
2082
|
+
},
|
|
2083
|
+
sourceMeta,
|
|
2084
|
+
);
|
|
2085
|
+
|
|
2086
|
+
expect(result).toMatchObject({ ok: true, completed: true });
|
|
2087
|
+
// AC1 — no second signup store: this IS the same lead.create call
|
|
2088
|
+
// /v1/leads/submit's routeLead path uses, carrying every landed
|
|
2089
|
+
// answer plus experimentKey/waitlistKey inside the one payload.
|
|
2090
|
+
expect(prisma.lead.create).toHaveBeenCalledWith(
|
|
2091
|
+
expect.objectContaining({
|
|
2092
|
+
data: expect.objectContaining({
|
|
2093
|
+
email: "signup@example.com",
|
|
2094
|
+
payload: expect.objectContaining({
|
|
2095
|
+
email: "signup@example.com",
|
|
2096
|
+
budget: "large",
|
|
2097
|
+
segment: "enterprise",
|
|
2098
|
+
experimentKey: "my-experiment",
|
|
2099
|
+
waitlistKey: "enterprise_list",
|
|
2100
|
+
}),
|
|
2101
|
+
}),
|
|
2102
|
+
}),
|
|
2103
|
+
);
|
|
2104
|
+
// Key order — experimentKey/waitlistKey land AFTER segment (D4:
|
|
2105
|
+
// "after segment is written and as the FINAL assignments").
|
|
2106
|
+
const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
|
|
2107
|
+
const keys = Object.keys(call.data.payload);
|
|
2108
|
+
expect(keys.indexOf("experimentKey")).toBeGreaterThan(
|
|
2109
|
+
keys.indexOf("segment"),
|
|
2110
|
+
);
|
|
2111
|
+
expect(keys.indexOf("waitlistKey")).toBeGreaterThan(
|
|
2112
|
+
keys.indexOf("segment"),
|
|
2113
|
+
);
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it("a flow with no experiment block completes with a payload byte-identical to today (regression floor)", async () => {
|
|
2117
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2118
|
+
id: SESSION_ID,
|
|
2119
|
+
siteId: SITE_ID,
|
|
2120
|
+
flowId: DEFINITION_ID,
|
|
2121
|
+
flowSlug: "get-a-quote",
|
|
2122
|
+
configSnapshot: contactFirstFlow(), // no `experiment` block
|
|
2123
|
+
state: {
|
|
2124
|
+
contact: { email: "plain2@example.com", company: "Acme" },
|
|
2125
|
+
budget: { budget: "small" },
|
|
2126
|
+
},
|
|
2127
|
+
email: "plain2@example.com",
|
|
2128
|
+
segment: null,
|
|
2129
|
+
leadId: null,
|
|
2130
|
+
completedAt: null,
|
|
2131
|
+
});
|
|
2132
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2133
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
2134
|
+
id: "lead_00000000-0000-7000-8000-0000000000f2",
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
await submitStep(
|
|
2138
|
+
{
|
|
2139
|
+
siteId: SITE_ID,
|
|
2140
|
+
slug: "get-a-quote",
|
|
2141
|
+
sessionId: SESSION_ID,
|
|
2142
|
+
stepKey: "thanks",
|
|
2143
|
+
values: {},
|
|
2144
|
+
},
|
|
2145
|
+
sourceMeta,
|
|
2146
|
+
);
|
|
2147
|
+
|
|
2148
|
+
const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
|
|
2149
|
+
expect(call.data.payload).not.toHaveProperty("experimentKey");
|
|
2150
|
+
expect(call.data.payload).not.toHaveProperty("waitlistKey");
|
|
2151
|
+
expect(call.data.payload).toEqual({
|
|
2152
|
+
email: "plain2@example.com",
|
|
2153
|
+
company: "Acme",
|
|
2154
|
+
budget: "small",
|
|
2155
|
+
flowSlug: "get-a-quote",
|
|
2156
|
+
locale: "en",
|
|
2157
|
+
});
|
|
2158
|
+
});
|
|
2159
|
+
});
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// ── cleanupAbandonedSessions — retention/cleanup policy (#3974 AC3) ──────
|
|
2163
|
+
|
|
2164
|
+
describe("cleanupAbandonedSessions", () => {
|
|
2165
|
+
it("returns flows_not_configured when DATABASE_URL is unset", async () => {
|
|
2166
|
+
delete process.env.DATABASE_URL;
|
|
2167
|
+
|
|
2168
|
+
const result = await cleanupAbandonedSessions();
|
|
2169
|
+
|
|
2170
|
+
expect(result).toEqual({
|
|
2171
|
+
ok: false,
|
|
2172
|
+
error: { kind: "flows_not_configured", message: expect.any(String) },
|
|
2173
|
+
});
|
|
2174
|
+
expect(prisma.flowSession.deleteMany).not.toHaveBeenCalled();
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
it("deletes abandoned sessions past the retention window and reports the count", async () => {
|
|
2178
|
+
(prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
|
|
2179
|
+
count: 3,
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
const result = await cleanupAbandonedSessions();
|
|
2183
|
+
|
|
2184
|
+
expect(result).toEqual({ ok: true, deleted: 3 });
|
|
2185
|
+
expect(prisma.flowSession.deleteMany).toHaveBeenCalledTimes(1);
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
it("only ever targets abandoned rows — completedAt: null is structural in the where clause", async () => {
|
|
2189
|
+
(prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
|
|
2190
|
+
count: 0,
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
await cleanupAbandonedSessions();
|
|
2194
|
+
|
|
2195
|
+
expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith(
|
|
2196
|
+
expect.objectContaining({
|
|
2197
|
+
where: expect.objectContaining({ completedAt: null }),
|
|
2198
|
+
}),
|
|
2199
|
+
);
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
it("computes the cutoff from the default 90-day retention window", async () => {
|
|
2203
|
+
(prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
|
|
2204
|
+
count: 0,
|
|
2205
|
+
});
|
|
2206
|
+
const now = new Date("2026-08-11T00:00:00.000Z");
|
|
2207
|
+
|
|
2208
|
+
await cleanupAbandonedSessions({ now });
|
|
2209
|
+
|
|
2210
|
+
const expectedCutoff = new Date("2026-05-13T00:00:00.000Z"); // 90 days earlier
|
|
2211
|
+
expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith({
|
|
2212
|
+
where: {
|
|
2213
|
+
completedAt: null,
|
|
2214
|
+
updatedAt: { lte: expectedCutoff },
|
|
2215
|
+
},
|
|
2216
|
+
});
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it("honors a caller-supplied staleDays override", async () => {
|
|
2220
|
+
(prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
|
|
2221
|
+
count: 0,
|
|
2222
|
+
});
|
|
2223
|
+
const now = new Date("2026-08-11T00:00:00.000Z");
|
|
2224
|
+
|
|
2225
|
+
await cleanupAbandonedSessions({ now, staleDays: 30 });
|
|
2226
|
+
|
|
2227
|
+
const expectedCutoff = new Date("2026-07-12T00:00:00.000Z"); // 30 days earlier
|
|
2228
|
+
expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith({
|
|
2229
|
+
where: {
|
|
2230
|
+
completedAt: null,
|
|
2231
|
+
updatedAt: { lte: expectedCutoff },
|
|
2232
|
+
},
|
|
2233
|
+
});
|
|
2234
|
+
});
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
// ─── #4663 — flow extensions (D1–D6) ──────────────────────────────────────
|
|
2238
|
+
|
|
2239
|
+
// A minimal branching fixture exercising every extension in one flow:
|
|
2240
|
+
// contact (email) -> site (url, optional) -> site_score (aeo_score) ->
|
|
2241
|
+
// note (showIf not pre_launch) -> interests (multi) -> q7 (single) ->
|
|
2242
|
+
// q8 (showIf q7 = yes).
|
|
2243
|
+
function branchingFlow() {
|
|
2244
|
+
return {
|
|
2245
|
+
slug: "branching",
|
|
2246
|
+
defaultLocale: "en",
|
|
2247
|
+
flags: [{ name: "pre_launch", when: { unanswered: "url" } }],
|
|
2248
|
+
steps: [
|
|
2249
|
+
{
|
|
2250
|
+
key: "contact",
|
|
2251
|
+
type: "field" as const,
|
|
2252
|
+
copy: { title: en("Start") },
|
|
2253
|
+
fields: [
|
|
2254
|
+
{
|
|
2255
|
+
name: "email",
|
|
2256
|
+
inputType: "email" as const,
|
|
2257
|
+
required: true,
|
|
2258
|
+
mapsTo: "email" as const,
|
|
2259
|
+
copy: { label: en("Email") },
|
|
2260
|
+
},
|
|
2261
|
+
],
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
key: "site",
|
|
2265
|
+
type: "field" as const,
|
|
2266
|
+
copy: { title: en("Site") },
|
|
2267
|
+
fields: [
|
|
2268
|
+
{
|
|
2269
|
+
name: "url",
|
|
2270
|
+
inputType: "url" as const,
|
|
2271
|
+
required: false,
|
|
2272
|
+
copy: { label: en("URL") },
|
|
2273
|
+
},
|
|
2274
|
+
],
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
key: "site_score",
|
|
2278
|
+
type: "aeo_score" as const,
|
|
2279
|
+
copy: { title: en("Scoring...") },
|
|
2280
|
+
urlField: "url",
|
|
2281
|
+
},
|
|
2282
|
+
{
|
|
2283
|
+
key: "note",
|
|
2284
|
+
type: "field" as const,
|
|
2285
|
+
copy: { title: en("Tell us more") },
|
|
2286
|
+
showIf: { not: { flag: "pre_launch" } },
|
|
2287
|
+
fields: [
|
|
2288
|
+
{
|
|
2289
|
+
name: "note",
|
|
2290
|
+
inputType: "textarea" as const,
|
|
2291
|
+
required: true,
|
|
2292
|
+
minLength: 10,
|
|
2293
|
+
copy: { label: en("Note") },
|
|
2294
|
+
},
|
|
2295
|
+
],
|
|
2296
|
+
},
|
|
2297
|
+
{
|
|
2298
|
+
key: "interests",
|
|
2299
|
+
type: "segmentation" as const,
|
|
2300
|
+
copy: { title: en("Interests") },
|
|
2301
|
+
questions: [
|
|
2302
|
+
{
|
|
2303
|
+
name: "interests",
|
|
2304
|
+
multi: true as const,
|
|
2305
|
+
exclusiveValues: ["none"],
|
|
2306
|
+
options: [
|
|
2307
|
+
{ value: "a", label: en("A") },
|
|
2308
|
+
{ value: "b", label: en("B") },
|
|
2309
|
+
{ value: "none", label: en("None") },
|
|
2310
|
+
],
|
|
2311
|
+
},
|
|
2312
|
+
],
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
key: "q7",
|
|
2316
|
+
type: "segmentation" as const,
|
|
2317
|
+
copy: { title: en("Interested?") },
|
|
2318
|
+
questions: [
|
|
2319
|
+
{
|
|
2320
|
+
name: "q7",
|
|
2321
|
+
options: [
|
|
2322
|
+
{ value: "yes", label: en("Yes") },
|
|
2323
|
+
{ value: "no", label: en("No") },
|
|
2324
|
+
],
|
|
2325
|
+
},
|
|
2326
|
+
],
|
|
2327
|
+
},
|
|
2328
|
+
{
|
|
2329
|
+
key: "q8",
|
|
2330
|
+
type: "segmentation" as const,
|
|
2331
|
+
copy: { title: en("How much?") },
|
|
2332
|
+
showIf: { answer: "q7", eq: "yes" },
|
|
2333
|
+
questions: [
|
|
2334
|
+
{
|
|
2335
|
+
name: "q8",
|
|
2336
|
+
options: [
|
|
2337
|
+
{ value: "small", label: en("Small") },
|
|
2338
|
+
{ value: "large", label: en("Large") },
|
|
2339
|
+
],
|
|
2340
|
+
},
|
|
2341
|
+
],
|
|
2342
|
+
},
|
|
2343
|
+
],
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
beforeEach(() => {
|
|
2348
|
+
mockScoreUrl.mockReset();
|
|
2349
|
+
mockLoadExampleScore.mockReset();
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
describe("AC1 — branch served server-side (D1)", () => {
|
|
2353
|
+
it("serves q8 next when q7=yes, and 409s a submission for the now-hidden q8 after q7=no", async () => {
|
|
2354
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2355
|
+
id: SESSION_ID,
|
|
2356
|
+
siteId: SITE_ID,
|
|
2357
|
+
flowSlug: "branching",
|
|
2358
|
+
configSnapshot: branchingFlow(),
|
|
2359
|
+
state: {
|
|
2360
|
+
contact: { email: "a@example.com" },
|
|
2361
|
+
site: { url: "https://example.com" },
|
|
2362
|
+
site_score: {},
|
|
2363
|
+
note: { note: "0123456789" },
|
|
2364
|
+
interests: { interests: ["a"] },
|
|
2365
|
+
},
|
|
2366
|
+
flags: {},
|
|
2367
|
+
email: "a@example.com",
|
|
2368
|
+
segment: null,
|
|
2369
|
+
leadId: null,
|
|
2370
|
+
completedAt: null,
|
|
2371
|
+
});
|
|
2372
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2373
|
+
|
|
2374
|
+
const yesResult = await submitStep(
|
|
2375
|
+
{
|
|
2376
|
+
siteId: SITE_ID,
|
|
2377
|
+
slug: "branching",
|
|
2378
|
+
sessionId: SESSION_ID,
|
|
2379
|
+
stepKey: "q7",
|
|
2380
|
+
values: { q7: "yes" },
|
|
2381
|
+
},
|
|
2382
|
+
sourceMeta,
|
|
2383
|
+
);
|
|
2384
|
+
expect(yesResult).toMatchObject({
|
|
2385
|
+
ok: true,
|
|
2386
|
+
nextStepKey: "q8",
|
|
2387
|
+
completed: false,
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
// Now simulate the session having landed q7=no instead (q8 hidden).
|
|
2391
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2392
|
+
id: SESSION_ID,
|
|
2393
|
+
siteId: SITE_ID,
|
|
2394
|
+
flowSlug: "branching",
|
|
2395
|
+
configSnapshot: branchingFlow(),
|
|
2396
|
+
state: {
|
|
2397
|
+
contact: { email: "a@example.com" },
|
|
2398
|
+
site: { url: "https://example.com" },
|
|
2399
|
+
site_score: {},
|
|
2400
|
+
note: { note: "0123456789" },
|
|
2401
|
+
interests: { interests: ["a"] },
|
|
2402
|
+
q7: { q7: "no" },
|
|
2403
|
+
},
|
|
2404
|
+
flags: {},
|
|
2405
|
+
email: "a@example.com",
|
|
2406
|
+
segment: null,
|
|
2407
|
+
leadId: null,
|
|
2408
|
+
completedAt: null,
|
|
2409
|
+
});
|
|
2410
|
+
|
|
2411
|
+
const conflictResult = await submitStep(
|
|
2412
|
+
{
|
|
2413
|
+
siteId: SITE_ID,
|
|
2414
|
+
slug: "branching",
|
|
2415
|
+
sessionId: SESSION_ID,
|
|
2416
|
+
stepKey: "q8",
|
|
2417
|
+
values: { q8: "small" },
|
|
2418
|
+
},
|
|
2419
|
+
sourceMeta,
|
|
2420
|
+
);
|
|
2421
|
+
expect(conflictResult).toEqual({
|
|
2422
|
+
ok: false,
|
|
2423
|
+
error: { kind: "step_conflict", message: expect.any(String) },
|
|
2424
|
+
});
|
|
2425
|
+
});
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
describe("AC2 — flag-driven skip, persisted with the signup (D2/D3)", () => {
|
|
2429
|
+
it("derives pre_launch when url is unanswered", async () => {
|
|
2430
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2431
|
+
id: SESSION_ID,
|
|
2432
|
+
siteId: SITE_ID,
|
|
2433
|
+
flowSlug: "branching",
|
|
2434
|
+
configSnapshot: branchingFlow(),
|
|
2435
|
+
state: { contact: { email: "a@example.com" } },
|
|
2436
|
+
flags: {},
|
|
2437
|
+
email: "a@example.com",
|
|
2438
|
+
segment: null,
|
|
2439
|
+
leadId: null,
|
|
2440
|
+
completedAt: null,
|
|
2441
|
+
});
|
|
2442
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2443
|
+
|
|
2444
|
+
// Land "site" with NO value — derives pre_launch.
|
|
2445
|
+
const siteResult = await submitStep(
|
|
2446
|
+
{
|
|
2447
|
+
siteId: SITE_ID,
|
|
2448
|
+
slug: "branching",
|
|
2449
|
+
sessionId: SESSION_ID,
|
|
2450
|
+
stepKey: "site",
|
|
2451
|
+
values: {},
|
|
2452
|
+
},
|
|
2453
|
+
sourceMeta,
|
|
2454
|
+
);
|
|
2455
|
+
expect(siteResult).toMatchObject({ ok: true, nextStepKey: "site_score" });
|
|
2456
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
2457
|
+
expect.objectContaining({
|
|
2458
|
+
data: expect.objectContaining({ flags: { pre_launch: true } }),
|
|
2459
|
+
}),
|
|
2460
|
+
);
|
|
2461
|
+
});
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
describe("AC3 — open text persists verbatim; bounds enforced (D5)", () => {
|
|
2465
|
+
it("rejects a note shorter than minLength and a malformed url", async () => {
|
|
2466
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2467
|
+
id: SESSION_ID,
|
|
2468
|
+
siteId: SITE_ID,
|
|
2469
|
+
flowSlug: "branching",
|
|
2470
|
+
configSnapshot: branchingFlow(),
|
|
2471
|
+
state: {
|
|
2472
|
+
contact: { email: "a@example.com" },
|
|
2473
|
+
site: { url: "https://example.com" },
|
|
2474
|
+
site_score: {},
|
|
2475
|
+
},
|
|
2476
|
+
flags: {},
|
|
2477
|
+
email: "a@example.com",
|
|
2478
|
+
segment: null,
|
|
2479
|
+
leadId: null,
|
|
2480
|
+
completedAt: null,
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
const tooShort = await submitStep(
|
|
2484
|
+
{
|
|
2485
|
+
siteId: SITE_ID,
|
|
2486
|
+
slug: "branching",
|
|
2487
|
+
sessionId: SESSION_ID,
|
|
2488
|
+
stepKey: "note",
|
|
2489
|
+
values: { note: "short" },
|
|
2490
|
+
},
|
|
2491
|
+
sourceMeta,
|
|
2492
|
+
);
|
|
2493
|
+
expect(tooShort).toEqual({
|
|
2494
|
+
ok: false,
|
|
2495
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
const badUrl = await submitStep(
|
|
2499
|
+
{
|
|
2500
|
+
siteId: SITE_ID,
|
|
2501
|
+
slug: "branching",
|
|
2502
|
+
sessionId: SESSION_ID,
|
|
2503
|
+
stepKey: "site",
|
|
2504
|
+
values: { url: "not a url" },
|
|
2505
|
+
},
|
|
2506
|
+
sourceMeta,
|
|
2507
|
+
);
|
|
2508
|
+
expect(badUrl).toEqual({
|
|
2509
|
+
ok: false,
|
|
2510
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
// Security-review finding (#4663, M3): under the installed zod 4.4.3,
|
|
2514
|
+
// z.string().url() restricts neither hostname shape nor protocol, so a
|
|
2515
|
+
// non-http(s) scheme would have passed this check and reached
|
|
2516
|
+
// persistence (SSRF itself stays blocked downstream at assertUrlSafe —
|
|
2517
|
+
// this asserts the separate, narrower value-boundary gap is closed).
|
|
2518
|
+
const jsScheme = await submitStep(
|
|
2519
|
+
{
|
|
2520
|
+
siteId: SITE_ID,
|
|
2521
|
+
slug: "branching",
|
|
2522
|
+
sessionId: SESSION_ID,
|
|
2523
|
+
stepKey: "site",
|
|
2524
|
+
values: { url: "javascript:alert(1)" },
|
|
2525
|
+
},
|
|
2526
|
+
sourceMeta,
|
|
2527
|
+
);
|
|
2528
|
+
expect(jsScheme).toEqual({
|
|
2529
|
+
ok: false,
|
|
2530
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
2531
|
+
});
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
describe("AC5 — multi-select (D4)", () => {
|
|
2536
|
+
it("accepts a non-exclusive multi-select and rejects none+other combined", async () => {
|
|
2537
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2538
|
+
id: SESSION_ID,
|
|
2539
|
+
siteId: SITE_ID,
|
|
2540
|
+
flowSlug: "branching",
|
|
2541
|
+
configSnapshot: branchingFlow(),
|
|
2542
|
+
state: {
|
|
2543
|
+
contact: { email: "a@example.com" },
|
|
2544
|
+
site: { url: "https://example.com" },
|
|
2545
|
+
site_score: {},
|
|
2546
|
+
note: { note: "0123456789" },
|
|
2547
|
+
},
|
|
2548
|
+
flags: {},
|
|
2549
|
+
email: "a@example.com",
|
|
2550
|
+
segment: null,
|
|
2551
|
+
leadId: null,
|
|
2552
|
+
completedAt: null,
|
|
2553
|
+
});
|
|
2554
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2555
|
+
|
|
2556
|
+
const ok = await submitStep(
|
|
2557
|
+
{
|
|
2558
|
+
siteId: SITE_ID,
|
|
2559
|
+
slug: "branching",
|
|
2560
|
+
sessionId: SESSION_ID,
|
|
2561
|
+
stepKey: "interests",
|
|
2562
|
+
values: { interests: ["a", "b"] },
|
|
2563
|
+
},
|
|
2564
|
+
sourceMeta,
|
|
2565
|
+
);
|
|
2566
|
+
expect(ok).toMatchObject({ ok: true });
|
|
2567
|
+
|
|
2568
|
+
const rejected = await submitStep(
|
|
2569
|
+
{
|
|
2570
|
+
siteId: SITE_ID,
|
|
2571
|
+
slug: "branching",
|
|
2572
|
+
sessionId: SESSION_ID,
|
|
2573
|
+
stepKey: "interests",
|
|
2574
|
+
values: { interests: ["a", "none"] },
|
|
2575
|
+
},
|
|
2576
|
+
sourceMeta,
|
|
2577
|
+
);
|
|
2578
|
+
expect(rejected).toEqual({
|
|
2579
|
+
ok: false,
|
|
2580
|
+
error: { kind: "invalid_input", message: expect.any(String) },
|
|
2581
|
+
});
|
|
2582
|
+
});
|
|
2583
|
+
});
|
|
2584
|
+
|
|
2585
|
+
describe("AC6 — mid-flow AEO score (D6)", () => {
|
|
2586
|
+
it("calls scoreUrl with the landed url, persists + returns aeoScore", async () => {
|
|
2587
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2588
|
+
id: SESSION_ID,
|
|
2589
|
+
siteId: SITE_ID,
|
|
2590
|
+
flowSlug: "branching",
|
|
2591
|
+
configSnapshot: branchingFlow(),
|
|
2592
|
+
state: {
|
|
2593
|
+
contact: { email: "a@example.com" },
|
|
2594
|
+
site: { url: "https://example.com" },
|
|
2595
|
+
},
|
|
2596
|
+
flags: {},
|
|
2597
|
+
email: "a@example.com",
|
|
2598
|
+
segment: null,
|
|
2599
|
+
leadId: null,
|
|
2600
|
+
completedAt: null,
|
|
2601
|
+
});
|
|
2602
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2603
|
+
const readyScore = {
|
|
2604
|
+
status: "ready" as const,
|
|
2605
|
+
score: 42,
|
|
2606
|
+
dimensions: [],
|
|
2607
|
+
lowestTwo: [],
|
|
2608
|
+
meta: {
|
|
2609
|
+
auditedAt: "2026-01-01T00:00:00.000Z",
|
|
2610
|
+
pagesAudited: 1,
|
|
2611
|
+
partial: false,
|
|
2612
|
+
skipped: [],
|
|
2613
|
+
schemaVersion: "1",
|
|
2614
|
+
registryVersion: "1",
|
|
2615
|
+
},
|
|
2616
|
+
};
|
|
2617
|
+
mockScoreUrl.mockResolvedValue(readyScore);
|
|
2618
|
+
|
|
2619
|
+
const result = await submitStep(
|
|
2620
|
+
{
|
|
2621
|
+
siteId: SITE_ID,
|
|
2622
|
+
slug: "branching",
|
|
2623
|
+
sessionId: SESSION_ID,
|
|
2624
|
+
stepKey: "site_score",
|
|
2625
|
+
values: {},
|
|
2626
|
+
},
|
|
2627
|
+
sourceMeta,
|
|
2628
|
+
);
|
|
2629
|
+
|
|
2630
|
+
expect(mockScoreUrl).toHaveBeenCalledWith("https://example.com", {
|
|
2631
|
+
budgetMs: 6000,
|
|
2632
|
+
});
|
|
2633
|
+
expect(result).toMatchObject({ ok: true, aeoScore: readyScore });
|
|
2634
|
+
expect(prisma.flowSession.update).toHaveBeenCalledWith(
|
|
2635
|
+
expect.objectContaining({
|
|
2636
|
+
data: expect.objectContaining({ aeoScore: readyScore }),
|
|
2637
|
+
}),
|
|
2638
|
+
);
|
|
2639
|
+
});
|
|
2640
|
+
|
|
2641
|
+
it("calls loadExampleScore when the url was never landed", async () => {
|
|
2642
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2643
|
+
id: SESSION_ID,
|
|
2644
|
+
siteId: SITE_ID,
|
|
2645
|
+
flowSlug: "branching",
|
|
2646
|
+
configSnapshot: branchingFlow(),
|
|
2647
|
+
state: { contact: { email: "a@example.com" }, site: {} },
|
|
2648
|
+
flags: { pre_launch: true },
|
|
2649
|
+
email: "a@example.com",
|
|
2650
|
+
segment: null,
|
|
2651
|
+
leadId: null,
|
|
2652
|
+
completedAt: null,
|
|
2653
|
+
});
|
|
2654
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2655
|
+
const exampleScore = {
|
|
2656
|
+
status: "unavailable" as const,
|
|
2657
|
+
reason: "no_pages_fetched" as const,
|
|
2658
|
+
message: "n/a",
|
|
2659
|
+
};
|
|
2660
|
+
mockLoadExampleScore.mockReturnValue(exampleScore);
|
|
2661
|
+
|
|
2662
|
+
const result = await submitStep(
|
|
2663
|
+
{
|
|
2664
|
+
siteId: SITE_ID,
|
|
2665
|
+
slug: "branching",
|
|
2666
|
+
sessionId: SESSION_ID,
|
|
2667
|
+
stepKey: "site_score",
|
|
2668
|
+
values: {},
|
|
2669
|
+
},
|
|
2670
|
+
sourceMeta,
|
|
2671
|
+
);
|
|
2672
|
+
|
|
2673
|
+
expect(mockScoreUrl).not.toHaveBeenCalled();
|
|
2674
|
+
expect(mockLoadExampleScore).toHaveBeenCalledTimes(1);
|
|
2675
|
+
expect(result).toMatchObject({ ok: true, aeoScore: exampleScore });
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
it("a thrown scoreUrl error lands as unavailable with a GENERIC message — never the raw exception text (code-review finding)", async () => {
|
|
2679
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2680
|
+
id: SESSION_ID,
|
|
2681
|
+
siteId: SITE_ID,
|
|
2682
|
+
flowSlug: "branching",
|
|
2683
|
+
configSnapshot: branchingFlow(),
|
|
2684
|
+
state: {
|
|
2685
|
+
contact: { email: "a@example.com" },
|
|
2686
|
+
site: { url: "https://example.com" },
|
|
2687
|
+
},
|
|
2688
|
+
flags: {},
|
|
2689
|
+
email: "a@example.com",
|
|
2690
|
+
segment: null,
|
|
2691
|
+
leadId: null,
|
|
2692
|
+
completedAt: null,
|
|
2693
|
+
});
|
|
2694
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2695
|
+
mockScoreUrl.mockRejectedValue(
|
|
2696
|
+
new Error("connect ECONNREFUSED 10.0.0.5:443 (internal DNS detail)"),
|
|
2697
|
+
);
|
|
2698
|
+
|
|
2699
|
+
const result = await submitStep(
|
|
2700
|
+
{
|
|
2701
|
+
siteId: SITE_ID,
|
|
2702
|
+
slug: "branching",
|
|
2703
|
+
sessionId: SESSION_ID,
|
|
2704
|
+
stepKey: "site_score",
|
|
2705
|
+
values: {},
|
|
2706
|
+
},
|
|
2707
|
+
sourceMeta,
|
|
2708
|
+
);
|
|
2709
|
+
|
|
2710
|
+
expect(result.ok && result.aeoScore).toEqual({
|
|
2711
|
+
status: "unavailable",
|
|
2712
|
+
reason: "crawl_failed",
|
|
2713
|
+
message: "Could not score this URL.",
|
|
2714
|
+
});
|
|
2715
|
+
const responseText = JSON.stringify(result);
|
|
2716
|
+
expect(responseText).not.toContain("ECONNREFUSED");
|
|
2717
|
+
expect(responseText).not.toContain("10.0.0.5");
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
// Security-review finding (#4663, M1): scoreUrl RETURNS (never throws) on
|
|
2721
|
+
// an SSRF-guard rejection, so the "never forward a raw message" fix above
|
|
2722
|
+
// — which only covers the catch/throw path — never fired for it. The
|
|
2723
|
+
// returned message ("Host H resolves to a blocked address: A") disclosed
|
|
2724
|
+
// a resolved private IP and was persisted into the lead record verbatim.
|
|
2725
|
+
it("an ssrf_blocked scoreUrl RESULT (not a throw) lands with a GENERIC message — never the resolved-address disclosure", async () => {
|
|
2726
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2727
|
+
id: SESSION_ID,
|
|
2728
|
+
siteId: SITE_ID,
|
|
2729
|
+
flowSlug: "branching",
|
|
2730
|
+
configSnapshot: branchingFlow(),
|
|
2731
|
+
state: {
|
|
2732
|
+
contact: { email: "a@example.com" },
|
|
2733
|
+
site: { url: "https://internal.example.com" },
|
|
2734
|
+
},
|
|
2735
|
+
flags: {},
|
|
2736
|
+
email: "a@example.com",
|
|
2737
|
+
segment: null,
|
|
2738
|
+
leadId: null,
|
|
2739
|
+
completedAt: null,
|
|
2740
|
+
});
|
|
2741
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2742
|
+
mockScoreUrl.mockResolvedValue({
|
|
2743
|
+
status: "unavailable",
|
|
2744
|
+
reason: "ssrf_blocked",
|
|
2745
|
+
message:
|
|
2746
|
+
"Host internal.example.com resolves to a blocked address: 169.254.169.254",
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2749
|
+
const result = await submitStep(
|
|
2750
|
+
{
|
|
2751
|
+
siteId: SITE_ID,
|
|
2752
|
+
slug: "branching",
|
|
2753
|
+
sessionId: SESSION_ID,
|
|
2754
|
+
stepKey: "site_score",
|
|
2755
|
+
values: {},
|
|
2756
|
+
},
|
|
2757
|
+
sourceMeta,
|
|
2758
|
+
);
|
|
2759
|
+
|
|
2760
|
+
expect(result.ok && result.aeoScore).toEqual({
|
|
2761
|
+
status: "unavailable",
|
|
2762
|
+
reason: "ssrf_blocked",
|
|
2763
|
+
message: "This URL cannot be scored.",
|
|
2764
|
+
});
|
|
2765
|
+
const responseText = JSON.stringify(result);
|
|
2766
|
+
expect(responseText).not.toContain("169.254.169.254");
|
|
2767
|
+
expect(responseText).not.toContain("blocked address");
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
it("re-landing the url step with a changed value resets aeoScore to null and drops the score step's state", async () => {
|
|
2771
|
+
(prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
|
|
2772
|
+
id: SESSION_ID,
|
|
2773
|
+
siteId: SITE_ID,
|
|
2774
|
+
flowSlug: "branching",
|
|
2775
|
+
configSnapshot: branchingFlow(),
|
|
2776
|
+
state: {
|
|
2777
|
+
contact: { email: "a@example.com" },
|
|
2778
|
+
site: { url: "https://old.example.com" },
|
|
2779
|
+
site_score: {},
|
|
2780
|
+
},
|
|
2781
|
+
flags: {},
|
|
2782
|
+
email: "a@example.com",
|
|
2783
|
+
segment: null,
|
|
2784
|
+
leadId: null,
|
|
2785
|
+
completedAt: null,
|
|
2786
|
+
aeoScore: { status: "ready", score: 1 },
|
|
2787
|
+
});
|
|
2788
|
+
(prisma.flowSession.update as jest.Mock).mockResolvedValue({});
|
|
2789
|
+
|
|
2790
|
+
const result = await submitStep(
|
|
2791
|
+
{
|
|
2792
|
+
siteId: SITE_ID,
|
|
2793
|
+
slug: "branching",
|
|
2794
|
+
sessionId: SESSION_ID,
|
|
2795
|
+
stepKey: "site",
|
|
2796
|
+
values: { url: "https://new.example.com" },
|
|
2797
|
+
},
|
|
2798
|
+
sourceMeta,
|
|
2799
|
+
);
|
|
2800
|
+
|
|
2801
|
+
expect(result).toMatchObject({ ok: true, nextStepKey: "site_score" });
|
|
2802
|
+
const call = (prisma.flowSession.update as jest.Mock).mock.calls[0]![0] as {
|
|
2803
|
+
data: { state: Record<string, unknown>; aeoScore: unknown };
|
|
2804
|
+
};
|
|
2805
|
+
expect(Object.keys(call.data.state)).not.toContain("site_score");
|
|
2806
|
+
expect(call.data.aeoScore).toBe(Prisma.JsonNull);
|
|
2807
|
+
});
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2810
|
+
// ── AC8 — Regression floor: FLOW_PRESETS schema-parse stability ──────────
|
|
2811
|
+
//
|
|
2812
|
+
// Spec 4663-flow-extensions-spec.md AC8, clause 1. `database/flows/presets.ts`
|
|
2813
|
+
// is UNTOUCHED by #4663 (verified: `git diff origin/main..HEAD --
|
|
2814
|
+
// database/flows/presets.ts` is empty) — so the only way this diff could
|
|
2815
|
+
// regress an existing preset is if the EXTENDED flowConfigSchema silently
|
|
2816
|
+
// injects a new default/coerced field into a config that declares none of
|
|
2817
|
+
// the new grammar (showIf/flags/whenHidden/multi/exclusiveValues/url/
|
|
2818
|
+
// minLength/aeo_score). A JSON round-trip of the parsed result deep-equal
|
|
2819
|
+
// to a JSON round-trip of the raw preset proves no field was added, dropped,
|
|
2820
|
+
// or coerced by the schema extension — this IS "parse deep-equals the
|
|
2821
|
+
// pre-change parse" without needing a checked-out pre-change snapshot,
|
|
2822
|
+
// because the input is provably unchanged and Zod parse is pure.
|
|
2823
|
+
describe("AC8 — FLOW_PRESETS schema-parse stability (regression floor)", () => {
|
|
2824
|
+
it.each(FLOW_PRESETS.map((p) => [p.slug, p] as const))(
|
|
2825
|
+
"preset %s parses to a value deep-equal to its own raw JSON shape (no field added/dropped/coerced by the D1-D10 schema extension)",
|
|
2826
|
+
(_slug, preset) => {
|
|
2827
|
+
const parsed = flowConfigSchema.parse(preset);
|
|
2828
|
+
expect(JSON.parse(JSON.stringify(parsed))).toEqual(
|
|
2829
|
+
JSON.parse(JSON.stringify(preset)),
|
|
2830
|
+
);
|
|
2831
|
+
},
|
|
2832
|
+
);
|
|
2833
|
+
|
|
2834
|
+
it("covers all four declared presets — never a stale hardcoded list (DEF-007, mirrors the seedFlowPresets test above)", () => {
|
|
2835
|
+
expect(FLOW_PRESETS.map((p) => p.slug).sort()).toEqual(
|
|
2836
|
+
["form", "lead-magnet", "roadmap", "waitlist"].sort(),
|
|
2837
|
+
);
|
|
2838
|
+
});
|
|
2839
|
+
});
|
|
2840
|
+
});
|