create-ailk 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -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 +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -23,9 +23,19 @@ jest.mock("../../lib/prisma.js");
|
|
|
23
23
|
|
|
24
24
|
// ─── Mock: email (external boundary) ─────────────────────────────────────────
|
|
25
25
|
const mockSendLeadNotification = jest.fn();
|
|
26
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
26
27
|
jest.mock("@working-theory/email", () => ({
|
|
27
28
|
sendLeadNotification: (...args: unknown[]) =>
|
|
28
29
|
mockSendLeadNotification(...args),
|
|
30
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
31
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// ─── Mock: the plan-offerings lister (a content read) ────────────────────────
|
|
35
|
+
const mockListPlanOfferings = jest.fn();
|
|
36
|
+
// eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam routes/leads/__tests__/magnet-completion.test.ts mocks.
|
|
37
|
+
jest.mock("../plan-offerings.js", () => ({
|
|
38
|
+
listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
|
|
29
39
|
}));
|
|
30
40
|
|
|
31
41
|
// ─── Import after mocks ───────────────────────────────────────────────────────
|
|
@@ -34,10 +44,21 @@ import * as os from "os";
|
|
|
34
44
|
import * as path from "path";
|
|
35
45
|
|
|
36
46
|
import { logger } from "@working-theory/observability";
|
|
47
|
+
import {
|
|
48
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
49
|
+
CONSENT_LEAD_SOURCE_LIST,
|
|
50
|
+
consentPurposeById,
|
|
51
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
52
|
+
} from "@working-theory/validation";
|
|
37
53
|
|
|
38
54
|
import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
|
|
55
|
+
import {
|
|
56
|
+
armConsentLedger,
|
|
57
|
+
type ConsentLedgerDouble,
|
|
58
|
+
} from "../../lib/__tests__/consent-ledger-double.js";
|
|
39
59
|
import { prisma } from "../../lib/prisma.js";
|
|
40
60
|
import { routeLead } from "../lead-routing";
|
|
61
|
+
import * as consentService from "../consent";
|
|
41
62
|
import * as personService from "../person";
|
|
42
63
|
|
|
43
64
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -141,7 +162,9 @@ describe("routeLead", () => {
|
|
|
141
162
|
|
|
142
163
|
// ── 2. DB down → fallback engages → persisted:file ───────────────────────
|
|
143
164
|
it("DB down → file fallback → persisted:file", async () => {
|
|
144
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
165
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
166
|
+
new Error("connection refused"),
|
|
167
|
+
);
|
|
145
168
|
|
|
146
169
|
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
147
170
|
RouteLeadResult,
|
|
@@ -367,9 +390,12 @@ describe("routeLead", () => {
|
|
|
367
390
|
const inputForService = {
|
|
368
391
|
...inputWithoutId,
|
|
369
392
|
// Re-attach as a plain unknown property so Zod's strict() can reject it.
|
|
370
|
-
...({
|
|
393
|
+
...({ id: inputWithExtraId.id } as Record<string, unknown>),
|
|
371
394
|
} as Parameters<typeof routeLead>[0];
|
|
372
|
-
const result = (await routeLead(inputForService, sourceMeta)) as Extract<
|
|
395
|
+
const result = (await routeLead(inputForService, sourceMeta)) as Extract<
|
|
396
|
+
RouteLeadResult,
|
|
397
|
+
{ ok: false }
|
|
398
|
+
>;
|
|
373
399
|
|
|
374
400
|
expect(result.ok).toBe(false);
|
|
375
401
|
expect(result.error.kind).toBe("invalid_input");
|
|
@@ -433,7 +459,9 @@ describe("routeLead", () => {
|
|
|
433
459
|
|
|
434
460
|
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
435
461
|
expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
436
|
-
expect(data.workspaceId).toBe(
|
|
462
|
+
expect(data.workspaceId).toBe(
|
|
463
|
+
"wksp_00000000-0000-7000-8000-000000000001",
|
|
464
|
+
);
|
|
437
465
|
});
|
|
438
466
|
});
|
|
439
467
|
|
|
@@ -456,10 +484,10 @@ describe("routeLead", () => {
|
|
|
456
484
|
);
|
|
457
485
|
(prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
|
|
458
486
|
|
|
459
|
-
const result = (await routeLead(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
487
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
488
|
+
RouteLeadResult,
|
|
489
|
+
{ ok: false }
|
|
490
|
+
>;
|
|
463
491
|
|
|
464
492
|
expect(result.ok).toBe(false);
|
|
465
493
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -475,10 +503,10 @@ describe("routeLead", () => {
|
|
|
475
503
|
);
|
|
476
504
|
(prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
|
|
477
505
|
|
|
478
|
-
const result = (await routeLead(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
506
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
507
|
+
RouteLeadResult,
|
|
508
|
+
{ ok: false }
|
|
509
|
+
>;
|
|
482
510
|
|
|
483
511
|
expect(result.ok).toBe(false);
|
|
484
512
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -489,15 +517,17 @@ describe("routeLead", () => {
|
|
|
489
517
|
it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
|
|
490
518
|
const before = await listTmpDirFiles();
|
|
491
519
|
const notNullErr = Object.assign(
|
|
492
|
-
new Error(
|
|
520
|
+
new Error(
|
|
521
|
+
'null value in column "workspace_id" violates not-null constraint (23502)',
|
|
522
|
+
),
|
|
493
523
|
{},
|
|
494
524
|
);
|
|
495
525
|
(prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
|
|
496
526
|
|
|
497
|
-
const result = (await routeLead(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
527
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
528
|
+
RouteLeadResult,
|
|
529
|
+
{ ok: false }
|
|
530
|
+
>;
|
|
501
531
|
|
|
502
532
|
expect(result.ok).toBe(false);
|
|
503
533
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -512,10 +542,10 @@ describe("routeLead", () => {
|
|
|
512
542
|
new Error("connection refused"),
|
|
513
543
|
);
|
|
514
544
|
|
|
515
|
-
const result = (await routeLead(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
545
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
546
|
+
RouteLeadResult,
|
|
547
|
+
{ ok: true }
|
|
548
|
+
>;
|
|
519
549
|
|
|
520
550
|
expect(result.ok).toBe(true);
|
|
521
551
|
expect(result.persisted).toBe("file");
|
|
@@ -542,14 +572,17 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
|
|
|
542
572
|
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
543
573
|
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
|
544
574
|
);
|
|
545
|
-
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
575
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
576
|
+
id: "sub_1",
|
|
577
|
+
});
|
|
546
578
|
|
|
547
579
|
await routeLead(
|
|
548
580
|
{ ...subscribeInput, email: "dupe@example.com" },
|
|
549
581
|
sourceMeta,
|
|
550
582
|
);
|
|
551
583
|
|
|
552
|
-
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
|
|
584
|
+
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
|
|
585
|
+
.calls[0][0];
|
|
553
586
|
expect(where.workspaceId).toBe(subscribeInput.siteId);
|
|
554
587
|
expect(where.siteId_email).toEqual({
|
|
555
588
|
siteId: subscribeInput.siteId,
|
|
@@ -588,7 +621,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
588
621
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
|
|
589
622
|
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
590
623
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
|
|
591
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
624
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
625
|
+
ok: true,
|
|
626
|
+
messageId: "msg-1",
|
|
627
|
+
});
|
|
592
628
|
});
|
|
593
629
|
|
|
594
630
|
afterEach(async () => {
|
|
@@ -646,7 +682,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
646
682
|
});
|
|
647
683
|
|
|
648
684
|
const result = await routeLead(leadInput, sourceMeta);
|
|
649
|
-
expect(result).toMatchObject({
|
|
685
|
+
expect(result).toMatchObject({
|
|
686
|
+
ok: false,
|
|
687
|
+
error: { kind: "delivery_failed" },
|
|
688
|
+
});
|
|
650
689
|
});
|
|
651
690
|
|
|
652
691
|
it("a callback is delivered by email too — carrying the name and number to call", async () => {
|
|
@@ -669,7 +708,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
669
708
|
|
|
670
709
|
it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
|
|
671
710
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
672
|
-
expect(result).toMatchObject({
|
|
711
|
+
expect(result).toMatchObject({
|
|
712
|
+
ok: false,
|
|
713
|
+
error: { kind: "config_missing" },
|
|
714
|
+
});
|
|
673
715
|
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
674
716
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
675
717
|
});
|
|
@@ -683,7 +725,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
683
725
|
it("refuses with config_missing before any write", async () => {
|
|
684
726
|
const result = await routeLead(leadInput, sourceMeta);
|
|
685
727
|
|
|
686
|
-
expect(result).toMatchObject({
|
|
728
|
+
expect(result).toMatchObject({
|
|
729
|
+
ok: false,
|
|
730
|
+
error: { kind: "config_missing" },
|
|
731
|
+
});
|
|
687
732
|
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
688
733
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
689
734
|
expect(await listFiles()).toEqual([]);
|
|
@@ -694,13 +739,18 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
694
739
|
beforeEach(() => {
|
|
695
740
|
process.env.DATABASE_URL = "postgres://test";
|
|
696
741
|
process.env.NODE_ENV = "production";
|
|
697
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
742
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
743
|
+
new Error("connection refused"),
|
|
744
|
+
);
|
|
698
745
|
});
|
|
699
746
|
|
|
700
747
|
it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
|
|
701
748
|
const result = await routeLead(leadInput, sourceMeta);
|
|
702
749
|
|
|
703
|
-
expect(result).toMatchObject({
|
|
750
|
+
expect(result).toMatchObject({
|
|
751
|
+
ok: false,
|
|
752
|
+
error: { kind: "persist_failed" },
|
|
753
|
+
});
|
|
704
754
|
expect(await listFiles()).toEqual([]);
|
|
705
755
|
});
|
|
706
756
|
|
|
@@ -719,11 +769,16 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
719
769
|
|
|
720
770
|
it("a newsletter signup is never emailed — persist_failed", async () => {
|
|
721
771
|
Object.assign(process.env, EMAIL_ENV);
|
|
722
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
772
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
773
|
+
new Error("connection refused"),
|
|
774
|
+
);
|
|
723
775
|
|
|
724
776
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
725
777
|
|
|
726
|
-
expect(result).toMatchObject({
|
|
778
|
+
expect(result).toMatchObject({
|
|
779
|
+
ok: false,
|
|
780
|
+
error: { kind: "persist_failed" },
|
|
781
|
+
});
|
|
727
782
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
728
783
|
});
|
|
729
784
|
|
|
@@ -735,7 +790,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
735
790
|
});
|
|
736
791
|
|
|
737
792
|
const result = await routeLead(leadInput, sourceMeta);
|
|
738
|
-
expect(result).toMatchObject({
|
|
793
|
+
expect(result).toMatchObject({
|
|
794
|
+
ok: false,
|
|
795
|
+
error: { kind: "persist_failed" },
|
|
796
|
+
});
|
|
739
797
|
});
|
|
740
798
|
});
|
|
741
799
|
|
|
@@ -743,7 +801,9 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
743
801
|
it("DB down in test → persisted:file", async () => {
|
|
744
802
|
process.env.DATABASE_URL = "postgres://test";
|
|
745
803
|
process.env.NODE_ENV = "test";
|
|
746
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
804
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
805
|
+
new Error("connection refused"),
|
|
806
|
+
);
|
|
747
807
|
|
|
748
808
|
const result = await routeLead(leadInput, sourceMeta);
|
|
749
809
|
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
@@ -951,6 +1011,140 @@ describe("#7869 — routeLead with an idempotency key", () => {
|
|
|
951
1011
|
});
|
|
952
1012
|
});
|
|
953
1013
|
|
|
1014
|
+
// ─── #8730, #9130 — a keyed consent-bearing lead replays ─────────────────────
|
|
1015
|
+
//
|
|
1016
|
+
// The route writes `payload.consent` on every request, its `acceptedAt` that
|
|
1017
|
+
// request's receive time, so a retry's payload differs from the stored one
|
|
1018
|
+
// there and nowhere else. The replay comparison leaves it out for EVERY
|
|
1019
|
+
// consent-bearing source — iterating the list, so a source added to it later
|
|
1020
|
+
// cannot be gated by the route yet compared here.
|
|
1021
|
+
describe("#8730, #9130 — routeLead replays a keyed consent-bearing lead", () => {
|
|
1022
|
+
const KEY = "fixture-retry-0000-0000-9130";
|
|
1023
|
+
const STORED_ID = "lead_00000000-0000-7000-8000-000000009131";
|
|
1024
|
+
const ENV_KEYS = [
|
|
1025
|
+
"DATABASE_URL",
|
|
1026
|
+
"NODE_ENV",
|
|
1027
|
+
"AILK_INBOX_FALLBACK_DIR",
|
|
1028
|
+
"LEAD_CAPTURE_FILE",
|
|
1029
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
1030
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1031
|
+
] as const;
|
|
1032
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1033
|
+
let tmpDir: string;
|
|
1034
|
+
|
|
1035
|
+
const consentAt = (acceptedAt: string) => ({
|
|
1036
|
+
statement: "I accept the terms.",
|
|
1037
|
+
acceptedAt,
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
beforeEach(async () => {
|
|
1041
|
+
jest.clearAllMocks();
|
|
1042
|
+
saved = {};
|
|
1043
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1044
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
1045
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-consent-replay-"));
|
|
1046
|
+
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
1047
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1048
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1049
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1050
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
|
|
1051
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
afterEach(async () => {
|
|
1055
|
+
for (const k of ENV_KEYS) {
|
|
1056
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
1057
|
+
else process.env[k] = saved[k];
|
|
1058
|
+
}
|
|
1059
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("covers every consent-bearing source, the job application included", () => {
|
|
1063
|
+
expect(CONSENT_LEAD_SOURCE_LIST).toContain("flow:job-application");
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it.each(CONSENT_LEAD_SOURCE_LIST)(
|
|
1067
|
+
"%s — a retry whose only difference is the server-written payload.consent replays the stored lead",
|
|
1068
|
+
async (source) => {
|
|
1069
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1070
|
+
id: STORED_ID,
|
|
1071
|
+
email: "visitor@example.com",
|
|
1072
|
+
source,
|
|
1073
|
+
payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const result = await routeLead(
|
|
1077
|
+
{
|
|
1078
|
+
kind: "submit",
|
|
1079
|
+
siteId: "site-abc",
|
|
1080
|
+
source,
|
|
1081
|
+
email: "visitor@example.com",
|
|
1082
|
+
payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
|
|
1083
|
+
},
|
|
1084
|
+
sourceMeta,
|
|
1085
|
+
{ idempotencyKey: KEY },
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
expect(result).toMatchObject({ ok: true, replayed: true, id: STORED_ID });
|
|
1089
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1090
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
1091
|
+
},
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
it.each(CONSENT_LEAD_SOURCE_LIST)(
|
|
1095
|
+
"%s — a retry with a different form field is still a conflict",
|
|
1096
|
+
async (source) => {
|
|
1097
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1098
|
+
id: STORED_ID,
|
|
1099
|
+
email: "visitor@example.com",
|
|
1100
|
+
source,
|
|
1101
|
+
payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const result = await routeLead(
|
|
1105
|
+
{
|
|
1106
|
+
kind: "submit",
|
|
1107
|
+
siteId: "site-abc",
|
|
1108
|
+
source,
|
|
1109
|
+
email: "visitor@example.com",
|
|
1110
|
+
payload: { name: "Someone else", consent: consentAt("2026-10-18T12:00:00.000Z") },
|
|
1111
|
+
},
|
|
1112
|
+
sourceMeta,
|
|
1113
|
+
{ idempotencyKey: KEY },
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
expect(result).toEqual({
|
|
1117
|
+
ok: false,
|
|
1118
|
+
error: { kind: "idempotency_conflict", message: "idempotency key reused" },
|
|
1119
|
+
});
|
|
1120
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1121
|
+
},
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
it("a source outside the list still compares payload.consent", async () => {
|
|
1125
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1126
|
+
id: STORED_ID,
|
|
1127
|
+
email: "visitor@example.com",
|
|
1128
|
+
source: "flow:quiz",
|
|
1129
|
+
payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const result = await routeLead(
|
|
1133
|
+
{
|
|
1134
|
+
kind: "submit",
|
|
1135
|
+
siteId: "site-abc",
|
|
1136
|
+
source: "flow:quiz",
|
|
1137
|
+
email: "visitor@example.com",
|
|
1138
|
+
payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
|
|
1139
|
+
},
|
|
1140
|
+
sourceMeta,
|
|
1141
|
+
{ idempotencyKey: KEY },
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "idempotency_conflict" } });
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
954
1148
|
// ─── #852 D6 — every submitted lead carries its Person ──────────────────────
|
|
955
1149
|
//
|
|
956
1150
|
// The Person step runs through the shared mock's `person` delegate, whose
|
|
@@ -967,14 +1161,20 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
967
1161
|
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
968
1162
|
const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
|
|
969
1163
|
const createData = () =>
|
|
970
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
1164
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
1165
|
+
string,
|
|
1166
|
+
unknown
|
|
1167
|
+
>;
|
|
971
1168
|
const personDelegate = () => ({
|
|
972
1169
|
findFirst: jest.mocked(prisma.person.findFirst),
|
|
973
1170
|
createMany: jest.mocked(prisma.person.createMany),
|
|
974
1171
|
updateMany: jest.mocked(prisma.person.updateMany),
|
|
975
1172
|
});
|
|
976
1173
|
const personCalls = () =>
|
|
977
|
-
Object.values(personDelegate()).reduce(
|
|
1174
|
+
Object.values(personDelegate()).reduce(
|
|
1175
|
+
(n, fn) => n + fn.mock.calls.length,
|
|
1176
|
+
0,
|
|
1177
|
+
);
|
|
978
1178
|
let warn: jest.SpyInstance;
|
|
979
1179
|
let error: jest.SpyInstance;
|
|
980
1180
|
|
|
@@ -987,9 +1187,16 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
987
1187
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
988
1188
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
989
1189
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
990
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
991
|
-
|
|
992
|
-
|
|
1190
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1191
|
+
ok: true,
|
|
1192
|
+
messageId: "msg-852",
|
|
1193
|
+
});
|
|
1194
|
+
warn = jest
|
|
1195
|
+
.spyOn(logger, "warn")
|
|
1196
|
+
.mockImplementation(() => undefined as never);
|
|
1197
|
+
error = jest
|
|
1198
|
+
.spyOn(logger, "error")
|
|
1199
|
+
.mockImplementation(() => undefined as never);
|
|
993
1200
|
});
|
|
994
1201
|
|
|
995
1202
|
afterEach(() => {
|
|
@@ -1003,21 +1210,33 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1003
1210
|
|
|
1004
1211
|
it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
|
|
1005
1212
|
const result = await routeLead(
|
|
1006
|
-
{
|
|
1213
|
+
{
|
|
1214
|
+
...leadInput,
|
|
1215
|
+
email: "Test@Example.com",
|
|
1216
|
+
payload: { name: "Tess", message: "hi" },
|
|
1217
|
+
},
|
|
1007
1218
|
sourceMeta,
|
|
1008
1219
|
);
|
|
1009
1220
|
|
|
1010
1221
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1011
1222
|
expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
|
|
1012
|
-
expect.objectContaining({
|
|
1223
|
+
expect.objectContaining({
|
|
1224
|
+
where: { workspaceId: "site-abc", email: "test@example.com" },
|
|
1225
|
+
}),
|
|
1226
|
+
);
|
|
1227
|
+
expect(createData()).toEqual(
|
|
1228
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1013
1229
|
);
|
|
1014
|
-
expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1015
1230
|
});
|
|
1016
1231
|
|
|
1017
1232
|
// ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
|
|
1018
1233
|
const LINKED = {
|
|
1019
1234
|
ok: true as const,
|
|
1020
|
-
value: {
|
|
1235
|
+
value: {
|
|
1236
|
+
personId: MOCK_PERSON_ID as never,
|
|
1237
|
+
created: false,
|
|
1238
|
+
billing: "unchanged" as const,
|
|
1239
|
+
},
|
|
1021
1240
|
};
|
|
1022
1241
|
const findSpies: jest.SpyInstance[] = [];
|
|
1023
1242
|
const spyFind = () => {
|
|
@@ -1029,54 +1248,88 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1029
1248
|
while (findSpies.length > 0) findSpies.pop()?.mockRestore();
|
|
1030
1249
|
});
|
|
1031
1250
|
const subscriberData = () =>
|
|
1032
|
-
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
|
|
1251
|
+
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
|
|
1252
|
+
string,
|
|
1253
|
+
unknown
|
|
1254
|
+
>;
|
|
1033
1255
|
const callbackData = () =>
|
|
1034
|
-
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
|
|
1256
|
+
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
|
|
1257
|
+
string,
|
|
1258
|
+
unknown
|
|
1259
|
+
>;
|
|
1035
1260
|
|
|
1036
1261
|
it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
|
|
1037
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1262
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1263
|
+
id: "subscr_x",
|
|
1264
|
+
});
|
|
1038
1265
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1039
1266
|
await routeLead(subscribeInput, sourceMeta);
|
|
1040
1267
|
expect(find).toHaveBeenCalledTimes(1);
|
|
1041
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1042
|
-
|
|
1268
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1269
|
+
email: "subscriber@example.com",
|
|
1270
|
+
});
|
|
1271
|
+
expect(subscriberData()).toEqual(
|
|
1272
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1273
|
+
);
|
|
1043
1274
|
});
|
|
1044
1275
|
|
|
1045
1276
|
it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
|
|
1046
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1277
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1278
|
+
id: "subscr_x",
|
|
1279
|
+
});
|
|
1047
1280
|
jest
|
|
1048
1281
|
.spyOn(personService, "findOrCreatePerson")
|
|
1049
1282
|
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1050
1283
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1051
1284
|
expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
|
|
1052
|
-
expect(subscriberData()).toEqual(
|
|
1053
|
-
|
|
1285
|
+
expect(subscriberData()).toEqual(
|
|
1286
|
+
expect.objectContaining({ personId: null }),
|
|
1287
|
+
);
|
|
1288
|
+
const skipped = warn.mock.calls.filter(
|
|
1289
|
+
([fields]) => (fields as { event?: string }).event === "person-skipped",
|
|
1290
|
+
);
|
|
1054
1291
|
expect(skipped).toHaveLength(1);
|
|
1055
1292
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1056
1293
|
});
|
|
1057
1294
|
|
|
1058
1295
|
it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
|
|
1059
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1296
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1297
|
+
id: "subscr_x",
|
|
1298
|
+
});
|
|
1060
1299
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1061
1300
|
const linked = await routeLead(subscribeInput, sourceMeta);
|
|
1062
1301
|
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1063
1302
|
|
|
1064
|
-
find.mockResolvedValueOnce({
|
|
1303
|
+
find.mockResolvedValueOnce({
|
|
1304
|
+
ok: false,
|
|
1305
|
+
error: { kind: "internal_error" },
|
|
1306
|
+
});
|
|
1065
1307
|
const failed = await routeLead(subscribeInput, sourceMeta);
|
|
1066
1308
|
expect(failed).toEqual(linked);
|
|
1067
|
-
expect(subscriberData()).toEqual(
|
|
1068
|
-
|
|
1309
|
+
expect(subscriberData()).toEqual(
|
|
1310
|
+
expect.objectContaining({ personId: null }),
|
|
1311
|
+
);
|
|
1312
|
+
expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
|
|
1313
|
+
event: "person-failed",
|
|
1314
|
+
siteId: "site-abc",
|
|
1315
|
+
});
|
|
1069
1316
|
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1070
1317
|
|
|
1071
1318
|
find.mockRejectedValueOnce(new Error("boom"));
|
|
1072
1319
|
const thrown = await routeLead(subscribeInput, sourceMeta);
|
|
1073
1320
|
expect(thrown).toEqual(linked);
|
|
1074
|
-
expect(subscriberData()).toEqual(
|
|
1321
|
+
expect(subscriberData()).toEqual(
|
|
1322
|
+
expect.objectContaining({ personId: null }),
|
|
1323
|
+
);
|
|
1075
1324
|
});
|
|
1076
1325
|
|
|
1077
1326
|
it("a duplicate subscribe (P2002) makes no second Person call", async () => {
|
|
1078
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
1079
|
-
|
|
1327
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
1328
|
+
Object.assign(new Error("dup"), { code: "P2002" }),
|
|
1329
|
+
);
|
|
1330
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
1331
|
+
id: "subscr_existing",
|
|
1332
|
+
});
|
|
1080
1333
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1081
1334
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1082
1335
|
expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
|
|
@@ -1087,7 +1340,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1087
1340
|
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1088
1341
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1089
1342
|
await routeLead(
|
|
1090
|
-
{
|
|
1343
|
+
{
|
|
1344
|
+
...callbackInput,
|
|
1345
|
+
phone: "+1 415 555 0123",
|
|
1346
|
+
payload: { email: "c@x.io" },
|
|
1347
|
+
},
|
|
1091
1348
|
sourceMeta,
|
|
1092
1349
|
);
|
|
1093
1350
|
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
@@ -1095,27 +1352,32 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1095
1352
|
name: "Alice",
|
|
1096
1353
|
contact: { telephone: "+14155550123" },
|
|
1097
1354
|
});
|
|
1098
|
-
expect(callbackData()).toEqual(
|
|
1355
|
+
expect(callbackData()).toEqual(
|
|
1356
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1357
|
+
);
|
|
1099
1358
|
});
|
|
1100
1359
|
|
|
1101
1360
|
it.each([
|
|
1102
1361
|
["no payload.email", { notes: "urgent" }],
|
|
1103
1362
|
["a numeric payload.email", { email: 12345 }],
|
|
1104
|
-
])(
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1363
|
+
])(
|
|
1364
|
+
"a callback with %s makes no person delegate call and writes no personId key",
|
|
1365
|
+
async (_label, payload) => {
|
|
1366
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1367
|
+
id: "callbk_x",
|
|
1368
|
+
});
|
|
1369
|
+
await routeLead({ ...callbackInput, payload }, sourceMeta);
|
|
1370
|
+
expect(personCalls()).toBe(0);
|
|
1371
|
+
expect(Object.keys(callbackData())).not.toContain("personId");
|
|
1372
|
+
},
|
|
1373
|
+
);
|
|
1110
1374
|
|
|
1111
1375
|
// ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
|
|
1112
1376
|
it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
|
|
1113
1377
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1114
|
-
await routeLead(
|
|
1115
|
-
{
|
|
1116
|
-
|
|
1117
|
-
{ contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" } },
|
|
1118
|
-
);
|
|
1378
|
+
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
|
|
1379
|
+
contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
|
|
1380
|
+
});
|
|
1119
1381
|
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1120
1382
|
email: "test@example.com",
|
|
1121
1383
|
name: "Ann",
|
|
@@ -1128,7 +1390,10 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1128
1390
|
it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
|
|
1129
1391
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1130
1392
|
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
|
|
1131
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1393
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1394
|
+
email: "test@example.com",
|
|
1395
|
+
name: "Bob",
|
|
1396
|
+
});
|
|
1132
1397
|
});
|
|
1133
1398
|
|
|
1134
1399
|
it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
|
|
@@ -1141,9 +1406,15 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1141
1406
|
|
|
1142
1407
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1143
1408
|
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1144
|
-
const skipped = warn.mock.calls.filter(
|
|
1409
|
+
const skipped = warn.mock.calls.filter(
|
|
1410
|
+
([fields]) => (fields as { event?: string }).event === "person-skipped",
|
|
1411
|
+
);
|
|
1145
1412
|
expect(skipped).toHaveLength(1);
|
|
1146
|
-
expect(skipped[0]![0]).toEqual({
|
|
1413
|
+
expect(skipped[0]![0]).toEqual({
|
|
1414
|
+
event: "person-skipped",
|
|
1415
|
+
kind: "invalid_email",
|
|
1416
|
+
siteId: "site-abc",
|
|
1417
|
+
});
|
|
1147
1418
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1148
1419
|
});
|
|
1149
1420
|
|
|
@@ -1165,18 +1436,30 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1165
1436
|
const ok = await routeLead(leadInput, sourceMeta);
|
|
1166
1437
|
jest.mocked(prisma.lead.create).mockClear();
|
|
1167
1438
|
mockSendLeadNotification.mockClear();
|
|
1168
|
-
personDelegate()["findFirst"]!.mockRejectedValueOnce(
|
|
1439
|
+
personDelegate()["findFirst"]!.mockRejectedValueOnce(
|
|
1440
|
+
new Error('relation "people" does not exist'),
|
|
1441
|
+
);
|
|
1169
1442
|
|
|
1170
1443
|
const failed = await routeLead(leadInput, sourceMeta);
|
|
1171
1444
|
|
|
1172
1445
|
// The answer is the same as a successful capture, apart from the internal
|
|
1173
1446
|
// `personId` (#8438 D11) the route never puts on a wire.
|
|
1174
1447
|
expect(failed).toEqual({ ...ok, personId: null });
|
|
1175
|
-
expect(failed).toMatchObject({
|
|
1448
|
+
expect(failed).toMatchObject({
|
|
1449
|
+
ok: true,
|
|
1450
|
+
id: LEAD_ID,
|
|
1451
|
+
persisted: "db",
|
|
1452
|
+
email: { ok: true },
|
|
1453
|
+
});
|
|
1176
1454
|
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1177
|
-
const lines = error.mock.calls.filter(
|
|
1455
|
+
const lines = error.mock.calls.filter(
|
|
1456
|
+
([fields]) => (fields as { event?: string }).event === "person-failed",
|
|
1457
|
+
);
|
|
1178
1458
|
expect(lines).toHaveLength(1);
|
|
1179
|
-
expect(lines[0]![0]).toEqual({
|
|
1459
|
+
expect(lines[0]![0]).toEqual({
|
|
1460
|
+
event: "person-failed",
|
|
1461
|
+
siteId: "site-abc",
|
|
1462
|
+
});
|
|
1180
1463
|
expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
|
|
1181
1464
|
});
|
|
1182
1465
|
});
|
|
@@ -1187,7 +1470,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1187
1470
|
// paid checkout lead a registered spoke's server posted with the session id
|
|
1188
1471
|
// as its key. Every other lead makes no plan call; a failing plan step never
|
|
1189
1472
|
// changes the lead's result.
|
|
1190
|
-
|
|
1473
|
+
// #8793 D1 — a marked checkout lead HOLDS its plan: it never writes a
|
|
1474
|
+
// Person's plan, logs `held` (or `skipped` for an offering that is no key),
|
|
1475
|
+
// and the plan is released only at the Person's verified first link
|
|
1476
|
+
// (`person.test.ts`, `checkout-plan.test.ts`).
|
|
1477
|
+
describe("#7877 — the checkout lead holds the plan (AC-5; #8793 D1, AC-7)", () => {
|
|
1191
1478
|
const ENV_KEYS = [
|
|
1192
1479
|
"DATABASE_URL",
|
|
1193
1480
|
"NODE_ENV",
|
|
@@ -1204,7 +1491,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1204
1491
|
siteId: WS,
|
|
1205
1492
|
source: "web",
|
|
1206
1493
|
email: "cleo@x.io",
|
|
1207
|
-
payload: {
|
|
1494
|
+
payload: {
|
|
1495
|
+
site: "wendi",
|
|
1496
|
+
page: "checkout",
|
|
1497
|
+
offering: "wendi_retainer",
|
|
1498
|
+
} as Record<string, unknown>,
|
|
1208
1499
|
};
|
|
1209
1500
|
let setPlan: jest.SpyInstance;
|
|
1210
1501
|
let info: jest.SpyInstance;
|
|
@@ -1220,10 +1511,19 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1220
1511
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1221
1512
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1222
1513
|
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1223
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1514
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1515
|
+
ok: true,
|
|
1516
|
+
messageId: "msg-7877",
|
|
1517
|
+
});
|
|
1518
|
+
setPlan = jest
|
|
1519
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
1520
|
+
.mockResolvedValue("set");
|
|
1521
|
+
info = jest
|
|
1522
|
+
.spyOn(logger, "info")
|
|
1523
|
+
.mockImplementation(() => undefined as never);
|
|
1524
|
+
error = jest
|
|
1525
|
+
.spyOn(logger, "error")
|
|
1526
|
+
.mockImplementation(() => undefined as never);
|
|
1227
1527
|
});
|
|
1228
1528
|
|
|
1229
1529
|
afterEach(() => {
|
|
@@ -1240,31 +1540,61 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1240
1540
|
[...info.mock.calls, ...error.mock.calls]
|
|
1241
1541
|
.map(([fields]) => fields as Record<string, unknown>)
|
|
1242
1542
|
.filter((fields) => fields.event === "checkout-plan");
|
|
1543
|
+
/** Every `checkout-plan` line is logged at `info`, never at `error`. */
|
|
1544
|
+
const infoPlanLines = () =>
|
|
1545
|
+
info.mock.calls
|
|
1546
|
+
.map(([fields]) => fields as Record<string, unknown>)
|
|
1547
|
+
.filter((fields) => fields.event === "checkout-plan");
|
|
1548
|
+
/** The lead's answer, as it was before #8793. */
|
|
1549
|
+
const answered = {
|
|
1550
|
+
ok: true,
|
|
1551
|
+
id: LEAD_ID,
|
|
1552
|
+
personId: MOCK_PERSON_ID,
|
|
1553
|
+
persisted: "db",
|
|
1554
|
+
email: { ok: true, messageId: "msg-7877" },
|
|
1555
|
+
};
|
|
1243
1556
|
|
|
1244
|
-
it("a live spoke checkout lead
|
|
1245
|
-
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
expect(
|
|
1249
|
-
expect(
|
|
1557
|
+
it("a live spoke checkout lead holds the plan: no plan write, one held line", async () => {
|
|
1558
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1559
|
+
idempotencyKey: KEY,
|
|
1560
|
+
});
|
|
1561
|
+
expect(result).toEqual(answered);
|
|
1562
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1563
|
+
expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
|
|
1564
|
+
expect(infoPlanLines()).toEqual([
|
|
1565
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1566
|
+
]);
|
|
1567
|
+
expect(planLines()).toEqual(infoPlanLines());
|
|
1250
1568
|
});
|
|
1251
1569
|
|
|
1252
|
-
it("a bare slug offering is
|
|
1570
|
+
it("a bare slug offering is held as it is", async () => {
|
|
1253
1571
|
await routeLead(
|
|
1254
|
-
{
|
|
1572
|
+
{
|
|
1573
|
+
...checkoutLead,
|
|
1574
|
+
payload: { ...checkoutLead.payload, offering: "retainer" },
|
|
1575
|
+
},
|
|
1255
1576
|
spokeMeta,
|
|
1256
1577
|
{ idempotencyKey: KEY },
|
|
1257
1578
|
);
|
|
1258
|
-
expect(setPlan).
|
|
1579
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1580
|
+
expect(planLines()).toEqual([
|
|
1581
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1582
|
+
]);
|
|
1259
1583
|
});
|
|
1260
1584
|
|
|
1261
1585
|
it("a positive integer amountTotal still qualifies", async () => {
|
|
1262
1586
|
await routeLead(
|
|
1263
|
-
{
|
|
1587
|
+
{
|
|
1588
|
+
...checkoutLead,
|
|
1589
|
+
payload: { ...checkoutLead.payload, amountTotal: 4900 },
|
|
1590
|
+
},
|
|
1264
1591
|
spokeMeta,
|
|
1265
1592
|
{ idempotencyKey: KEY },
|
|
1266
1593
|
);
|
|
1267
|
-
expect(setPlan).
|
|
1594
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1595
|
+
expect(planLines()).toEqual([
|
|
1596
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1597
|
+
]);
|
|
1268
1598
|
});
|
|
1269
1599
|
|
|
1270
1600
|
it.each([
|
|
@@ -1278,16 +1608,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1278
1608
|
],
|
|
1279
1609
|
[
|
|
1280
1610
|
"a negative amountTotal",
|
|
1281
|
-
{
|
|
1611
|
+
{
|
|
1612
|
+
...checkoutLead,
|
|
1613
|
+
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1614
|
+
},
|
|
1282
1615
|
spokeMeta,
|
|
1283
1616
|
KEY,
|
|
1284
1617
|
],
|
|
1285
|
-
[
|
|
1618
|
+
[
|
|
1619
|
+
"a key that is not a session id",
|
|
1620
|
+
checkoutLead,
|
|
1621
|
+
spokeMeta,
|
|
1622
|
+
"abcdefghijklmnop",
|
|
1623
|
+
],
|
|
1286
1624
|
["no key", checkoutLead, spokeMeta, undefined],
|
|
1287
1625
|
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1288
1626
|
[
|
|
1289
1627
|
"page contact",
|
|
1290
|
-
{
|
|
1628
|
+
{
|
|
1629
|
+
...checkoutLead,
|
|
1630
|
+
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1631
|
+
},
|
|
1291
1632
|
spokeMeta,
|
|
1292
1633
|
KEY,
|
|
1293
1634
|
],
|
|
@@ -1313,18 +1654,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1313
1654
|
// checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
|
|
1314
1655
|
// relayed". An unmarked create names no `spokeCheckout` key at all.
|
|
1315
1656
|
const createData = () =>
|
|
1316
|
-
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
|
|
1657
|
+
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
|
|
1658
|
+
string,
|
|
1659
|
+
unknown
|
|
1660
|
+
>;
|
|
1317
1661
|
|
|
1318
|
-
it("a live spoke checkout lead is created with spokeCheckout true and
|
|
1662
|
+
it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
|
|
1319
1663
|
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1320
1664
|
expect(createData()).toMatchObject({ spokeCheckout: true });
|
|
1321
|
-
expect(setPlan).
|
|
1665
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1666
|
+
expect(planLines()).toHaveLength(1);
|
|
1322
1667
|
});
|
|
1323
1668
|
|
|
1324
|
-
it("the same lead relayed for a visitor is created with no spokeCheckout key and
|
|
1325
|
-
await routeLead(
|
|
1669
|
+
it("the same lead relayed for a visitor is created with no spokeCheckout key and holds no plan", async () => {
|
|
1670
|
+
await routeLead(
|
|
1671
|
+
checkoutLead,
|
|
1672
|
+
{ ...spokeMeta, relayed: true },
|
|
1673
|
+
{ idempotencyKey: KEY },
|
|
1674
|
+
);
|
|
1326
1675
|
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1327
|
-
expect(setPlan).
|
|
1676
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1677
|
+
expect(planLines()).toEqual([]);
|
|
1328
1678
|
});
|
|
1329
1679
|
|
|
1330
1680
|
it.each([
|
|
@@ -1338,16 +1688,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1338
1688
|
],
|
|
1339
1689
|
[
|
|
1340
1690
|
"a negative amountTotal",
|
|
1341
|
-
{
|
|
1691
|
+
{
|
|
1692
|
+
...checkoutLead,
|
|
1693
|
+
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1694
|
+
},
|
|
1342
1695
|
spokeMeta,
|
|
1343
1696
|
KEY,
|
|
1344
1697
|
],
|
|
1345
|
-
[
|
|
1698
|
+
[
|
|
1699
|
+
"a key that is not a session id",
|
|
1700
|
+
checkoutLead,
|
|
1701
|
+
spokeMeta,
|
|
1702
|
+
"abcdefghijklmnop",
|
|
1703
|
+
],
|
|
1346
1704
|
["no key", checkoutLead, spokeMeta, undefined],
|
|
1347
1705
|
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1348
1706
|
[
|
|
1349
1707
|
"page contact",
|
|
1350
|
-
{
|
|
1708
|
+
{
|
|
1709
|
+
...checkoutLead,
|
|
1710
|
+
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1711
|
+
},
|
|
1351
1712
|
spokeMeta,
|
|
1352
1713
|
KEY,
|
|
1353
1714
|
],
|
|
@@ -1357,24 +1718,48 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1357
1718
|
spokeMeta,
|
|
1358
1719
|
KEY,
|
|
1359
1720
|
],
|
|
1360
|
-
] as const)(
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1721
|
+
] as const)(
|
|
1722
|
+
"no spokeCheckout key in the create data for %s",
|
|
1723
|
+
async (_label, input, meta, key) => {
|
|
1724
|
+
await routeLead(
|
|
1725
|
+
input as typeof checkoutLead,
|
|
1726
|
+
meta,
|
|
1727
|
+
key === undefined ? {} : { idempotencyKey: key },
|
|
1728
|
+
);
|
|
1729
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1730
|
+
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1731
|
+
},
|
|
1732
|
+
);
|
|
1369
1733
|
|
|
1370
1734
|
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1371
|
-
await routeLead(
|
|
1372
|
-
{
|
|
1735
|
+
const result = await routeLead(
|
|
1736
|
+
{
|
|
1737
|
+
...checkoutLead,
|
|
1738
|
+
payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
|
|
1739
|
+
},
|
|
1373
1740
|
spokeMeta,
|
|
1374
1741
|
{ idempotencyKey: KEY },
|
|
1375
1742
|
);
|
|
1743
|
+
expect(result).toEqual(answered);
|
|
1376
1744
|
expect(setPlan).not.toHaveBeenCalled();
|
|
1377
|
-
expect(
|
|
1745
|
+
expect(infoPlanLines()).toEqual([
|
|
1746
|
+
{ event: "checkout-plan", outcome: "skipped", siteId: WS },
|
|
1747
|
+
]);
|
|
1748
|
+
expect(planLines()).toEqual(infoPlanLines());
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it("no checkout-plan line names an email, an offering or a key", async () => {
|
|
1752
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1753
|
+
expect(planLines()).toHaveLength(1);
|
|
1754
|
+
for (const line of planLines()) {
|
|
1755
|
+
expect(line).not.toHaveProperty("email");
|
|
1756
|
+
expect(line).not.toHaveProperty("offering");
|
|
1757
|
+
expect(line).not.toHaveProperty("key");
|
|
1758
|
+
}
|
|
1759
|
+
const logged = JSON.stringify(planLines());
|
|
1760
|
+
expect(logged).not.toContain("retainer");
|
|
1761
|
+
expect(logged).not.toContain("cleo@x.io");
|
|
1762
|
+
expect(logged).not.toContain(KEY);
|
|
1378
1763
|
});
|
|
1379
1764
|
|
|
1380
1765
|
it("a replayed key writes nothing and sets nothing", async () => {
|
|
@@ -1384,27 +1769,892 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1384
1769
|
source: checkoutLead.source,
|
|
1385
1770
|
payload: checkoutLead.payload,
|
|
1386
1771
|
});
|
|
1387
|
-
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1772
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1773
|
+
idempotencyKey: KEY,
|
|
1774
|
+
});
|
|
1388
1775
|
expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
|
|
1389
1776
|
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1390
1777
|
expect(setPlan).not.toHaveBeenCalled();
|
|
1391
1778
|
});
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
|
|
1782
|
+
const ENV_KEYS = [
|
|
1783
|
+
"DATABASE_URL",
|
|
1784
|
+
"NODE_ENV",
|
|
1785
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
1786
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1787
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
1788
|
+
] as const;
|
|
1789
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1790
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000008757";
|
|
1791
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008757";
|
|
1792
|
+
const KEY = "cs_live_d14Purchase"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8757 AC-11), not a credential
|
|
1793
|
+
const ORIGIN = "https://spoke.example";
|
|
1794
|
+
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
1795
|
+
const checkoutLead = {
|
|
1796
|
+
kind: "submit" as const,
|
|
1797
|
+
siteId: WS,
|
|
1798
|
+
source: "web",
|
|
1799
|
+
email: "Cleo@X.io",
|
|
1800
|
+
payload: {
|
|
1801
|
+
site: "wendi",
|
|
1802
|
+
page: "checkout",
|
|
1803
|
+
offering: "wendi_retainer",
|
|
1804
|
+
amountTotal: 4900,
|
|
1805
|
+
currency: "usd",
|
|
1806
|
+
} as Record<string, unknown>,
|
|
1807
|
+
};
|
|
1808
|
+
// The lead table's count — the per-recipient cap's one read. The shared
|
|
1809
|
+
// prisma double does not carry it, so this suite arranges it.
|
|
1810
|
+
const leadCount = jest.fn();
|
|
1811
|
+
let setPlan: jest.SpyInstance;
|
|
1812
|
+
let info: jest.SpyInstance;
|
|
1813
|
+
let error: jest.SpyInstance;
|
|
1814
|
+
|
|
1815
|
+
/** Let the fired (void) email step run to its end. */
|
|
1816
|
+
const settle = async () => {
|
|
1817
|
+
for (let i = 0; i < 5; i += 1)
|
|
1818
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1819
|
+
};
|
|
1820
|
+
const interactionCreates = () =>
|
|
1821
|
+
jest.mocked(prisma.interaction.create).mock.calls;
|
|
1822
|
+
const emailCalls = () => mockSendPurchaseConfirmationEmail.mock.calls;
|
|
1823
|
+
|
|
1824
|
+
beforeEach(() => {
|
|
1825
|
+
jest.clearAllMocks();
|
|
1826
|
+
saved = {};
|
|
1827
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1828
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1829
|
+
delete process.env.NODE_ENV;
|
|
1830
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1831
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1832
|
+
delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
|
|
1833
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1834
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1835
|
+
Object.assign(prisma.lead, { count: leadCount });
|
|
1836
|
+
leadCount.mockReset().mockResolvedValue(1);
|
|
1837
|
+
jest
|
|
1838
|
+
.mocked(prisma.interaction.create)
|
|
1839
|
+
.mockResolvedValue({ id: "intx_1" } as never);
|
|
1840
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1841
|
+
ok: true,
|
|
1842
|
+
messageId: "msg-8757",
|
|
1843
|
+
});
|
|
1844
|
+
mockSendPurchaseConfirmationEmail.mockResolvedValue({
|
|
1845
|
+
ok: true,
|
|
1846
|
+
messageId: "msg-buy",
|
|
1847
|
+
});
|
|
1848
|
+
mockListPlanOfferings.mockResolvedValue([
|
|
1849
|
+
{ key: "retainer", name: "Retainer", type: "service" },
|
|
1850
|
+
]);
|
|
1851
|
+
setPlan = jest
|
|
1852
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
1853
|
+
.mockResolvedValue("set");
|
|
1854
|
+
info = jest
|
|
1855
|
+
.spyOn(logger, "info")
|
|
1856
|
+
.mockImplementation(() => undefined as never);
|
|
1857
|
+
error = jest
|
|
1858
|
+
.spyOn(logger, "error")
|
|
1859
|
+
.mockImplementation(() => undefined as never);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
afterEach(() => {
|
|
1863
|
+
for (const k of ENV_KEYS) {
|
|
1864
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
1865
|
+
else process.env[k] = saved[k];
|
|
1866
|
+
}
|
|
1867
|
+
Reflect.deleteProperty(prisma.lead, "count");
|
|
1868
|
+
setPlan.mockRestore();
|
|
1869
|
+
info.mockRestore();
|
|
1870
|
+
error.mockRestore();
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
it("the first write sends one purchase email with the spoke's account link and writes one Inbox message", async () => {
|
|
1874
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1875
|
+
idempotencyKey: KEY,
|
|
1876
|
+
accountOrigin: ORIGIN,
|
|
1877
|
+
});
|
|
1878
|
+
await settle();
|
|
1879
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1880
|
+
|
|
1881
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1882
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1883
|
+
expect(email).toMatchObject({
|
|
1884
|
+
to: "Cleo@X.io",
|
|
1885
|
+
fromAddress: "noreply@example.com",
|
|
1886
|
+
subject: "Your purchase",
|
|
1887
|
+
cta: "Open your account",
|
|
1888
|
+
accountLink: `${ORIGIN}/api/hub-session/sign-in?next=%2Faccount&email=Cleo%40X.io`,
|
|
1889
|
+
});
|
|
1890
|
+
expect(email.body).toContain("Retainer");
|
|
1891
|
+
expect(email.body).toContain("$49.00");
|
|
1892
|
+
|
|
1893
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1894
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1895
|
+
{ data: Record<string, unknown> },
|
|
1896
|
+
];
|
|
1897
|
+
expect(data).toEqual({
|
|
1898
|
+
workspaceId: WS,
|
|
1899
|
+
personId: MOCK_PERSON_ID,
|
|
1900
|
+
channel: "member",
|
|
1901
|
+
direction: "outbound",
|
|
1902
|
+
subject: "Your purchase",
|
|
1903
|
+
body: email.body,
|
|
1904
|
+
createdBy: "system:checkout",
|
|
1905
|
+
});
|
|
1906
|
+
expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it("an offering that is not listed is named 'your purchase' in both, never the raw string", async () => {
|
|
1910
|
+
await routeLead(
|
|
1911
|
+
{
|
|
1912
|
+
...checkoutLead,
|
|
1913
|
+
payload: { ...checkoutLead.payload, offering: "wendi_<b>Mystery</b>" },
|
|
1914
|
+
},
|
|
1915
|
+
spokeMeta,
|
|
1916
|
+
{ idempotencyKey: KEY, accountOrigin: ORIGIN },
|
|
1917
|
+
);
|
|
1918
|
+
await settle();
|
|
1919
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1920
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1921
|
+
{ data: Record<string, string> },
|
|
1922
|
+
];
|
|
1923
|
+
for (const body of [email.body, data.body]) {
|
|
1924
|
+
expect(body).toContain("your purchase");
|
|
1925
|
+
expect(body).not.toContain("Mystery");
|
|
1926
|
+
expect(body).not.toContain("wendi_");
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
it.each([
|
|
1931
|
+
[
|
|
1932
|
+
"the lister fails",
|
|
1933
|
+
() => mockListPlanOfferings.mockResolvedValueOnce({ failed: "Error" }),
|
|
1934
|
+
],
|
|
1935
|
+
[
|
|
1936
|
+
"the lister throws",
|
|
1937
|
+
() => mockListPlanOfferings.mockRejectedValueOnce(new Error("down")),
|
|
1938
|
+
],
|
|
1939
|
+
])(
|
|
1940
|
+
"when %s, the offering is 'your purchase' and both still go out",
|
|
1941
|
+
async (_label, arrange) => {
|
|
1942
|
+
arrange();
|
|
1943
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1944
|
+
idempotencyKey: KEY,
|
|
1945
|
+
accountOrigin: ORIGIN,
|
|
1946
|
+
});
|
|
1947
|
+
await settle();
|
|
1948
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1949
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1950
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1951
|
+
{ data: Record<string, string> },
|
|
1952
|
+
];
|
|
1953
|
+
expect(data.body).toContain("your purchase");
|
|
1954
|
+
},
|
|
1955
|
+
);
|
|
1956
|
+
|
|
1957
|
+
it("a listed name holding a bidi override comes out with it stripped", async () => {
|
|
1958
|
+
mockListPlanOfferings.mockResolvedValue([
|
|
1959
|
+
{ key: "retainer", name: "Re\u202Etainer\u0007", type: "service" },
|
|
1960
|
+
]);
|
|
1961
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1962
|
+
idempotencyKey: KEY,
|
|
1963
|
+
accountOrigin: ORIGIN,
|
|
1964
|
+
});
|
|
1965
|
+
await settle();
|
|
1966
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1967
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1968
|
+
{ data: Record<string, string> },
|
|
1969
|
+
];
|
|
1970
|
+
for (const body of [email.body, data.body]) {
|
|
1971
|
+
expect(body).toContain("Retainer");
|
|
1972
|
+
|
|
1973
|
+
// The two code points by name, not in a regex: a scaffold's standalone
|
|
1974
|
+
// lint refuses a control character in a regular expression.
|
|
1975
|
+
expect(body).not.toContain("\u202E");
|
|
1976
|
+
expect(body).not.toContain("\u0007");
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
it("with no amount, the body names the offering alone", async () => {
|
|
1981
|
+
const { amountTotal: _a, currency: _c, ...payload } = checkoutLead.payload;
|
|
1982
|
+
await routeLead({ ...checkoutLead, payload }, spokeMeta, {
|
|
1983
|
+
idempotencyKey: KEY,
|
|
1984
|
+
accountOrigin: ORIGIN,
|
|
1985
|
+
});
|
|
1986
|
+
await settle();
|
|
1987
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1988
|
+
{ data: Record<string, string> },
|
|
1989
|
+
];
|
|
1990
|
+
expect(data.body).toBe("Thanks for your purchase of Retainer.");
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
it("a Stripe special-case currency (ISK) leaves the amount out rather than misstate it", async () => {
|
|
1994
|
+
await routeLead(
|
|
1995
|
+
{
|
|
1996
|
+
...checkoutLead,
|
|
1997
|
+
payload: {
|
|
1998
|
+
...checkoutLead.payload,
|
|
1999
|
+
amountTotal: 990000,
|
|
2000
|
+
currency: "isk",
|
|
2001
|
+
},
|
|
2002
|
+
},
|
|
2003
|
+
spokeMeta,
|
|
2004
|
+
{ idempotencyKey: KEY, accountOrigin: ORIGIN },
|
|
2005
|
+
);
|
|
2006
|
+
await settle();
|
|
2007
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
2008
|
+
{ data: Record<string, string> },
|
|
2009
|
+
];
|
|
2010
|
+
expect(data.body).toBe("Thanks for your purchase of Retainer.");
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
it("an offering read that outlasts its budget names 'your purchase' and does not hold the lead", async () => {
|
|
2014
|
+
mockListPlanOfferings.mockReturnValue(new Promise(() => undefined));
|
|
2015
|
+
const started = Date.now();
|
|
2016
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2017
|
+
idempotencyKey: KEY,
|
|
2018
|
+
accountOrigin: ORIGIN,
|
|
2019
|
+
});
|
|
2020
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
2021
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2022
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
2023
|
+
{ data: Record<string, string> },
|
|
2024
|
+
];
|
|
2025
|
+
expect(data.body).toContain("your purchase");
|
|
2026
|
+
}, 10_000);
|
|
2027
|
+
|
|
2028
|
+
it("with no account origin, the email carries no link", async () => {
|
|
2029
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2030
|
+
await settle();
|
|
2031
|
+
expect(emailCalls()).toHaveLength(1);
|
|
2032
|
+
expect(emailCalls()[0]?.[0]).not.toHaveProperty("accountLink");
|
|
2033
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
it("a replay of the same Idempotency-Key makes neither", async () => {
|
|
2037
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2038
|
+
id: LEAD_ID,
|
|
2039
|
+
email: checkoutLead.email,
|
|
2040
|
+
source: checkoutLead.source,
|
|
2041
|
+
payload: checkoutLead.payload,
|
|
2042
|
+
});
|
|
2043
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2044
|
+
idempotencyKey: KEY,
|
|
2045
|
+
accountOrigin: ORIGIN,
|
|
2046
|
+
});
|
|
2047
|
+
await settle();
|
|
2048
|
+
expect(result).toMatchObject({ ok: true, replayed: true });
|
|
2049
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2050
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
it.each([
|
|
2054
|
+
["a test-mode checkout lead", spokeMeta, "cs_test_d14Purchase"],
|
|
2055
|
+
["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
|
|
2056
|
+
["an anonymous (non-spoke) lead", sourceMeta, KEY],
|
|
2057
|
+
] as const)("%s makes neither", async (_label, meta, key) => {
|
|
2058
|
+
const result = await routeLead(checkoutLead, meta, {
|
|
2059
|
+
idempotencyKey: key,
|
|
2060
|
+
accountOrigin: ORIGIN,
|
|
2061
|
+
});
|
|
2062
|
+
await settle();
|
|
2063
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
2064
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2065
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
it("a lead with no Person makes neither", async () => {
|
|
2069
|
+
const find = jest
|
|
2070
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
2071
|
+
.mockResolvedValue({
|
|
2072
|
+
ok: false,
|
|
2073
|
+
error: { kind: "invalid_email" },
|
|
2074
|
+
} as never);
|
|
2075
|
+
try {
|
|
2076
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2077
|
+
idempotencyKey: KEY,
|
|
2078
|
+
accountOrigin: ORIGIN,
|
|
2079
|
+
});
|
|
2080
|
+
await settle();
|
|
2081
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
2082
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2083
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
2084
|
+
} finally {
|
|
2085
|
+
find.mockRestore();
|
|
2086
|
+
}
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
it("with five marked leads for the address in the last day, the sixth sends no email and still writes the message", async () => {
|
|
2090
|
+
const now = Date.now();
|
|
2091
|
+
leadCount.mockResolvedValue(6); // the five before, and this one
|
|
2092
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2093
|
+
idempotencyKey: KEY,
|
|
2094
|
+
accountOrigin: ORIGIN,
|
|
2095
|
+
});
|
|
2096
|
+
await settle();
|
|
2097
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2098
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
2099
|
+
const [{ where }] = leadCount.mock.calls[0] as [
|
|
2100
|
+
{ where: Record<string, unknown> },
|
|
2101
|
+
];
|
|
2102
|
+
expect(where).toMatchObject({
|
|
2103
|
+
workspaceId: WS,
|
|
2104
|
+
spokeCheckout: true,
|
|
2105
|
+
email: { equals: "Cleo@X.io", mode: "insensitive" },
|
|
2106
|
+
});
|
|
2107
|
+
const since = (where.createdAt as { gte: Date }).gte.getTime();
|
|
2108
|
+
expect(now - since).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000 - 1000);
|
|
2109
|
+
expect(now - since).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000);
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("with more than 50 marked leads in the workspace in the last hour, no email (any address) and the message is still written", async () => {
|
|
2113
|
+
const now = Date.now();
|
|
2114
|
+
// The recipient count first, then the workspace's hourly count.
|
|
2115
|
+
leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(51);
|
|
2116
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2117
|
+
idempotencyKey: KEY,
|
|
2118
|
+
accountOrigin: ORIGIN,
|
|
2119
|
+
});
|
|
2120
|
+
await settle();
|
|
2121
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2122
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
2123
|
+
const [{ where }] = leadCount.mock.calls[1] as [
|
|
2124
|
+
{ where: Record<string, unknown> },
|
|
2125
|
+
];
|
|
2126
|
+
expect(where).toMatchObject({ workspaceId: WS, spokeCheckout: true });
|
|
2127
|
+
expect(where).not.toHaveProperty("email");
|
|
2128
|
+
const since = (where.createdAt as { gte: Date }).gte.getTime();
|
|
2129
|
+
expect(now - since).toBeGreaterThanOrEqual(60 * 60 * 1000 - 1000);
|
|
2130
|
+
expect(now - since).toBeLessThanOrEqual(60 * 60 * 1000 + 5000);
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
it("with exactly 50 in the hour, the email still goes", async () => {
|
|
2134
|
+
leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(50);
|
|
2135
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2136
|
+
idempotencyKey: KEY,
|
|
2137
|
+
accountOrigin: ORIGIN,
|
|
2138
|
+
});
|
|
2139
|
+
await settle();
|
|
2140
|
+
expect(emailCalls()).toHaveLength(1);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
it("with five in the day including this one, the email still goes", async () => {
|
|
2144
|
+
leadCount.mockResolvedValue(5);
|
|
2145
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2146
|
+
idempotencyKey: KEY,
|
|
2147
|
+
accountOrigin: ORIGIN,
|
|
2148
|
+
});
|
|
2149
|
+
await settle();
|
|
2150
|
+
expect(emailCalls()).toHaveLength(1);
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
it.each([
|
|
2154
|
+
[
|
|
2155
|
+
"the Inbox write throws",
|
|
2156
|
+
() =>
|
|
2157
|
+
jest
|
|
2158
|
+
.mocked(prisma.interaction.create)
|
|
2159
|
+
.mockRejectedValueOnce(new Error("down")),
|
|
2160
|
+
],
|
|
2161
|
+
[
|
|
2162
|
+
"the cap read throws",
|
|
2163
|
+
() => leadCount.mockRejectedValueOnce(new Error("down")),
|
|
2164
|
+
],
|
|
2165
|
+
[
|
|
2166
|
+
"the email send rejects",
|
|
2167
|
+
() =>
|
|
2168
|
+
mockSendPurchaseConfirmationEmail.mockRejectedValueOnce(
|
|
2169
|
+
new Error("down"),
|
|
2170
|
+
),
|
|
2171
|
+
],
|
|
2172
|
+
])(
|
|
2173
|
+
"a purchase step that fails (%s) never changes the lead's answer",
|
|
2174
|
+
async (_label, arrange) => {
|
|
2175
|
+
arrange();
|
|
2176
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2177
|
+
idempotencyKey: KEY,
|
|
2178
|
+
accountOrigin: ORIGIN,
|
|
2179
|
+
});
|
|
2180
|
+
await settle();
|
|
2181
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2182
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
2183
|
+
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
2184
|
+
expect(logged).not.toContain("Cleo@X.io");
|
|
2185
|
+
expect(logged).not.toContain("retainer");
|
|
2186
|
+
},
|
|
2187
|
+
);
|
|
2188
|
+
|
|
2189
|
+
it("with no sender configured, no email is sent and the message is still written", async () => {
|
|
2190
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
2191
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2192
|
+
idempotencyKey: KEY,
|
|
2193
|
+
accountOrigin: ORIGIN,
|
|
2194
|
+
});
|
|
2195
|
+
await settle();
|
|
2196
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2197
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
2198
|
+
});
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
describe("#8778 — a spoke checkout's terms grant", () => {
|
|
2202
|
+
const ENV_KEYS = [
|
|
2203
|
+
"DATABASE_URL",
|
|
2204
|
+
"NODE_ENV",
|
|
2205
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
2206
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
2207
|
+
] as const;
|
|
2208
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
2209
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000008778";
|
|
2210
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008778";
|
|
2211
|
+
const KEY = "cs_live_g8778Grant"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8778), not a credential
|
|
2212
|
+
const ACCOUNT_TERMS = consentPurposeById(
|
|
2213
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
2214
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
2215
|
+
)!;
|
|
2216
|
+
const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
|
|
2217
|
+
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
2218
|
+
const checkoutLead = {
|
|
2219
|
+
kind: "submit" as const,
|
|
2220
|
+
siteId: WS,
|
|
2221
|
+
source: "web",
|
|
2222
|
+
email: "Cleo@X.io",
|
|
2223
|
+
payload: {
|
|
2224
|
+
site: "wendi",
|
|
2225
|
+
page: "checkout",
|
|
2226
|
+
offering: "wendi_retainer",
|
|
2227
|
+
amountTotal: 4900,
|
|
2228
|
+
currency: "usd",
|
|
2229
|
+
checkoutSession: KEY,
|
|
2230
|
+
termsAccepted: STAMP,
|
|
2231
|
+
} as Record<string, unknown>,
|
|
2232
|
+
};
|
|
2233
|
+
const leadCount = jest.fn();
|
|
2234
|
+
let double: ConsentLedgerDouble;
|
|
2235
|
+
let setPlan: jest.SpyInstance;
|
|
2236
|
+
let info: jest.SpyInstance;
|
|
2237
|
+
let error: jest.SpyInstance;
|
|
2238
|
+
|
|
2239
|
+
/** The ledger double, plus the per-session replay read it does not model. */
|
|
2240
|
+
function armLedger(): void {
|
|
2241
|
+
double = armConsentLedger(prisma);
|
|
2242
|
+
const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
|
|
2243
|
+
(prisma.$queryRaw as jest.Mock).mockImplementation(
|
|
2244
|
+
(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
2245
|
+
if (strings.join("?").includes(`"source" = 'checkout'`)) {
|
|
2246
|
+
const [workspaceId, personId, purposeId, sessionId] =
|
|
2247
|
+
values as string[];
|
|
2248
|
+
return Promise.resolve(
|
|
2249
|
+
double.ledger
|
|
2250
|
+
.filter(
|
|
2251
|
+
(r) =>
|
|
2252
|
+
r.workspaceId === workspaceId &&
|
|
2253
|
+
r.personId === personId &&
|
|
2254
|
+
r.purposeId === purposeId &&
|
|
2255
|
+
r.source === "checkout" &&
|
|
2256
|
+
r.sourceRef === sessionId,
|
|
2257
|
+
)
|
|
2258
|
+
.map(() => ({ found: 1 })),
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
return ledgerRead(strings, ...values);
|
|
2262
|
+
},
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const consentLines = () =>
|
|
2267
|
+
[...info.mock.calls, ...error.mock.calls]
|
|
2268
|
+
.map(([obj]) => obj as Record<string, unknown>)
|
|
2269
|
+
.filter((obj) => obj?.["event"] === "checkout-consent");
|
|
2270
|
+
|
|
2271
|
+
const settle = async () => {
|
|
2272
|
+
for (let i = 0; i < 5; i += 1)
|
|
2273
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2274
|
+
};
|
|
2275
|
+
|
|
2276
|
+
beforeEach(() => {
|
|
2277
|
+
jest.clearAllMocks();
|
|
2278
|
+
saved = {};
|
|
2279
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
2280
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
2281
|
+
delete process.env.NODE_ENV;
|
|
2282
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
2283
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
2284
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
2285
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
2286
|
+
Object.assign(prisma.lead, { count: leadCount });
|
|
2287
|
+
leadCount.mockReset().mockResolvedValue(1);
|
|
2288
|
+
jest
|
|
2289
|
+
.mocked(prisma.interaction.create)
|
|
2290
|
+
.mockResolvedValue({ id: "intx_1" } as never);
|
|
2291
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
|
|
2292
|
+
mockSendPurchaseConfirmationEmail.mockResolvedValue({
|
|
2293
|
+
ok: true,
|
|
2294
|
+
messageId: "m2",
|
|
2295
|
+
});
|
|
2296
|
+
mockListPlanOfferings.mockResolvedValue([]);
|
|
2297
|
+
setPlan = jest
|
|
2298
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
2299
|
+
.mockResolvedValue("set");
|
|
2300
|
+
info = jest
|
|
2301
|
+
.spyOn(logger, "info")
|
|
2302
|
+
.mockImplementation(() => undefined as never);
|
|
2303
|
+
error = jest
|
|
2304
|
+
.spyOn(logger, "error")
|
|
2305
|
+
.mockImplementation(() => undefined as never);
|
|
2306
|
+
armLedger();
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
afterEach(() => {
|
|
2310
|
+
for (const k of ENV_KEYS) {
|
|
2311
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
2312
|
+
else process.env[k] = saved[k];
|
|
2313
|
+
}
|
|
2314
|
+
Reflect.deleteProperty(prisma.lead, "count");
|
|
2315
|
+
for (const fn of [
|
|
2316
|
+
prisma.$transaction,
|
|
2317
|
+
prisma.$executeRaw,
|
|
2318
|
+
prisma.$queryRaw,
|
|
2319
|
+
prisma.consentGrant.createMany,
|
|
2320
|
+
prisma.consentVisitorLink.createMany,
|
|
2321
|
+
]) {
|
|
2322
|
+
(fn as jest.Mock).mockReset();
|
|
2323
|
+
}
|
|
2324
|
+
setPlan.mockRestore();
|
|
2325
|
+
info.mockRestore();
|
|
2326
|
+
error.mockRestore();
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
it("a lead whose session accepted the current terms writes one dated checkout grant for the buyer's Person", async () => {
|
|
2330
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2331
|
+
idempotencyKey: KEY,
|
|
2332
|
+
});
|
|
2333
|
+
await settle();
|
|
2334
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2335
|
+
|
|
2336
|
+
expect(double.ledger).toHaveLength(1);
|
|
2337
|
+
const [row] = double.ledger;
|
|
2338
|
+
expect(row).toMatchObject({
|
|
2339
|
+
workspaceId: WS,
|
|
2340
|
+
subjectType: "person",
|
|
2341
|
+
subjectId: MOCK_PERSON_ID,
|
|
2342
|
+
personId: MOCK_PERSON_ID,
|
|
2343
|
+
purposeId: ACCOUNT_TERMS_PURPOSE_ID,
|
|
2344
|
+
granted: true,
|
|
2345
|
+
source: "checkout",
|
|
2346
|
+
sourceRef: KEY,
|
|
2347
|
+
policyVersion: ACCOUNT_TERMS.policyVersion,
|
|
2348
|
+
locale: "en",
|
|
2349
|
+
});
|
|
2350
|
+
expect(row!.createdAt).toBeInstanceOf(Date);
|
|
2351
|
+
expect(consentLines()).toEqual([
|
|
2352
|
+
expect.objectContaining({ outcome: "recorded", siteId: WS }),
|
|
2353
|
+
]);
|
|
2354
|
+
// #8793 D1 — the grant is written; the plan is held, never set here.
|
|
2355
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
2356
|
+
});
|
|
1392
2357
|
|
|
1393
2358
|
it.each([
|
|
1394
|
-
["
|
|
1395
|
-
["
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
const
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2359
|
+
["termsAccepted: null (the box was not accepted)", null, "consent_absent"],
|
|
2360
|
+
["no termsAccepted key (an older spoke)", undefined, "consent_absent"],
|
|
2361
|
+
["an older version's stamp", `${ACCOUNT_TERMS_PURPOSE_ID}@1`, "terms_unstamped"],
|
|
2362
|
+
["another purpose's stamp", "wt_platform_terms@1", "terms_unstamped"],
|
|
2363
|
+
["a non-string value", true, "terms_unstamped"],
|
|
2364
|
+
])("a lead with %s writes no grant", async (_label, terms, outcome) => {
|
|
2365
|
+
const { termsAccepted: _t, ...rest } = checkoutLead.payload;
|
|
2366
|
+
const payload = terms === undefined ? rest : { ...rest, termsAccepted: terms };
|
|
2367
|
+
const result = await routeLead({ ...checkoutLead, payload }, spokeMeta, {
|
|
2368
|
+
idempotencyKey: KEY,
|
|
2369
|
+
});
|
|
2370
|
+
await settle();
|
|
2371
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2372
|
+
expect(double.ledger).toHaveLength(0);
|
|
2373
|
+
expect(consentLines()).toEqual([expect.objectContaining({ outcome })]);
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it("a replayed spoke webhook (the same Idempotency-Key and body) writes no second grant", async () => {
|
|
2377
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2378
|
+
expect(double.ledger).toHaveLength(1);
|
|
2379
|
+
|
|
2380
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2381
|
+
id: LEAD_ID,
|
|
2382
|
+
email: checkoutLead.email,
|
|
2383
|
+
source: checkoutLead.source,
|
|
2384
|
+
payload: checkoutLead.payload,
|
|
2385
|
+
});
|
|
2386
|
+
const replay = await routeLead(checkoutLead, spokeMeta, {
|
|
2387
|
+
idempotencyKey: KEY,
|
|
2388
|
+
});
|
|
2389
|
+
await settle();
|
|
2390
|
+
expect(replay).toMatchObject({ ok: true, replayed: true });
|
|
2391
|
+
expect(double.ledger).toHaveLength(1);
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
it("a redelivery whose stored body predates termsAccepted is a replay, not a conflict, and writes no grant", async () => {
|
|
2395
|
+
const { termsAccepted: _t, ...storedPayload } = checkoutLead.payload;
|
|
2396
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2397
|
+
id: LEAD_ID,
|
|
2398
|
+
email: checkoutLead.email,
|
|
2399
|
+
source: checkoutLead.source,
|
|
2400
|
+
payload: storedPayload,
|
|
2401
|
+
});
|
|
2402
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2403
|
+
idempotencyKey: KEY,
|
|
2404
|
+
});
|
|
2405
|
+
await settle();
|
|
2406
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, replayed: true });
|
|
2407
|
+
expect(double.ledger).toHaveLength(0);
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
it("a redelivery that differs in any other payload key is still a conflict", async () => {
|
|
2411
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2412
|
+
id: LEAD_ID,
|
|
2413
|
+
email: checkoutLead.email,
|
|
2414
|
+
source: checkoutLead.source,
|
|
2415
|
+
payload: { ...checkoutLead.payload, amountTotal: 1 },
|
|
2416
|
+
});
|
|
2417
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2418
|
+
idempotencyKey: KEY,
|
|
2419
|
+
});
|
|
2420
|
+
expect(result).toMatchObject({
|
|
2421
|
+
ok: false,
|
|
2422
|
+
error: { kind: "idempotency_conflict" },
|
|
2423
|
+
});
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
it("a second write for a session whose grant the Person already holds writes none (the store's own guard)", async () => {
|
|
2427
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2428
|
+
// The lead row lost (the lookup misses), the ledger kept: the grant is
|
|
2429
|
+
// still written at most once per Person and session.
|
|
2430
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2431
|
+
await settle();
|
|
2432
|
+
expect(double.ledger).toHaveLength(1);
|
|
2433
|
+
expect(consentLines().map((l) => l["outcome"])).toEqual([
|
|
2434
|
+
"recorded",
|
|
2435
|
+
"unchanged",
|
|
2436
|
+
]);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
it.each([
|
|
2440
|
+
["a test-mode checkout lead", spokeMeta, "cs_test_g8778Grant"],
|
|
2441
|
+
["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
|
|
2442
|
+
["an anonymous (non-spoke) lead", sourceMeta, KEY],
|
|
2443
|
+
] as const)("%s writes no grant", async (_label, meta, key) => {
|
|
2444
|
+
const result = await routeLead(checkoutLead, meta, { idempotencyKey: key });
|
|
2445
|
+
await settle();
|
|
2446
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
2447
|
+
expect(double.ledger).toHaveLength(0);
|
|
2448
|
+
expect(consentLines()).toEqual([]);
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
it("a lead with no Person writes no grant", async () => {
|
|
2452
|
+
const find = jest
|
|
2453
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
2454
|
+
.mockResolvedValue({
|
|
2455
|
+
ok: false,
|
|
2456
|
+
error: { kind: "invalid_email" },
|
|
2457
|
+
} as never);
|
|
2458
|
+
try {
|
|
2459
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2460
|
+
await settle();
|
|
2461
|
+
expect(double.ledger).toHaveLength(0);
|
|
2462
|
+
} finally {
|
|
2463
|
+
find.mockRestore();
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
it("a ledger write that fails never changes the lead's answer, and logs no address or Person id", async () => {
|
|
2468
|
+
(prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(
|
|
2469
|
+
new Error("down Cleo@X.io"),
|
|
2470
|
+
);
|
|
2471
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2472
|
+
idempotencyKey: KEY,
|
|
2473
|
+
});
|
|
2474
|
+
await settle();
|
|
2475
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2476
|
+
expect(consentLines()).toEqual([
|
|
2477
|
+
expect.objectContaining({ outcome: "failed", errName: "Error" }),
|
|
2478
|
+
]);
|
|
1405
2479
|
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
1406
|
-
expect(logged).not.toContain("
|
|
1407
|
-
expect(logged).not.toContain(
|
|
1408
|
-
|
|
2480
|
+
expect(logged).not.toContain("Cleo@X.io");
|
|
2481
|
+
expect(logged).not.toContain(MOCK_PERSON_ID);
|
|
2482
|
+
});
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
describe("#8932 D7 — the per-Organization webhook's Offerings checkout lead (AC-5)", () => {
|
|
2486
|
+
const ENV_KEYS = [
|
|
2487
|
+
"DATABASE_URL",
|
|
2488
|
+
"NODE_ENV",
|
|
2489
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
2490
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
2491
|
+
] as const;
|
|
2492
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
2493
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000008932";
|
|
2494
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008932";
|
|
2495
|
+
const OTHER_WS = "wksp_00000000-0000-7000-8000-000000008933";
|
|
2496
|
+
const SESSION = "cs_live_a8932Offerings"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8932 AC-5), not a credential
|
|
2497
|
+
const KEY = `stripe-offerings ${SESSION}`;
|
|
2498
|
+
const ACCOUNT_TERMS = consentPurposeById(
|
|
2499
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
2500
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
2501
|
+
)!;
|
|
2502
|
+
const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
|
|
2503
|
+
/** The input the webhook builds (D6) — the offering is the record's full key. */
|
|
2504
|
+
const offeringsLead = {
|
|
2505
|
+
kind: "submit" as const,
|
|
2506
|
+
siteId: WS,
|
|
2507
|
+
source: "web",
|
|
2508
|
+
email: "buyer@example.com",
|
|
2509
|
+
payload: {
|
|
2510
|
+
page: "checkout",
|
|
2511
|
+
offering: "growth_monthly",
|
|
2512
|
+
site: WS,
|
|
2513
|
+
amountTotal: 4900,
|
|
2514
|
+
currency: "usd",
|
|
2515
|
+
termsAccepted: STAMP,
|
|
2516
|
+
} as Record<string, unknown>,
|
|
2517
|
+
};
|
|
2518
|
+
const webhookMeta = {
|
|
2519
|
+
ip: "203.0.113.9",
|
|
2520
|
+
userAgent: "Stripe/1.0",
|
|
2521
|
+
receivedAt: "2026-10-18T00:00:00.000Z",
|
|
2522
|
+
};
|
|
2523
|
+
const twin = { idempotencyKey: KEY, offeringsCheckout: true as const };
|
|
2524
|
+
const leadCount = jest.fn();
|
|
2525
|
+
let setPlan: jest.SpyInstance;
|
|
2526
|
+
let consent: jest.SpyInstance;
|
|
2527
|
+
let info: jest.SpyInstance;
|
|
2528
|
+
let error: jest.SpyInstance;
|
|
2529
|
+
|
|
2530
|
+
beforeEach(() => {
|
|
2531
|
+
jest.clearAllMocks();
|
|
2532
|
+
saved = {};
|
|
2533
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
2534
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
2535
|
+
delete process.env.NODE_ENV;
|
|
2536
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
2537
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
2538
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
2539
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
2540
|
+
Object.assign(prisma.lead, { count: leadCount });
|
|
2541
|
+
leadCount.mockReset().mockResolvedValue(1);
|
|
2542
|
+
jest
|
|
2543
|
+
.mocked(prisma.interaction.create)
|
|
2544
|
+
.mockResolvedValue({ id: "intx_1" } as never);
|
|
2545
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
|
|
2546
|
+
mockSendPurchaseConfirmationEmail.mockResolvedValue({ ok: true, messageId: "m2" });
|
|
2547
|
+
mockListPlanOfferings.mockResolvedValue([]);
|
|
2548
|
+
setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
|
|
2549
|
+
consent = jest
|
|
2550
|
+
.spyOn(consentService, "recordCheckoutConsent")
|
|
2551
|
+
.mockResolvedValue("recorded");
|
|
2552
|
+
info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
|
|
2553
|
+
error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
afterEach(() => {
|
|
2557
|
+
for (const k of ENV_KEYS) {
|
|
2558
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
2559
|
+
else process.env[k] = saved[k];
|
|
2560
|
+
}
|
|
2561
|
+
Reflect.deleteProperty(prisma.lead, "count");
|
|
2562
|
+
setPlan.mockRestore();
|
|
2563
|
+
consent.mockRestore();
|
|
2564
|
+
info.mockRestore();
|
|
2565
|
+
error.mockRestore();
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
const createData = () =>
|
|
2569
|
+
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
|
|
2570
|
+
const planLines = () =>
|
|
2571
|
+
[...info.mock.calls, ...error.mock.calls]
|
|
2572
|
+
.map(([fields]) => fields as Record<string, unknown>)
|
|
2573
|
+
.filter((fields) => fields?.["event"] === "checkout-plan");
|
|
2574
|
+
const planWrites = () =>
|
|
2575
|
+
jest
|
|
2576
|
+
.mocked(prisma.person.updateMany)
|
|
2577
|
+
.mock.calls.filter(([args]) => "planOffering" in ((args as { data?: object }).data ?? {}));
|
|
2578
|
+
|
|
2579
|
+
it("creates one marked lead on the prefixed key, holds the full key and grants consent on the bare session id", async () => {
|
|
2580
|
+
const result = await routeLead(offeringsLead, webhookMeta, twin);
|
|
2581
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2582
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
2583
|
+
expect(createData()).toMatchObject({
|
|
2584
|
+
spokeCheckout: true,
|
|
2585
|
+
idempotencyKey: KEY,
|
|
2586
|
+
payload: expect.objectContaining({ offering: "growth_monthly" }),
|
|
2587
|
+
});
|
|
2588
|
+
expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "held", siteId: WS }]);
|
|
2589
|
+
expect(consent).toHaveBeenCalledTimes(1);
|
|
2590
|
+
expect(consent).toHaveBeenCalledWith(
|
|
2591
|
+
expect.objectContaining({ workspaceId: WS, sessionId: SESSION }),
|
|
2592
|
+
);
|
|
2593
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
2594
|
+
expect(planWrites()).toEqual([]);
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
it.each([
|
|
2598
|
+
["without offeringsCheckout", offeringsLead, { idempotencyKey: KEY }],
|
|
2599
|
+
[
|
|
2600
|
+
"an offering the grammar refuses",
|
|
2601
|
+
{ ...offeringsLead, payload: { ...offeringsLead.payload, offering: "growth_quarterly" } },
|
|
2602
|
+
twin,
|
|
2603
|
+
],
|
|
2604
|
+
[
|
|
2605
|
+
"a site that is another workspace",
|
|
2606
|
+
{ ...offeringsLead, payload: { ...offeringsLead.payload, site: OTHER_WS } },
|
|
2607
|
+
twin,
|
|
2608
|
+
],
|
|
2609
|
+
["a test-mode session key", offeringsLead, { ...twin, idempotencyKey: "stripe-offerings cs_test_a1" }],
|
|
2610
|
+
["the bare session id as the key", offeringsLead, { ...twin, idempotencyKey: SESSION }],
|
|
2611
|
+
["source contact", { ...offeringsLead, source: "contact" }, twin],
|
|
2612
|
+
[
|
|
2613
|
+
"page contact",
|
|
2614
|
+
{ ...offeringsLead, payload: { ...offeringsLead.payload, page: "contact" } },
|
|
2615
|
+
twin,
|
|
2616
|
+
],
|
|
2617
|
+
] as const)("creates an unmarked lead and holds nothing for %s", async (_label, input, options) => {
|
|
2618
|
+
const result = await routeLead(input as typeof offeringsLead, webhookMeta, options);
|
|
2619
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
2620
|
+
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
2621
|
+
expect(planLines()).toEqual([]);
|
|
2622
|
+
expect(consent).not.toHaveBeenCalled();
|
|
2623
|
+
expect(prisma.interaction.create).not.toHaveBeenCalled();
|
|
2624
|
+
expect(planWrites()).toEqual([]);
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
it("leaves a spoke checkout lead marked exactly as before", async () => {
|
|
2628
|
+
await routeLead(
|
|
2629
|
+
{
|
|
2630
|
+
kind: "submit",
|
|
2631
|
+
siteId: WS,
|
|
2632
|
+
source: "web",
|
|
2633
|
+
email: "cleo@x.io",
|
|
2634
|
+
payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
|
|
2635
|
+
},
|
|
2636
|
+
{ ...webhookMeta, viaSpoke: true },
|
|
2637
|
+
{ idempotencyKey: "cs_live_a1B2c3" }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
|
|
2638
|
+
);
|
|
2639
|
+
expect(createData()).toMatchObject({ spokeCheckout: true });
|
|
2640
|
+
expect(consent).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }));
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
it("the verified first link releases the full lookup key into the Person's plan", async () => {
|
|
2644
|
+
setPlan.mockRestore();
|
|
2645
|
+
jest.mocked(prisma.lead.findMany).mockResolvedValue([
|
|
2646
|
+
{ email: offeringsLead.email, payload: offeringsLead.payload },
|
|
2647
|
+
] as never);
|
|
2648
|
+
jest.mocked(prisma.person.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
2649
|
+
const outcome = await personService.releaseHeldCheckoutPlan(
|
|
2650
|
+
WS,
|
|
2651
|
+
MOCK_PERSON_ID,
|
|
2652
|
+
"usr_buyer_8932",
|
|
2653
|
+
"buyer@example.com",
|
|
2654
|
+
);
|
|
2655
|
+
expect(outcome).toBe("set");
|
|
2656
|
+
expect(prisma.person.updateMany).toHaveBeenCalledWith(
|
|
2657
|
+
expect.objectContaining({ data: { planOffering: "growth_monthly" } }),
|
|
2658
|
+
);
|
|
1409
2659
|
});
|
|
1410
2660
|
});
|