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,1314 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file waitlist-dashboard.test.ts
|
|
4
|
+
*
|
|
5
|
+
* The B0 service layer (#4665 D2/D3/D4/D5, #4671). Mocked Prisma (a
|
|
6
|
+
* where-clause-honoring in-memory matcher, mirroring
|
|
7
|
+
* `routes/workspaces/__tests__/tenant-isolation.test.ts`'s discipline): the
|
|
8
|
+
* platform-operator predicate both ways (sentinel-org owner -> "all";
|
|
9
|
+
* sentinel-org admin, non-sentinel owner -> "site"); every query carries the
|
|
10
|
+
* scope; a foreign lead is `not_found`; D4 arithmetic on a fixture (raw /
|
|
11
|
+
* qualified / null cases / "(direct)" / "(other)" / multi counted per
|
|
12
|
+
* value); no write verb reachable.
|
|
13
|
+
*/
|
|
14
|
+
import { waitlistDashboardListSchema } from "@working-theory/validation";
|
|
15
|
+
|
|
16
|
+
import { MAX_AGGREGATION_ROWS } from "../answer-distributions";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getExperimentComparison,
|
|
20
|
+
getSignup,
|
|
21
|
+
isPlatformOperator,
|
|
22
|
+
listExperiments,
|
|
23
|
+
listSignups,
|
|
24
|
+
resolveWaitlistReadScope,
|
|
25
|
+
WAITLIST_DASHBOARD_ROLES,
|
|
26
|
+
} from "../waitlist-dashboard";
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
29
|
+
jest.mock("../../lib/prisma.js", () => ({
|
|
30
|
+
prisma: {
|
|
31
|
+
waitlistExperiment: { findMany: jest.fn(), findFirst: jest.fn() },
|
|
32
|
+
lead: { findMany: jest.fn(), findFirst: jest.fn() },
|
|
33
|
+
waitlistScore: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
|
|
34
|
+
flowSession: { findMany: jest.fn() },
|
|
35
|
+
flowCheckout: { findMany: jest.fn() },
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// #4861 — the register engine is wrapped, not replaced: the REAL `recommend`
|
|
40
|
+
// runs, through a `jest.fn` so the per-call count is observable (spec §5.1
|
|
41
|
+
// AC-1). Every existing test's behaviour is byte-identical.
|
|
42
|
+
jest.mock("@working-theory/validation", () => {
|
|
43
|
+
const actual = jest.requireActual("@working-theory/validation");
|
|
44
|
+
return { ...actual, recommend: jest.fn(actual.recommend) };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
import { logger } from "@working-theory/observability";
|
|
48
|
+
|
|
49
|
+
import { prisma } from "../../lib/prisma.js";
|
|
50
|
+
import type { TenantContext } from "../tenant-context";
|
|
51
|
+
|
|
52
|
+
// ── Fixture ids ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const ORG_SENTINEL = "org_00000000-0000-7000-8000-000000000001";
|
|
55
|
+
const WS_A = "wksp_00000000-0000-7000-8000-000000000001"; // sentinel org's own site
|
|
56
|
+
// A genuinely non-sentinel org id — a real (non-zero-timestamp) v7 uuid, so
|
|
57
|
+
// `isSentinel` correctly reads false for it (unlike the all-zero-timestamp
|
|
58
|
+
// sentinel range `00000000-0000-7000-8000-…`).
|
|
59
|
+
const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
|
|
60
|
+
const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
|
|
61
|
+
|
|
62
|
+
const EXP_A = "wlexp_00000000-0000-7000-8000-0000000000aa"; // qualifying, site A
|
|
63
|
+
const EXP_B = "wlexp_00000000-0000-7000-8000-0000000000bb"; // lead-capture, site B
|
|
64
|
+
|
|
65
|
+
// ── Config fixtures (waitlistExperimentConfigSchema-shaped) ──────────────────
|
|
66
|
+
|
|
67
|
+
const en = (s: string) => ({ en: s });
|
|
68
|
+
|
|
69
|
+
const CONFIG_A = {
|
|
70
|
+
key: "founder-diagnostic",
|
|
71
|
+
mode: "qualifying" as const,
|
|
72
|
+
status: "live" as const,
|
|
73
|
+
waitlists: [
|
|
74
|
+
{ key: "early_bird", flowSlug: "founder-diagnostic-early" },
|
|
75
|
+
{ key: "general", flowSlug: "founder-diagnostic-general", entryPageKey: "gen-page" },
|
|
76
|
+
],
|
|
77
|
+
questionSet: {
|
|
78
|
+
version: 1 as const,
|
|
79
|
+
questions: [
|
|
80
|
+
{
|
|
81
|
+
id: "role",
|
|
82
|
+
prompt: en("Role?"),
|
|
83
|
+
answerType: "single" as const,
|
|
84
|
+
options: [
|
|
85
|
+
{ value: "founder", label: en("Founder") },
|
|
86
|
+
{ value: "other", label: en("Other") },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{ id: "notes", prompt: en("Notes?"), answerType: "text" as const, minLength: 1 },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
scoring: {
|
|
93
|
+
version: 1 as const,
|
|
94
|
+
points: [
|
|
95
|
+
{
|
|
96
|
+
name: "role_pt",
|
|
97
|
+
question: "role",
|
|
98
|
+
kind: "optionWeights" as const,
|
|
99
|
+
weights: { founder: 5, other: 0 },
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
scores: { total: { sum: ["role_pt"] } },
|
|
103
|
+
tiers: [
|
|
104
|
+
{ key: "hot", when: { score: "total", gte: 5 } },
|
|
105
|
+
{ key: "cold", when: { score: "total", gte: 0 } },
|
|
106
|
+
],
|
|
107
|
+
default: "cold",
|
|
108
|
+
serviceableExcludes: ["cold"],
|
|
109
|
+
},
|
|
110
|
+
tracking: {
|
|
111
|
+
version: 1 as const,
|
|
112
|
+
utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
|
|
113
|
+
waitlists: {
|
|
114
|
+
early_bird: { campaign: "eb-campaign", channels: ["organic"] },
|
|
115
|
+
general: { campaign: "gen-campaign", channels: ["organic"] },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
stripeConfigRef: null,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const CONFIG_B = {
|
|
122
|
+
key: "lead-capture-exp",
|
|
123
|
+
mode: "lead-capture" as const,
|
|
124
|
+
status: "draft" as const,
|
|
125
|
+
waitlists: [{ key: "general", flowSlug: "lead-capture-general" }],
|
|
126
|
+
questionSet: {
|
|
127
|
+
version: 1 as const,
|
|
128
|
+
questions: [{ id: "role", prompt: en("Role?"), answerType: "text" as const, minLength: 1 }],
|
|
129
|
+
},
|
|
130
|
+
tracking: {
|
|
131
|
+
version: 1 as const,
|
|
132
|
+
utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
|
|
133
|
+
waitlists: { general: { campaign: "lc-campaign", channels: ["organic"] } },
|
|
134
|
+
},
|
|
135
|
+
stripeConfigRef: null,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const EXPERIMENTS = [
|
|
139
|
+
{
|
|
140
|
+
id: EXP_A,
|
|
141
|
+
siteId: WS_A,
|
|
142
|
+
key: CONFIG_A.key,
|
|
143
|
+
mode: CONFIG_A.mode,
|
|
144
|
+
status: CONFIG_A.status,
|
|
145
|
+
config: CONFIG_A,
|
|
146
|
+
createdAt: new Date("2026-08-01T00:00:00.000Z"),
|
|
147
|
+
updatedAt: new Date("2026-08-01T00:00:00.000Z"),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: EXP_B,
|
|
151
|
+
siteId: WS_B,
|
|
152
|
+
key: CONFIG_B.key,
|
|
153
|
+
mode: CONFIG_B.mode,
|
|
154
|
+
status: CONFIG_B.status,
|
|
155
|
+
config: CONFIG_B,
|
|
156
|
+
createdAt: new Date("2026-08-02T00:00:00.000Z"),
|
|
157
|
+
updatedAt: new Date("2026-08-02T00:00:00.000Z"),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// ── Lead fixtures for EXP_A (four signups across two waitlists) ─────────────
|
|
162
|
+
|
|
163
|
+
const LEAD_1 = "lead_00000000-0000-7000-8000-000000000001"; // early_bird, founder, twitter -> hot
|
|
164
|
+
const LEAD_2 = "lead_00000000-0000-7000-8000-000000000002"; // early_bird, other, direct -> cold
|
|
165
|
+
const LEAD_3 = "lead_00000000-0000-7000-8000-000000000003"; // general, founder, direct, unscored
|
|
166
|
+
const LEAD_4 = "lead_00000000-0000-7000-8000-000000000004"; // general, weird-value (-> other), twitter -> cold
|
|
167
|
+
const LEAD_5 = "lead_00000000-0000-7000-8000-000000000005"; // EXP_B's one signup
|
|
168
|
+
|
|
169
|
+
const LEADS = [
|
|
170
|
+
{
|
|
171
|
+
id: LEAD_1,
|
|
172
|
+
siteId: WS_A,
|
|
173
|
+
email: "one@example.com",
|
|
174
|
+
source: "flow:founder-diagnostic-early",
|
|
175
|
+
payload: {
|
|
176
|
+
experimentKey: "founder-diagnostic",
|
|
177
|
+
waitlistKey: "early_bird",
|
|
178
|
+
role: "founder",
|
|
179
|
+
notes: "hi",
|
|
180
|
+
utm_source: "twitter",
|
|
181
|
+
},
|
|
182
|
+
createdAt: new Date("2026-08-10T00:00:00.000Z"),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: LEAD_2,
|
|
186
|
+
siteId: WS_A,
|
|
187
|
+
email: "two@example.com",
|
|
188
|
+
source: "flow:founder-diagnostic-early",
|
|
189
|
+
payload: {
|
|
190
|
+
experimentKey: "founder-diagnostic",
|
|
191
|
+
waitlistKey: "early_bird",
|
|
192
|
+
role: "other",
|
|
193
|
+
},
|
|
194
|
+
createdAt: new Date("2026-08-11T00:00:00.000Z"),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: LEAD_3,
|
|
198
|
+
siteId: WS_A,
|
|
199
|
+
email: "three@example.com",
|
|
200
|
+
source: "flow:founder-diagnostic-general",
|
|
201
|
+
payload: {
|
|
202
|
+
experimentKey: "founder-diagnostic",
|
|
203
|
+
waitlistKey: "general",
|
|
204
|
+
role: "founder",
|
|
205
|
+
},
|
|
206
|
+
createdAt: new Date("2026-08-12T00:00:00.000Z"),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: LEAD_4,
|
|
210
|
+
siteId: WS_A,
|
|
211
|
+
email: "four@example.com",
|
|
212
|
+
source: "flow:founder-diagnostic-general",
|
|
213
|
+
payload: {
|
|
214
|
+
experimentKey: "founder-diagnostic",
|
|
215
|
+
waitlistKey: "general",
|
|
216
|
+
role: "weird-value",
|
|
217
|
+
utm_source: "twitter",
|
|
218
|
+
},
|
|
219
|
+
createdAt: new Date("2026-08-13T00:00:00.000Z"),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: LEAD_5,
|
|
223
|
+
siteId: WS_B,
|
|
224
|
+
email: "five@example.com",
|
|
225
|
+
source: "flow:lead-capture-general",
|
|
226
|
+
payload: { experimentKey: "lead-capture-exp", waitlistKey: "general", role: "solo" },
|
|
227
|
+
createdAt: new Date("2026-08-14T00:00:00.000Z"),
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const SCORES = [
|
|
232
|
+
{
|
|
233
|
+
leadId: LEAD_1,
|
|
234
|
+
siteId: WS_A,
|
|
235
|
+
experimentKey: "founder-diagnostic",
|
|
236
|
+
tier: "hot",
|
|
237
|
+
score: 5,
|
|
238
|
+
categoryScores: { total: 5 },
|
|
239
|
+
ruleTrace: { fired: ["role_pt"] },
|
|
240
|
+
updatedAt: new Date("2026-08-10T01:00:00.000Z"),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
leadId: LEAD_2,
|
|
244
|
+
siteId: WS_A,
|
|
245
|
+
experimentKey: "founder-diagnostic",
|
|
246
|
+
tier: "cold",
|
|
247
|
+
score: 0,
|
|
248
|
+
categoryScores: { total: 0 },
|
|
249
|
+
ruleTrace: { fired: [] },
|
|
250
|
+
updatedAt: new Date("2026-08-11T01:00:00.000Z"),
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
leadId: LEAD_4,
|
|
254
|
+
siteId: WS_A,
|
|
255
|
+
experimentKey: "founder-diagnostic",
|
|
256
|
+
tier: "cold",
|
|
257
|
+
score: 0,
|
|
258
|
+
categoryScores: { total: 0 },
|
|
259
|
+
ruleTrace: { fired: [] },
|
|
260
|
+
updatedAt: new Date("2026-08-13T01:00:00.000Z"),
|
|
261
|
+
},
|
|
262
|
+
// LEAD_3 is intentionally unscored — A2 has not run for it yet.
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
// ── A generic, where-clause-honoring in-memory matcher ───────────────────────
|
|
266
|
+
|
|
267
|
+
type Row = Record<string, unknown>;
|
|
268
|
+
type Where = Record<string, unknown>;
|
|
269
|
+
|
|
270
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
271
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
272
|
+
return a === b;
|
|
273
|
+
}
|
|
274
|
+
function lessThan(a: unknown, b: unknown): boolean {
|
|
275
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() < b.getTime();
|
|
276
|
+
return (a as never) < (b as never);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function matchesWhere(row: Row, where: Where): boolean {
|
|
280
|
+
for (const [key, cond] of Object.entries(where)) {
|
|
281
|
+
if (key === "OR") {
|
|
282
|
+
if (!(cond as Where[]).some((w) => matchesWhere(row, w))) return false;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (key === "AND") {
|
|
286
|
+
if (!(cond as Where[]).every((w) => matchesWhere(row, w))) return false;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (key === "payload") {
|
|
290
|
+
const { path, equals } = cond as { path: string[]; equals: unknown };
|
|
291
|
+
const payload = row.payload as Record<string, unknown> | undefined;
|
|
292
|
+
const value = payload ? payload[path[0] as string] : undefined;
|
|
293
|
+
if (!valuesEqual(value, equals)) return false;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const rowValue = row[key];
|
|
297
|
+
if (cond !== null && typeof cond === "object" && !Array.isArray(cond) && !(cond instanceof Date)) {
|
|
298
|
+
const condObj = cond as Record<string, unknown>;
|
|
299
|
+
if ("in" in condObj) {
|
|
300
|
+
if (!(condObj.in as unknown[]).some((v) => valuesEqual(v, rowValue))) return false;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if ("lt" in condObj) {
|
|
304
|
+
if (!lessThan(rowValue, condObj.lt)) return false;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!valuesEqual(rowValue, cond)) return false;
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sortRows(rows: Row[], orderBy: unknown): Row[] {
|
|
314
|
+
if (!orderBy) return rows;
|
|
315
|
+
const orders = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
316
|
+
return [...rows].sort((a, b) => {
|
|
317
|
+
for (const ord of orders as Array<Record<string, "asc" | "desc">>) {
|
|
318
|
+
const [field, dir] = Object.entries(ord)[0] as [string, "asc" | "desc"];
|
|
319
|
+
const av = a[field];
|
|
320
|
+
const bv = b[field];
|
|
321
|
+
let cmp: number;
|
|
322
|
+
if (av instanceof Date && bv instanceof Date) cmp = av.getTime() - bv.getTime();
|
|
323
|
+
else if ((av as never) < (bv as never)) cmp = -1;
|
|
324
|
+
else if ((av as never) > (bv as never)) cmp = 1;
|
|
325
|
+
else cmp = 0;
|
|
326
|
+
if (dir === "desc") cmp = -cmp;
|
|
327
|
+
if (cmp !== 0) return cmp;
|
|
328
|
+
}
|
|
329
|
+
return 0;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function installFixtureMocks(): void {
|
|
334
|
+
(prisma.waitlistExperiment.findMany as jest.Mock).mockImplementation(
|
|
335
|
+
({ where }: { where?: Where }) =>
|
|
336
|
+
Promise.resolve(sortRows(EXPERIMENTS.filter((e) => matchesWhere(e, where ?? {})), { createdAt: "desc" })),
|
|
337
|
+
);
|
|
338
|
+
(prisma.waitlistExperiment.findFirst as jest.Mock).mockImplementation(
|
|
339
|
+
({ where }: { where?: Where }) =>
|
|
340
|
+
Promise.resolve(EXPERIMENTS.find((e) => matchesWhere(e, where ?? {})) ?? null),
|
|
341
|
+
);
|
|
342
|
+
(prisma.lead.findMany as jest.Mock).mockImplementation(
|
|
343
|
+
({ where, orderBy, take }: { where?: Where; orderBy?: unknown; take?: number }) => {
|
|
344
|
+
let rows = LEADS.filter((l) => matchesWhere(l, where ?? {}));
|
|
345
|
+
rows = sortRows(rows, orderBy);
|
|
346
|
+
if (typeof take === "number") rows = rows.slice(0, take);
|
|
347
|
+
return Promise.resolve(rows);
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
(prisma.lead.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
|
|
351
|
+
Promise.resolve(LEADS.find((l) => matchesWhere(l, where ?? {})) ?? null),
|
|
352
|
+
);
|
|
353
|
+
(prisma.waitlistScore.findMany as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
|
|
354
|
+
Promise.resolve(SCORES.filter((s) => matchesWhere(s, where ?? {}))),
|
|
355
|
+
);
|
|
356
|
+
(prisma.waitlistScore.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
|
|
357
|
+
Promise.resolve(SCORES.find((s) => matchesWhere(s, where ?? {})) ?? null),
|
|
358
|
+
);
|
|
359
|
+
(prisma.waitlistScore.findUnique as jest.Mock).mockImplementation(
|
|
360
|
+
({ where }: { where: { leadId: string } }) =>
|
|
361
|
+
Promise.resolve(SCORES.find((s) => s.leadId === where.leadId) ?? null),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function tenant(overrides: Partial<TenantContext>): TenantContext {
|
|
366
|
+
return {
|
|
367
|
+
userId: "user_00000000-0000-7000-8000-000000000001" as TenantContext["userId"],
|
|
368
|
+
organizationId: ORG_SENTINEL as TenantContext["organizationId"],
|
|
369
|
+
workspaceId: WS_A as TenantContext["workspaceId"],
|
|
370
|
+
role: "owner",
|
|
371
|
+
...overrides,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
beforeEach(() => {
|
|
376
|
+
jest.clearAllMocks();
|
|
377
|
+
installFixtureMocks();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ── AC5 — the platform-operator predicate, fail-closed on AILK_OPERATOR_EMAIL ──
|
|
381
|
+
//
|
|
382
|
+
// Sam-disposed 2026-09-02 (PR #4687 security review, High finding): the
|
|
383
|
+
// sentinel-owner predicate alone is no longer sufficient — `kind:"all"`
|
|
384
|
+
// requires an explicit deployment pin. `bindOperatorMembership`
|
|
385
|
+
// (`tenant-context.ts`) TOFU-binds this Membership to whichever caller wins
|
|
386
|
+
// a first-login race when the pin is unset; this surface turns that
|
|
387
|
+
// membership into cross-tenant signup-PII read access, so a deployment must
|
|
388
|
+
// opt in explicitly.
|
|
389
|
+
|
|
390
|
+
const ORIGINAL_ENV = process.env;
|
|
391
|
+
|
|
392
|
+
describe("isPlatformOperator / resolveWaitlistReadScope", () => {
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
process.env = { ...ORIGINAL_ENV };
|
|
395
|
+
delete process.env.AILK_OPERATOR_EMAIL;
|
|
396
|
+
});
|
|
397
|
+
afterAll(() => {
|
|
398
|
+
process.env = ORIGINAL_ENV;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("AILK_OPERATOR_EMAIL set + sentinel-org owner -> true, scope 'all'", () => {
|
|
402
|
+
process.env.AILK_OPERATOR_EMAIL = "op@example.com";
|
|
403
|
+
const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
|
|
404
|
+
expect(isPlatformOperator(t)).toBe(true);
|
|
405
|
+
expect(resolveWaitlistReadScope(t)).toEqual({ kind: "all" });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("AILK_OPERATOR_EMAIL UNSET + sentinel-org owner -> false, scope 'site' (fail closed)", () => {
|
|
409
|
+
// AILK_OPERATOR_EMAIL deliberately left unset by beforeEach.
|
|
410
|
+
const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
|
|
411
|
+
expect(isPlatformOperator(t)).toBe(false);
|
|
412
|
+
expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_A });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("a blank/whitespace-only AILK_OPERATOR_EMAIL is treated as unset (mirrors tenant-context.ts's own trim discipline)", () => {
|
|
416
|
+
process.env.AILK_OPERATOR_EMAIL = " ";
|
|
417
|
+
const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
|
|
418
|
+
expect(isPlatformOperator(t)).toBe(false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("AILK_OPERATOR_EMAIL set + sentinel-org admin -> false, scope 'site'", () => {
|
|
422
|
+
process.env.AILK_OPERATOR_EMAIL = "op@example.com";
|
|
423
|
+
const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "admin" });
|
|
424
|
+
expect(isPlatformOperator(t)).toBe(false);
|
|
425
|
+
expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_A });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("AILK_OPERATOR_EMAIL set + non-sentinel owner -> false, scope 'site'", () => {
|
|
429
|
+
process.env.AILK_OPERATOR_EMAIL = "op@example.com";
|
|
430
|
+
const t = tenant({ organizationId: ORG_B as TenantContext["organizationId"], workspaceId: WS_B as TenantContext["workspaceId"], role: "owner" });
|
|
431
|
+
expect(isPlatformOperator(t)).toBe(false);
|
|
432
|
+
expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_B });
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("false for a member regardless of pin or org (never a platform operator)", () => {
|
|
436
|
+
process.env.AILK_OPERATOR_EMAIL = "op@example.com";
|
|
437
|
+
const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "member" });
|
|
438
|
+
expect(isPlatformOperator(t)).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("the role floor allow-list is exactly owner|admin", () => {
|
|
442
|
+
expect(WAITLIST_DASHBOARD_ROLES.has("owner")).toBe(true);
|
|
443
|
+
expect(WAITLIST_DASHBOARD_ROLES.has("admin")).toBe(true);
|
|
444
|
+
expect(WAITLIST_DASHBOARD_ROLES.has("member")).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ── listExperiments — scope + rollup ─────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
describe("listExperiments", () => {
|
|
451
|
+
it("site scope sees only its own site's experiment", async () => {
|
|
452
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
453
|
+
expect(result.scope).toBe("site");
|
|
454
|
+
expect(result.experiments.map((e) => e.id)).toEqual([EXP_A]);
|
|
455
|
+
expect(result.rollup.sites).toBe(1);
|
|
456
|
+
expect(result.rollup.experiments).toEqual({ total: 1, live: 1, closed: 0, draft: 0 });
|
|
457
|
+
expect(result.rollup.raw).toBe(4);
|
|
458
|
+
expect(result.rollup.qualified).toBe(1);
|
|
459
|
+
expect(result.rollup.qualificationRate).toBeCloseTo(0.25);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("the other site's scope sees only its own experiment, unscored (null rollup)", async () => {
|
|
463
|
+
const result = await listExperiments({ kind: "site", siteId: WS_B });
|
|
464
|
+
expect(result.experiments.map((e) => e.id)).toEqual([EXP_B]);
|
|
465
|
+
expect(result.rollup.experiments).toEqual({ total: 1, live: 0, closed: 0, draft: 1 });
|
|
466
|
+
expect(result.rollup.raw).toBe(1);
|
|
467
|
+
expect(result.rollup.qualified).toBeNull();
|
|
468
|
+
expect(result.rollup.qualificationRate).toBeNull();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("platform-operator scope 'all' sees both sites' experiments; rollup.sites = 2", async () => {
|
|
472
|
+
const result = await listExperiments({ kind: "all" });
|
|
473
|
+
expect(result.scope).toBe("all");
|
|
474
|
+
expect(result.experiments.map((e) => e.id).sort()).toEqual([EXP_A, EXP_B].sort());
|
|
475
|
+
expect(result.rollup.sites).toBe(2);
|
|
476
|
+
expect(result.rollup.experiments).toEqual({ total: 2, live: 1, closed: 0, draft: 1 });
|
|
477
|
+
// Only EXP_A's qualified count is known — EXP_B is unscored (mode
|
|
478
|
+
// lead-capture) and contributes null, not zero.
|
|
479
|
+
expect(result.rollup.raw).toBe(5);
|
|
480
|
+
expect(result.rollup.qualified).toBe(1);
|
|
481
|
+
expect(result.rollup.qualificationRate).toBeCloseTo(0.2);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("returns an empty, zeroed rollup when scope admits no experiments", async () => {
|
|
485
|
+
const result = await listExperiments({ kind: "site", siteId: "wksp_00000000-0000-7000-8000-0000000000zz" });
|
|
486
|
+
expect(result.experiments).toEqual([]);
|
|
487
|
+
expect(result.rollup).toEqual({
|
|
488
|
+
experiments: { total: 0, live: 0, closed: 0, draft: 0 },
|
|
489
|
+
raw: 0,
|
|
490
|
+
qualified: null,
|
|
491
|
+
qualificationRate: null,
|
|
492
|
+
sites: 0,
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Medium finding, PR #4687 security review: a corrupted config on ONE
|
|
497
|
+
// experiment must not abort the platform operator's whole `all`-scope
|
|
498
|
+
// list (a cross-tenant availability coupling) — it is skipped, and every
|
|
499
|
+
// rollup count (total/status/sites/raw/qualified) excludes it.
|
|
500
|
+
it("skips an experiment with a corrupted config rather than throwing, under kind:'all'", async () => {
|
|
501
|
+
const corrupt = {
|
|
502
|
+
id: "wlexp_00000000-0000-7000-8000-0000000000cc",
|
|
503
|
+
siteId: WS_B,
|
|
504
|
+
key: "corrupt-exp",
|
|
505
|
+
mode: "qualifying",
|
|
506
|
+
status: "live",
|
|
507
|
+
config: { not: "a valid WaitlistExperimentConfig" },
|
|
508
|
+
createdAt: new Date("2026-08-03T00:00:00.000Z"),
|
|
509
|
+
updatedAt: new Date("2026-08-03T00:00:00.000Z"),
|
|
510
|
+
};
|
|
511
|
+
EXPERIMENTS.push(corrupt as never);
|
|
512
|
+
try {
|
|
513
|
+
const result = await listExperiments({ kind: "all" });
|
|
514
|
+
expect(result.experiments.map((e) => e.id).sort()).toEqual([EXP_A, EXP_B].sort());
|
|
515
|
+
// The corrupt row contributes to NEITHER count — same rollup as
|
|
516
|
+
// without it (the "both sites" test above).
|
|
517
|
+
expect(result.rollup.experiments).toEqual({ total: 2, live: 1, closed: 0, draft: 1 });
|
|
518
|
+
expect(result.rollup.sites).toBe(2);
|
|
519
|
+
} finally {
|
|
520
|
+
EXPERIMENTS.pop();
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("bounds its Lead/WaitlistScore aggregation reads with a `take` cap (Medium finding, PR #4687)", async () => {
|
|
525
|
+
await listExperiments({ kind: "all" });
|
|
526
|
+
const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
527
|
+
const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
528
|
+
expect(typeof leadCall.take).toBe("number");
|
|
529
|
+
expect(leadCall.take).toBeGreaterThan(0);
|
|
530
|
+
expect(typeof scoreCall.take).toBe("number");
|
|
531
|
+
expect(scoreCall.take).toBeGreaterThan(0);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// #5107 — without an explicit orderBy, Postgres does not guarantee which
|
|
535
|
+
// rows survive the `take: MAX_AGGREGATION_ROWS` cap above. Mirrors the
|
|
536
|
+
// sibling flowSession/flowCheckout reads in this same function (below),
|
|
537
|
+
// which already carry their own explicit orderBy alongside their take cap.
|
|
538
|
+
it("orders its Lead/WaitlistScore aggregation reads deterministically (#5107)", async () => {
|
|
539
|
+
await listExperiments({ kind: "all" });
|
|
540
|
+
const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
541
|
+
const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
542
|
+
expect(leadCall.orderBy).toEqual({ createdAt: "asc" });
|
|
543
|
+
expect(scoreCall.orderBy).toEqual({ createdAt: "asc" });
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ── getExperimentComparison — D4 arithmetic ──────────────────────────────────
|
|
548
|
+
|
|
549
|
+
describe("getExperimentComparison", () => {
|
|
550
|
+
it("computes per-waitlist and total metrics per D4, including (direct)/(other) and unscored handling", async () => {
|
|
551
|
+
const result = await getExperimentComparison({ kind: "all" }, EXP_A);
|
|
552
|
+
expect(result.ok).toBe(true);
|
|
553
|
+
if (!result.ok) return;
|
|
554
|
+
|
|
555
|
+
expect(result.value.experiment.id).toBe(EXP_A);
|
|
556
|
+
expect(result.value.serviceableExcludes).toEqual(["cold"]);
|
|
557
|
+
|
|
558
|
+
const earlyBird = result.value.waitlists.find((w) => w.key === "early_bird");
|
|
559
|
+
expect(earlyBird).toMatchObject({ raw: 2, qualified: 1, qualificationRate: 0.5, campaign: "eb-campaign" });
|
|
560
|
+
expect(earlyBird?.byTier.sort((a, b) => a.tier.localeCompare(b.tier))).toEqual([
|
|
561
|
+
{ tier: "cold", count: 1 },
|
|
562
|
+
{ tier: "hot", count: 1 },
|
|
563
|
+
]);
|
|
564
|
+
const ebSources = new Map(earlyBird?.bySource.map((s) => [s.source, s]));
|
|
565
|
+
expect(ebSources.get("(direct)")).toEqual({ source: "(direct)", raw: 1, qualified: 0, qualificationRate: 0 });
|
|
566
|
+
expect(ebSources.get("twitter")).toEqual({ source: "twitter", raw: 1, qualified: 1, qualificationRate: 1 });
|
|
567
|
+
|
|
568
|
+
const general = result.value.waitlists.find((w) => w.key === "general");
|
|
569
|
+
// LEAD_3 is unscored -> byTier has no entry for it; qualified counts only
|
|
570
|
+
// scored, non-excluded leads (zero here — the only score is "cold").
|
|
571
|
+
expect(general).toMatchObject({ raw: 2, qualified: 0, qualificationRate: 0 });
|
|
572
|
+
|
|
573
|
+
const roleDist = general?.distributions.find((d) => d.questionId === "role");
|
|
574
|
+
const roleOptions = new Map(roleDist?.options.map((o) => [o.value, o]));
|
|
575
|
+
expect(roleDist?.answered).toBe(2);
|
|
576
|
+
expect(roleOptions.get("founder")).toEqual({ value: "founder", count: 1, share: 0.5 });
|
|
577
|
+
// LEAD_4's "weird-value" is not a declared option -> the "(other)" bucket.
|
|
578
|
+
expect(roleOptions.get("(other)")).toEqual({ value: "(other)", count: 1, share: 0.5 });
|
|
579
|
+
|
|
580
|
+
// Total row aggregates over ALL of the experiment's waitlists (D4).
|
|
581
|
+
expect(result.value.total).toMatchObject({ key: "total", raw: 4, qualified: 1, qualificationRate: 0.25 });
|
|
582
|
+
const totalRoleDist = result.value.total.distributions.find((d) => d.questionId === "role");
|
|
583
|
+
expect(totalRoleDist?.answered).toBe(4);
|
|
584
|
+
const totalRoleOptions = new Map(totalRoleDist?.options.map((o) => [o.value, o]));
|
|
585
|
+
expect(totalRoleOptions.get("founder")?.count).toBe(2);
|
|
586
|
+
expect(totalRoleOptions.get("other")?.count).toBe(1);
|
|
587
|
+
expect(totalRoleOptions.get("(other)")?.count).toBe(1);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("qualified: null and serviceableExcludes: [] for a lead-capture (no scoring) experiment", async () => {
|
|
591
|
+
const result = await getExperimentComparison({ kind: "all" }, EXP_B);
|
|
592
|
+
expect(result.ok).toBe(true);
|
|
593
|
+
if (!result.ok) return;
|
|
594
|
+
expect(result.value.serviceableExcludes).toEqual([]);
|
|
595
|
+
expect(result.value.total.qualified).toBeNull();
|
|
596
|
+
expect(result.value.total.qualificationRate).toBeNull();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("a foreign experiment id under a site scope is not_found", async () => {
|
|
600
|
+
const result = await getExperimentComparison({ kind: "site", siteId: WS_B }, EXP_A);
|
|
601
|
+
expect(result.ok).toBe(false);
|
|
602
|
+
if (result.ok) return;
|
|
603
|
+
expect(result.error.kind).toBe("not_found");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("a missing experiment id under scope 'all' is not_found", async () => {
|
|
607
|
+
const result = await getExperimentComparison({ kind: "all" }, "wlexp_00000000-0000-7000-8000-00000000dead");
|
|
608
|
+
expect(result.ok).toBe(false);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("bounds its Lead/WaitlistScore aggregation reads with a `take` cap (Medium finding, PR #4687)", async () => {
|
|
612
|
+
await getExperimentComparison({ kind: "all" }, EXP_A);
|
|
613
|
+
const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
614
|
+
const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
615
|
+
expect(typeof leadCall.take).toBe("number");
|
|
616
|
+
expect(leadCall.take).toBeGreaterThan(0);
|
|
617
|
+
expect(typeof scoreCall.take).toBe("number");
|
|
618
|
+
expect(scoreCall.take).toBeGreaterThan(0);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// #5107 — same fix as listExperiments's pair above: an explicit orderBy
|
|
622
|
+
// makes which rows survive the take cap deterministic.
|
|
623
|
+
it("orders its Lead/WaitlistScore aggregation reads deterministically (#5107)", async () => {
|
|
624
|
+
await getExperimentComparison({ kind: "all" }, EXP_A);
|
|
625
|
+
const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
626
|
+
const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
|
|
627
|
+
expect(leadCall.orderBy).toEqual({ createdAt: "asc" });
|
|
628
|
+
expect(scoreCall.orderBy).toEqual({ createdAt: "asc" });
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ── getSignup — drill-down ────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
describe("getSignup", () => {
|
|
635
|
+
it("returns the scored signup's answers, tier, score, categoryScores, ruleTrace", async () => {
|
|
636
|
+
const result = await getSignup({ kind: "all" }, EXP_A, LEAD_1);
|
|
637
|
+
expect(result.ok).toBe(true);
|
|
638
|
+
if (!result.ok) return;
|
|
639
|
+
expect(result.value.waitlistKey).toBe("early_bird");
|
|
640
|
+
expect(result.value.answers.find((a) => a.questionId === "role")?.value).toBe("founder");
|
|
641
|
+
expect(result.value.score).toEqual({
|
|
642
|
+
tier: "hot",
|
|
643
|
+
score: 5,
|
|
644
|
+
categoryScores: { total: 5 },
|
|
645
|
+
ruleTrace: { fired: ["role_pt"] },
|
|
646
|
+
updatedAt: "2026-08-10T01:00:00.000Z",
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("returns score: null for an unscored signup", async () => {
|
|
651
|
+
const result = await getSignup({ kind: "all" }, EXP_A, LEAD_3);
|
|
652
|
+
expect(result.ok).toBe(true);
|
|
653
|
+
if (!result.ok) return;
|
|
654
|
+
expect(result.value.score).toBeNull();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("a foreign lead under a site scope is not_found — no enumeration signal", async () => {
|
|
658
|
+
const result = await getSignup({ kind: "site", siteId: WS_B }, EXP_B, LEAD_1);
|
|
659
|
+
expect(result.ok).toBe(false);
|
|
660
|
+
if (result.ok) return;
|
|
661
|
+
expect(result.error.kind).toBe("not_found");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("a lead belonging to a DIFFERENT experiment on the same site is not_found (loaded through the experiment)", async () => {
|
|
665
|
+
// LEAD_1 exists on site A but under "founder-diagnostic", not under a
|
|
666
|
+
// second (hypothetical) experiment id on that same site.
|
|
667
|
+
const result = await getSignup({ kind: "all" }, EXP_B, LEAD_1);
|
|
668
|
+
expect(result.ok).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("scopes the WaitlistScore read by siteId + experimentKey in the WHERE clause, not a post-filter (Low finding, PR #4687)", async () => {
|
|
672
|
+
await getSignup({ kind: "all" }, EXP_A, LEAD_1);
|
|
673
|
+
const call = (prisma.waitlistScore.findFirst as jest.Mock).mock.calls[0]?.[0];
|
|
674
|
+
expect(call.where).toMatchObject({ leadId: LEAD_1, siteId: WS_A, experimentKey: "founder-diagnostic" });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// A lead missing `waitlistKey` is corrupt/anomalous data (server-owned,
|
|
678
|
+
// written alongside `experimentKey` at completion) — treated as not_found
|
|
679
|
+
// rather than emitting an out-of-contract empty string (Low finding, PR
|
|
680
|
+
// #4687 security review).
|
|
681
|
+
it("treats a signup missing waitlistKey as not_found rather than emitting waitlistKey: ''", async () => {
|
|
682
|
+
const corruptLead = {
|
|
683
|
+
id: "lead_00000000-0000-7000-8000-0000000000c1",
|
|
684
|
+
siteId: WS_A,
|
|
685
|
+
email: "corrupt@example.com",
|
|
686
|
+
source: "flow:founder-diagnostic-early",
|
|
687
|
+
payload: { experimentKey: "founder-diagnostic", role: "founder" }, // no waitlistKey
|
|
688
|
+
createdAt: new Date("2026-08-15T00:00:00.000Z"),
|
|
689
|
+
};
|
|
690
|
+
LEADS.push(corruptLead as never);
|
|
691
|
+
try {
|
|
692
|
+
const result = await getSignup({ kind: "all" }, EXP_A, corruptLead.id);
|
|
693
|
+
expect(result.ok).toBe(false);
|
|
694
|
+
if (result.ok) return;
|
|
695
|
+
expect(result.error.kind).toBe("not_found");
|
|
696
|
+
} finally {
|
|
697
|
+
LEADS.pop();
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// ── listSignups — pagination ──────────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
describe("listSignups", () => {
|
|
705
|
+
it("returns the first page newest-first with a nextCursor when more remain", async () => {
|
|
706
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 2 });
|
|
707
|
+
expect(result.ok).toBe(true);
|
|
708
|
+
if (!result.ok) return;
|
|
709
|
+
// createdAt desc: LEAD_4 (13th), LEAD_3 (12th) are the newest two.
|
|
710
|
+
expect(result.value.items.map((i) => i.leadId)).toEqual([LEAD_4, LEAD_3]);
|
|
711
|
+
expect(result.value.nextCursor).toBe(LEAD_3);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("the second page (via cursor) returns the remaining rows with nextCursor: null", async () => {
|
|
715
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 2, cursor: LEAD_3 });
|
|
716
|
+
expect(result.ok).toBe(true);
|
|
717
|
+
if (!result.ok) return;
|
|
718
|
+
expect(result.value.items.map((i) => i.leadId)).toEqual([LEAD_2, LEAD_1]);
|
|
719
|
+
expect(result.value.nextCursor).toBeNull();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("filters by ?waitlist=", async () => {
|
|
723
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50, waitlist: "general" });
|
|
724
|
+
expect(result.ok).toBe(true);
|
|
725
|
+
if (!result.ok) return;
|
|
726
|
+
expect(result.value.items.map((i) => i.leadId).sort()).toEqual([LEAD_3, LEAD_4].sort());
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("carries tier/score from the joined WaitlistScore row, null when absent", async () => {
|
|
730
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50 });
|
|
731
|
+
expect(result.ok).toBe(true);
|
|
732
|
+
if (!result.ok) return;
|
|
733
|
+
const byId = new Map(result.value.items.map((i) => [i.leadId, i]));
|
|
734
|
+
expect(byId.get(LEAD_1)).toMatchObject({ tier: "hot", score: 5 });
|
|
735
|
+
expect(byId.get(LEAD_3)).toMatchObject({ tier: null, score: null });
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("a foreign experiment id under a site scope is not_found", async () => {
|
|
739
|
+
const result = await listSignups({ kind: "site", siteId: WS_B }, EXP_A, { limit: 50 });
|
|
740
|
+
expect(result.ok).toBe(false);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Low finding, PR #4687 security review: a supplied-but-unresolvable
|
|
744
|
+
// cursor must 400 (invalid_cursor), not silently degrade to page 1.
|
|
745
|
+
it("a cursor that does not exist at all is invalid_cursor", async () => {
|
|
746
|
+
const result = await listSignups({ kind: "all" }, EXP_A, {
|
|
747
|
+
limit: 50,
|
|
748
|
+
cursor: "lead_00000000-0000-7000-8000-00000000dead",
|
|
749
|
+
});
|
|
750
|
+
expect(result.ok).toBe(false);
|
|
751
|
+
if (result.ok) return;
|
|
752
|
+
expect(result.error.kind).toBe("invalid_cursor");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("a cursor belonging to a DIFFERENT experiment on the same site is invalid_cursor", async () => {
|
|
756
|
+
// LEAD_5 exists on site B under "lead-capture-exp" — not under EXP_A's
|
|
757
|
+
// "founder-diagnostic", even though both experiments' scope is 'all'.
|
|
758
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50, cursor: LEAD_5 });
|
|
759
|
+
expect(result.ok).toBe(false);
|
|
760
|
+
if (result.ok) return;
|
|
761
|
+
expect(result.error.kind).toBe("invalid_cursor");
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("skips a signup missing waitlistKey rather than emitting waitlistKey: '' (Low finding, PR #4687)", async () => {
|
|
765
|
+
const corruptLead = {
|
|
766
|
+
id: "lead_00000000-0000-7000-8000-0000000000c2",
|
|
767
|
+
siteId: WS_A,
|
|
768
|
+
email: "corrupt2@example.com",
|
|
769
|
+
source: "flow:founder-diagnostic-early",
|
|
770
|
+
payload: { experimentKey: "founder-diagnostic", role: "founder" }, // no waitlistKey
|
|
771
|
+
createdAt: new Date("2026-08-16T00:00:00.000Z"),
|
|
772
|
+
};
|
|
773
|
+
LEADS.push(corruptLead as never);
|
|
774
|
+
try {
|
|
775
|
+
const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50 });
|
|
776
|
+
expect(result.ok).toBe(true);
|
|
777
|
+
if (!result.ok) return;
|
|
778
|
+
expect(result.value.items.some((i) => i.leadId === corruptLead.id)).toBe(false);
|
|
779
|
+
expect(result.value.items.every((i) => i.waitlistKey !== "")).toBe(true);
|
|
780
|
+
} finally {
|
|
781
|
+
LEADS.pop();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// ── Read-only — no write verb is ever reachable (spec §5.2 AC4) ─────────────
|
|
787
|
+
|
|
788
|
+
describe("read-only discipline", () => {
|
|
789
|
+
it("the prisma mock declares no create/update/upsert/delete verb — an accidental call throws, not silently succeeds", () => {
|
|
790
|
+
for (const model of ["waitlistExperiment", "lead", "waitlistScore"] as const) {
|
|
791
|
+
for (const verb of ["create", "update", "upsert", "delete", "deleteMany", "updateMany", "createMany"] as const) {
|
|
792
|
+
expect((prisma[model] as Record<string, unknown>)[verb]).toBeUndefined();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// ── #4839 — per-offer funnel counts for a recommending experiment ───────────
|
|
799
|
+
|
|
800
|
+
const EXP_R = "wlexp_00000000-0000-7000-8000-0000000000dd"; // recommending, site A
|
|
801
|
+
|
|
802
|
+
const CONFIG_R = {
|
|
803
|
+
key: "ailk-reco",
|
|
804
|
+
mode: "recommending" as const,
|
|
805
|
+
status: "live" as const,
|
|
806
|
+
waitlists: [{ key: "main", flowSlug: "ailk-reco" }],
|
|
807
|
+
questionSet: {
|
|
808
|
+
version: 1 as const,
|
|
809
|
+
questions: [
|
|
810
|
+
{
|
|
811
|
+
id: "q_team",
|
|
812
|
+
prompt: en("Team?"),
|
|
813
|
+
answerType: "single" as const,
|
|
814
|
+
options: [
|
|
815
|
+
{ value: "solo", label: en("Solo") },
|
|
816
|
+
{ value: "small_team", label: en("Small team") },
|
|
817
|
+
],
|
|
818
|
+
},
|
|
819
|
+
],
|
|
820
|
+
},
|
|
821
|
+
tracking: {
|
|
822
|
+
version: 1 as const,
|
|
823
|
+
utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
|
|
824
|
+
waitlists: { main: { campaign: "reco-campaign", channels: ["organic"] } },
|
|
825
|
+
},
|
|
826
|
+
stripeConfigRef: "stripe-main",
|
|
827
|
+
offers: [
|
|
828
|
+
{
|
|
829
|
+
key: "pro",
|
|
830
|
+
copy: { name: en("Pro") },
|
|
831
|
+
price: { kind: "exact" as const, amount: 249, period: "annual" as const },
|
|
832
|
+
rules: [{ when: { answer: "q_team", eq: "small_team" }, reason: en("Teams need seats.") }],
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
key: "starter",
|
|
836
|
+
copy: { name: en("Starter") },
|
|
837
|
+
price: { kind: "label" as const, label: en("Free") },
|
|
838
|
+
rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders start free.") }],
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const EXPERIMENT_R = {
|
|
844
|
+
id: EXP_R,
|
|
845
|
+
siteId: WS_A,
|
|
846
|
+
key: CONFIG_R.key,
|
|
847
|
+
mode: CONFIG_R.mode,
|
|
848
|
+
status: CONFIG_R.status,
|
|
849
|
+
config: CONFIG_R,
|
|
850
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
851
|
+
updatedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const SESSIONS_R = [
|
|
855
|
+
{ siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T00:00:00.000Z"), state: { team: { q_team: "small_team" } } },
|
|
856
|
+
{ siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T01:00:00.000Z"), state: { team: { q_team: "small_team" } } },
|
|
857
|
+
{ siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T02:00:00.000Z"), state: { team: { q_team: "solo" } } },
|
|
858
|
+
// An incomplete walk and a foreign-site session — never counted.
|
|
859
|
+
{ siteId: WS_A, flowSlug: "ailk-reco", completedAt: null, state: { team: { q_team: "small_team" } } },
|
|
860
|
+
{ siteId: WS_B, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T03:00:00.000Z"), state: { team: { q_team: "small_team" } } },
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
const LEAD_R = {
|
|
864
|
+
id: "lead_00000000-0000-7000-8000-0000000000dd",
|
|
865
|
+
siteId: WS_A,
|
|
866
|
+
email: "buyer@example.com",
|
|
867
|
+
source: "flow:ailk-reco",
|
|
868
|
+
payload: {
|
|
869
|
+
experimentKey: "ailk-reco",
|
|
870
|
+
waitlistKey: "main",
|
|
871
|
+
q_team: "small_team",
|
|
872
|
+
recommender: { choice: "save", fits: ["pro"] },
|
|
873
|
+
},
|
|
874
|
+
createdAt: new Date("2026-09-02T00:30:00.000Z"),
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const CHECKOUTS_R = [
|
|
878
|
+
{ siteId: WS_A, experimentKey: "ailk-reco", offerKey: "pro", status: "completed" },
|
|
879
|
+
{ siteId: WS_A, experimentKey: "ailk-reco", offerKey: "pro", status: "completed" },
|
|
880
|
+
{ siteId: WS_A, experimentKey: "ailk-reco", offerKey: "starter", status: "created" },
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
function installRecommendingFixtures(): void {
|
|
884
|
+
EXPERIMENTS.push(EXPERIMENT_R as never);
|
|
885
|
+
LEADS.push(LEAD_R as never);
|
|
886
|
+
(prisma.flowSession.findMany as jest.Mock).mockImplementation(
|
|
887
|
+
({ where, take }: { where?: Where; take?: number }) => {
|
|
888
|
+
let rows = SESSIONS_R.filter((r) => {
|
|
889
|
+
const { completedAt, ...rest } = (where ?? {}) as Where & { completedAt?: { not: null } };
|
|
890
|
+
if (completedAt && r.completedAt === null) return false;
|
|
891
|
+
return matchesWhere(r as Row, rest);
|
|
892
|
+
});
|
|
893
|
+
if (typeof take === "number") rows = rows.slice(0, take);
|
|
894
|
+
return Promise.resolve(rows);
|
|
895
|
+
},
|
|
896
|
+
);
|
|
897
|
+
(prisma.flowCheckout.findMany as jest.Mock).mockImplementation(
|
|
898
|
+
({ where, take }: { where?: Where; take?: number }) => {
|
|
899
|
+
let rows = CHECKOUTS_R.filter((r) => matchesWhere(r as Row, where ?? {}));
|
|
900
|
+
if (typeof take === "number") rows = rows.slice(0, take);
|
|
901
|
+
return Promise.resolve(rows);
|
|
902
|
+
},
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function removeRecommendingFixtures(): void {
|
|
907
|
+
EXPERIMENTS.pop();
|
|
908
|
+
LEADS.pop();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
describe("listExperiments — per-offer funnel counts (#4839)", () => {
|
|
912
|
+
beforeEach(() => installRecommendingFixtures());
|
|
913
|
+
afterEach(() => removeRecommendingFixtures());
|
|
914
|
+
|
|
915
|
+
it("AC-1: the three counts per offer in register order; a created checkout is not counted; the response parses", async () => {
|
|
916
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
917
|
+
const reco = result.experiments.find((e) => e.id === EXP_R);
|
|
918
|
+
expect(reco?.counts.offers).toEqual([
|
|
919
|
+
{ offerKey: "pro", recommended: 2, saved: 1, checkedOut: 2 },
|
|
920
|
+
{ offerKey: "starter", recommended: 1, saved: 0, checkedOut: 0 },
|
|
921
|
+
]);
|
|
922
|
+
expect(() => waitlistDashboardListSchema.parse(result)).not.toThrow();
|
|
923
|
+
const qualifying = result.experiments.find((e) => e.id === EXP_A);
|
|
924
|
+
expect(qualifying?.counts).not.toHaveProperty("offers");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("AC-1: a lead-capture experiment in the same list carries no offers key", async () => {
|
|
928
|
+
const result = await listExperiments({ kind: "all" });
|
|
929
|
+
const leadCapture = result.experiments.find((e) => e.id === EXP_B);
|
|
930
|
+
expect(leadCapture?.counts).not.toHaveProperty("offers");
|
|
931
|
+
expect(result.experiments.find((e) => e.id === EXP_R)?.counts.offers).toHaveLength(2);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("AC-2: exactly one FlowSession read + one FlowCheckout read, scoped under site, capped", async () => {
|
|
935
|
+
await listExperiments({ kind: "site", siteId: WS_A });
|
|
936
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
|
|
937
|
+
expect(prisma.flowCheckout.findMany).toHaveBeenCalledTimes(1);
|
|
938
|
+
const sessionCall = (prisma.flowSession.findMany as jest.Mock).mock.calls[0][0];
|
|
939
|
+
const checkoutCall = (prisma.flowCheckout.findMany as jest.Mock).mock.calls[0][0];
|
|
940
|
+
expect(sessionCall.where.siteId).toBe(WS_A);
|
|
941
|
+
expect(checkoutCall.where.siteId).toBe(WS_A);
|
|
942
|
+
expect(sessionCall.take).toBe(MAX_AGGREGATION_ROWS);
|
|
943
|
+
expect(checkoutCall.take).toBe(MAX_AGGREGATION_ROWS);
|
|
944
|
+
expect(checkoutCall.orderBy).toEqual({ createdAt: "asc" });
|
|
945
|
+
expect(checkoutCall.where.status).toBe("completed");
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it("AC-2: the same two reads once under kind:'all', without siteId, even with two recommending experiments", async () => {
|
|
949
|
+
const second = { ...EXPERIMENT_R, id: "wlexp_00000000-0000-7000-8000-0000000000ee", siteId: WS_B };
|
|
950
|
+
EXPERIMENTS.push(second as never);
|
|
951
|
+
try {
|
|
952
|
+
await listExperiments({ kind: "all" });
|
|
953
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
|
|
954
|
+
expect(prisma.flowCheckout.findMany).toHaveBeenCalledTimes(1);
|
|
955
|
+
const sessionCall = (prisma.flowSession.findMany as jest.Mock).mock.calls[0][0];
|
|
956
|
+
const checkoutCall = (prisma.flowCheckout.findMany as jest.Mock).mock.calls[0][0];
|
|
957
|
+
expect(sessionCall.where).not.toHaveProperty("siteId");
|
|
958
|
+
expect(checkoutCall.where).not.toHaveProperty("siteId");
|
|
959
|
+
} finally {
|
|
960
|
+
EXPERIMENTS.pop();
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("AC-2: neither read runs when no recommending experiment is in scope", async () => {
|
|
965
|
+
await listExperiments({ kind: "site", siteId: WS_B });
|
|
966
|
+
expect(prisma.flowSession.findMany).not.toHaveBeenCalled();
|
|
967
|
+
expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("AC-4: the rollup and the existing per-experiment counts are unchanged by the funnel", async () => {
|
|
971
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
972
|
+
// The pre-#4839 fixture's rollup for site A was { raw: 4, qualified: 1,
|
|
973
|
+
// rate: 0.25 } over EXP_A alone; the recommending experiment adds its one
|
|
974
|
+
// saved lead to raw (it IS a signup) and nothing else.
|
|
975
|
+
expect(result.rollup.experiments).toEqual({ total: 2, live: 2, closed: 0, draft: 0 });
|
|
976
|
+
expect(result.rollup.raw).toBe(5);
|
|
977
|
+
expect(result.rollup.qualified).toBe(1);
|
|
978
|
+
expect(result.rollup.qualificationRate).toBeCloseTo(0.2);
|
|
979
|
+
const qualifying = result.experiments.find((e) => e.id === EXP_A);
|
|
980
|
+
expect(qualifying?.counts).toEqual({ raw: 4, qualified: 1, qualificationRate: 0.25 });
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// ── #4861 — the read-time recommended count costs the DISTINCT answer sets ───
|
|
985
|
+
//
|
|
986
|
+
// `computeOfferCounts` recomputes the pure `recommend()` for every completed
|
|
987
|
+
// session in scope, up to `MAX_AGGREGATION_ROWS` rows × the register's offers ×
|
|
988
|
+
// their rules, synchronously, on every list read (spec §1's measurement: 232 ms
|
|
989
|
+
// at 4 offers / 459 ms at 8 / 308 ms with scoring, on a 20k-row fixture). Spec
|
|
990
|
+
// D1 memoizes it per call on the session's answers PROJECTED onto the
|
|
991
|
+
// register's declared question ids — sound because
|
|
992
|
+
// `waitlistExperimentConfigSchema` rejects any predicate leaf or scoring point
|
|
993
|
+
// naming an undeclared question (`packages/validation/src/schemas/waitlist.ts:449–452`,
|
|
994
|
+
// `:759–765`), so `recommend()` cannot read a key outside that projection.
|
|
995
|
+
|
|
996
|
+
const EXP_C = "wlexp_00000000-0000-7000-8000-0000000000cc"; // recommending, site A, cost fixture
|
|
997
|
+
|
|
998
|
+
const CONFIG_C = {
|
|
999
|
+
key: "reco-cost",
|
|
1000
|
+
mode: "recommending" as const,
|
|
1001
|
+
status: "live" as const,
|
|
1002
|
+
waitlists: [{ key: "main", flowSlug: "reco-cost" }],
|
|
1003
|
+
questionSet: {
|
|
1004
|
+
version: 1 as const,
|
|
1005
|
+
questions: [
|
|
1006
|
+
// A declared FREE-TEXT question, FIRST in declared order: its answer is
|
|
1007
|
+
// raw visitor input under no charset constraint, so a memo key that
|
|
1008
|
+
// joined `id=value` pairs with a separator could be forged from this box
|
|
1009
|
+
// to impersonate a later question's answer (the third test below).
|
|
1010
|
+
{ id: "q_company", prompt: en("Company?"), answerType: "text" as const, minLength: 1 },
|
|
1011
|
+
{
|
|
1012
|
+
id: "q_team",
|
|
1013
|
+
prompt: en("Team?"),
|
|
1014
|
+
answerType: "single" as const,
|
|
1015
|
+
options: [
|
|
1016
|
+
{ value: "solo", label: en("Solo") },
|
|
1017
|
+
{ value: "small_team", label: en("Small team") },
|
|
1018
|
+
{ value: "agency", label: en("Agency") },
|
|
1019
|
+
],
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
id: "q_needs",
|
|
1023
|
+
prompt: en("Needs?"),
|
|
1024
|
+
answerType: "multi" as const,
|
|
1025
|
+
options: [
|
|
1026
|
+
{ value: "seo", label: en("SEO") },
|
|
1027
|
+
{ value: "leads", label: en("Leads") },
|
|
1028
|
+
{ value: "content", label: en("Content") },
|
|
1029
|
+
],
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: "q_support",
|
|
1033
|
+
prompt: en("Support?"),
|
|
1034
|
+
answerType: "single" as const,
|
|
1035
|
+
options: [
|
|
1036
|
+
{ value: "diy", label: en("DIY") },
|
|
1037
|
+
{ value: "guided", label: en("Guided") },
|
|
1038
|
+
],
|
|
1039
|
+
},
|
|
1040
|
+
],
|
|
1041
|
+
},
|
|
1042
|
+
tracking: {
|
|
1043
|
+
version: 1 as const,
|
|
1044
|
+
utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
|
|
1045
|
+
waitlists: { main: { campaign: "reco-cost-campaign", channels: ["organic"] } },
|
|
1046
|
+
},
|
|
1047
|
+
stripeConfigRef: "stripe-cost",
|
|
1048
|
+
offers: [
|
|
1049
|
+
{
|
|
1050
|
+
key: "free_oss",
|
|
1051
|
+
copy: { name: en("Free") },
|
|
1052
|
+
price: { kind: "label" as const, label: en("Free") },
|
|
1053
|
+
rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders start free.") }],
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
key: "pro",
|
|
1057
|
+
copy: { name: en("Pro") },
|
|
1058
|
+
price: { kind: "exact" as const, amount: 249, period: "annual" as const },
|
|
1059
|
+
rules: [
|
|
1060
|
+
{ when: { answer: "q_team", in: ["small_team", "agency"] }, reason: en("Teams need seats.") },
|
|
1061
|
+
],
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
key: "pro_plus",
|
|
1065
|
+
copy: { name: en("Pro Plus") },
|
|
1066
|
+
price: { kind: "exact" as const, amount: 449, period: "monthly" as const },
|
|
1067
|
+
rules: [
|
|
1068
|
+
{
|
|
1069
|
+
when: {
|
|
1070
|
+
allOf: [
|
|
1071
|
+
{ answer: "q_team", eq: "agency" },
|
|
1072
|
+
{ answer: "q_support", eq: "guided" },
|
|
1073
|
+
],
|
|
1074
|
+
},
|
|
1075
|
+
reason: en("Agencies want a person to call."),
|
|
1076
|
+
},
|
|
1077
|
+
],
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
key: "launch_service",
|
|
1081
|
+
copy: { name: en("Launch service") },
|
|
1082
|
+
price: { kind: "label" as const, label: en("From $5k") },
|
|
1083
|
+
rules: [
|
|
1084
|
+
{
|
|
1085
|
+
when: {
|
|
1086
|
+
allOf: [
|
|
1087
|
+
{ answer: "q_support", eq: "guided" },
|
|
1088
|
+
{ answer: "q_needs", includesAny: ["content"] },
|
|
1089
|
+
],
|
|
1090
|
+
},
|
|
1091
|
+
reason: en("Content plus hand-holding is a service engagement."),
|
|
1092
|
+
},
|
|
1093
|
+
],
|
|
1094
|
+
},
|
|
1095
|
+
],
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const EXPERIMENT_C = {
|
|
1099
|
+
id: EXP_C,
|
|
1100
|
+
siteId: WS_A,
|
|
1101
|
+
key: CONFIG_C.key,
|
|
1102
|
+
mode: CONFIG_C.mode,
|
|
1103
|
+
status: CONFIG_C.status,
|
|
1104
|
+
config: CONFIG_C,
|
|
1105
|
+
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
1106
|
+
updatedAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
const C_TEAMS = ["solo", "small_team", "agency"];
|
|
1110
|
+
const C_NEEDS = [["seo"], ["leads"], ["seo", "content"], ["content"]];
|
|
1111
|
+
const C_SUPPORT = ["diy", "guided"];
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* `count` completed sessions cycling through the register's option space — 12
|
|
1115
|
+
* distinct answer sets (lcm(3, 4, 2)) — each carrying a per-session-unique
|
|
1116
|
+
* value under an UNDECLARED key (`email`, as a recommender walk's field step
|
|
1117
|
+
* leaves in `state`). Keying the memo on the raw flattened map would make every
|
|
1118
|
+
* row unique; the D1 projection drops the undeclared key.
|
|
1119
|
+
*/
|
|
1120
|
+
function costSessions(count: number): Array<{
|
|
1121
|
+
siteId: string;
|
|
1122
|
+
flowSlug: string;
|
|
1123
|
+
completedAt: Date;
|
|
1124
|
+
state: Record<string, Record<string, string | string[]>>;
|
|
1125
|
+
}> {
|
|
1126
|
+
const rows = [];
|
|
1127
|
+
for (let i = 0; i < count; i += 1) {
|
|
1128
|
+
rows.push({
|
|
1129
|
+
siteId: WS_A,
|
|
1130
|
+
flowSlug: "reco-cost",
|
|
1131
|
+
completedAt: new Date(1_760_000_000_000 + i),
|
|
1132
|
+
state: {
|
|
1133
|
+
segmentation: {
|
|
1134
|
+
q_team: C_TEAMS[i % 3] as string,
|
|
1135
|
+
q_needs: C_NEEDS[i % 4] as string[],
|
|
1136
|
+
q_support: C_SUPPORT[i % 2] as string,
|
|
1137
|
+
},
|
|
1138
|
+
contact: { email: `visitor-${i}@example.com` },
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return rows;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The expected per-offer `recommended` counts, re-derived from the fixture's
|
|
1147
|
+
* own answers by reading CONFIG_C's four fit rules directly — never by calling
|
|
1148
|
+
* `recommend()`, so the assertion is independent of the code under test.
|
|
1149
|
+
*/
|
|
1150
|
+
function expectedRecommended(
|
|
1151
|
+
rows: ReturnType<typeof costSessions>,
|
|
1152
|
+
): Record<string, number> {
|
|
1153
|
+
const expected: Record<string, number> = {
|
|
1154
|
+
free_oss: 0,
|
|
1155
|
+
pro: 0,
|
|
1156
|
+
pro_plus: 0,
|
|
1157
|
+
launch_service: 0,
|
|
1158
|
+
};
|
|
1159
|
+
for (const row of rows) {
|
|
1160
|
+
const a = row.state.segmentation as { q_team: string; q_needs: string[]; q_support: string };
|
|
1161
|
+
// CONFIG_C's four fit rules, read straight off the register in its own
|
|
1162
|
+
// order, then capped the way the engine's contract caps them
|
|
1163
|
+
// (`packages/validation/src/schemas/waitlist-recommend.ts:266` — at most
|
|
1164
|
+
// the first three fits are returned).
|
|
1165
|
+
const matched: string[] = [];
|
|
1166
|
+
if (a.q_team === "solo") matched.push("free_oss");
|
|
1167
|
+
if (a.q_team === "small_team" || a.q_team === "agency") matched.push("pro");
|
|
1168
|
+
if (a.q_team === "agency" && a.q_support === "guided") matched.push("pro_plus");
|
|
1169
|
+
if (a.q_support === "guided" && a.q_needs.includes("content")) matched.push("launch_service");
|
|
1170
|
+
for (const key of matched.slice(0, 3)) expected[key] = (expected[key] ?? 0) + 1;
|
|
1171
|
+
}
|
|
1172
|
+
return expected;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
describe("listExperiments — recommended-count cost (#4861)", () => {
|
|
1176
|
+
const recommendMock = (
|
|
1177
|
+
jest.requireMock("@working-theory/validation") as { recommend: jest.Mock }
|
|
1178
|
+
).recommend;
|
|
1179
|
+
const realRecommend = (
|
|
1180
|
+
jest.requireActual("@working-theory/validation") as {
|
|
1181
|
+
recommend: (...args: never[]) => unknown;
|
|
1182
|
+
}
|
|
1183
|
+
).recommend;
|
|
1184
|
+
|
|
1185
|
+
beforeEach(() => {
|
|
1186
|
+
EXPERIMENTS.push(EXPERIMENT_C as never);
|
|
1187
|
+
recommendMock.mockImplementation(realRecommend);
|
|
1188
|
+
(prisma.flowCheckout.findMany as jest.Mock).mockResolvedValue([]);
|
|
1189
|
+
});
|
|
1190
|
+
afterEach(() => {
|
|
1191
|
+
EXPERIMENTS.pop();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("AC-1: at the 20,000-row cap, recommend() runs once per DISTINCT answer set — and every count is what the rows say", async () => {
|
|
1195
|
+
const rows = costSessions(MAX_AGGREGATION_ROWS);
|
|
1196
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(rows);
|
|
1197
|
+
|
|
1198
|
+
const startedAt = Date.now();
|
|
1199
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
1200
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1201
|
+
|
|
1202
|
+
// 12 distinct answer sets in 20,000 rows (RED at HEAD: 20,000 calls). The
|
|
1203
|
+
// lower bound keeps the assertion self-proving — a mock that stopped
|
|
1204
|
+
// intercepting would report 0 and pass the ceiling alone.
|
|
1205
|
+
expect(recommendMock.mock.calls.length).toBeGreaterThan(0);
|
|
1206
|
+
expect(recommendMock.mock.calls.length).toBeLessThanOrEqual(32);
|
|
1207
|
+
const expected = expectedRecommended(rows);
|
|
1208
|
+
expect(result.experiments.find((e) => e.id === EXP_C)?.counts.offers).toEqual([
|
|
1209
|
+
{ offerKey: "free_oss", recommended: expected.free_oss, saved: 0, checkedOut: 0 },
|
|
1210
|
+
{ offerKey: "pro", recommended: expected.pro, saved: 0, checkedOut: 0 },
|
|
1211
|
+
{ offerKey: "pro_plus", recommended: expected.pro_plus, saved: 0, checkedOut: 0 },
|
|
1212
|
+
{ offerKey: "launch_service", recommended: expected.launch_service, saved: 0, checkedOut: 0 },
|
|
1213
|
+
]);
|
|
1214
|
+
// A catastrophe ceiling, NOT the budget: the decided budget (≤ 500 ms at
|
|
1215
|
+
// the cap, spec D7) is recorded on #4861 with its measurement, because
|
|
1216
|
+
// wall-clock in CI is not a reliable gate.
|
|
1217
|
+
expect(elapsedMs).toBeLessThan(2_000);
|
|
1218
|
+
}, 60_000);
|
|
1219
|
+
|
|
1220
|
+
it("AC-1: two sessions differing only in an UNDECLARED answer key share one recommend() and both count", async () => {
|
|
1221
|
+
const base = costSessions(1)[0]!;
|
|
1222
|
+
const twin = {
|
|
1223
|
+
...base,
|
|
1224
|
+
completedAt: new Date(base.completedAt.getTime() + 1),
|
|
1225
|
+
state: {
|
|
1226
|
+
segmentation: { ...base.state.segmentation },
|
|
1227
|
+
contact: { email: "someone-else@example.com" },
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([base, twin]);
|
|
1231
|
+
|
|
1232
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
1233
|
+
|
|
1234
|
+
expect(recommendMock.mock.calls.length).toBe(1);
|
|
1235
|
+
const offers = result.experiments.find((e) => e.id === EXP_C)?.counts.offers;
|
|
1236
|
+
expect(offers?.find((o) => o.offerKey === "free_oss")?.recommended).toBe(2);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it("AC-1: a free-text answer cannot forge another session's answer set into one memo entry", async () => {
|
|
1240
|
+
const at = new Date("2026-09-04T00:00:00.000Z");
|
|
1241
|
+
// Under a delimiter-joined key these two collide: the first session's
|
|
1242
|
+
// company name carries the separator plus a literal `q_team=agency`.
|
|
1243
|
+
const forger = {
|
|
1244
|
+
siteId: WS_A,
|
|
1245
|
+
flowSlug: "reco-cost",
|
|
1246
|
+
completedAt: at,
|
|
1247
|
+
state: { segmentation: { q_company: "Acme\u001eq_team=agency" } },
|
|
1248
|
+
};
|
|
1249
|
+
const genuine = {
|
|
1250
|
+
siteId: WS_A,
|
|
1251
|
+
flowSlug: "reco-cost",
|
|
1252
|
+
completedAt: new Date(at.getTime() + 1),
|
|
1253
|
+
state: { segmentation: { q_company: "Acme", q_team: "agency" } },
|
|
1254
|
+
};
|
|
1255
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([forger, genuine]);
|
|
1256
|
+
|
|
1257
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
1258
|
+
|
|
1259
|
+
expect(recommendMock.mock.calls.length).toBe(2);
|
|
1260
|
+
const offers = result.experiments.find((e) => e.id === EXP_C)?.counts.offers;
|
|
1261
|
+
// Only the genuine `agency` answer reaches `pro`; the forger answered no
|
|
1262
|
+
// segmentation question at all and fits nothing.
|
|
1263
|
+
expect(offers?.find((o) => o.offerKey === "pro")?.recommended).toBe(1);
|
|
1264
|
+
expect(offers?.find((o) => o.offerKey === "free_oss")?.recommended).toBe(0);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it("AC-3: a flow no register can score costs ONE aggregated warn, not one per row", async () => {
|
|
1268
|
+
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
|
|
1269
|
+
recommendMock.mockImplementation(() => {
|
|
1270
|
+
throw Object.assign(new Error("register drifted"), { name: "WaitlistEvaluationError" });
|
|
1271
|
+
});
|
|
1272
|
+
const rows = costSessions(500);
|
|
1273
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(rows);
|
|
1274
|
+
|
|
1275
|
+
try {
|
|
1276
|
+
const result = await listExperiments({ kind: "site", siteId: WS_A });
|
|
1277
|
+
|
|
1278
|
+
const skipWarns = warnSpy.mock.calls.filter(
|
|
1279
|
+
(call) => typeof call[1] === "string" && call[1].includes("[waitlist-dashboard] skipped"),
|
|
1280
|
+
);
|
|
1281
|
+
expect(skipWarns).toHaveLength(1);
|
|
1282
|
+
expect(skipWarns[0]?.[0]).toEqual({
|
|
1283
|
+
siteId: WS_A,
|
|
1284
|
+
experimentKey: "reco-cost",
|
|
1285
|
+
skipped: 500,
|
|
1286
|
+
errName: "WaitlistEvaluationError",
|
|
1287
|
+
});
|
|
1288
|
+
// The failure is memoized too — once per distinct answer set, not per row.
|
|
1289
|
+
expect(recommendMock.mock.calls.length).toBeLessThanOrEqual(32);
|
|
1290
|
+
// A session nothing can score counts nowhere.
|
|
1291
|
+
expect(
|
|
1292
|
+
result.experiments.find((e) => e.id === EXP_C)?.counts.offers?.every((o) => o.recommended === 0),
|
|
1293
|
+
).toBe(true);
|
|
1294
|
+
} finally {
|
|
1295
|
+
warnSpy.mockRestore();
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("AC-3: a fully scoreable flow emits no skipped-session warn at all", async () => {
|
|
1300
|
+
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
|
|
1301
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(costSessions(60));
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
await listExperiments({ kind: "site", siteId: WS_A });
|
|
1305
|
+
expect(
|
|
1306
|
+
warnSpy.mock.calls.filter(
|
|
1307
|
+
(call) => typeof call[1] === "string" && call[1].includes("[waitlist-dashboard] skipped"),
|
|
1308
|
+
),
|
|
1309
|
+
).toHaveLength(0);
|
|
1310
|
+
} finally {
|
|
1311
|
+
warnSpy.mockRestore();
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
});
|