create-ailk 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -22
- package/component-catalog.json +3 -3
- package/component-catalog.md +3 -3
- package/dist/add-module.js +4 -2
- package/dist/add-route.d.ts +12 -1
- package/dist/add-route.js +31 -3
- package/dist/cli.js +12 -7
- package/dist/derive-architecture.d.ts +3 -4
- package/dist/derive-architecture.js +65 -29
- package/dist/lib/design-checks.d.ts +9 -4
- package/dist/lib/design-checks.js +13 -7
- package/dist/lib/extract-module-bundle.js +29 -2
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +8 -3
- package/dist/lib/import-graph.d.ts +1 -1
- package/dist/lib/import-graph.js +4 -4
- package/dist/lib/module-license-gate.d.ts +12 -1
- package/dist/lib/module-license-gate.js +36 -15
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.d.ts +20 -3
- package/dist/module-architecture.js +129 -552
- package/dist/paid-sections.d.ts +25 -1
- package/dist/paid-sections.js +88 -3
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +39 -2
- package/dist/template-record.d.ts +21 -3
- package/dist/template-record.js +38 -7
- package/dist/topology.d.ts +16 -3
- package/dist/topology.js +54 -23
- package/dist/update.d.ts +10 -1
- package/dist/update.js +715 -21
- package/package.json +3 -3
- package/release-scope.json +2 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/template-guard.sh +137 -0
- package/templates/.claude/rules/extending.md +106 -0
- package/templates/.claude/rules/loop-contract.md +2 -2
- package/templates/.claude/settings.json +11 -0
- package/templates/.env.example +20 -5
- package/templates/.github/workflows/ci.yml +7 -4
- package/templates/CLAUDE.md +11 -1
- package/templates/CONVENTIONS.md +8 -10
- package/templates/README.md +14 -13
- package/templates/apps/api/.env.example +23 -2
- package/templates/apps/api/CLAUDE.md +119 -83
- package/templates/apps/api/jest.config.cjs +15 -5
- package/templates/apps/api/package.json +16 -20
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
- package/templates/apps/api/src/index.ts +1 -1
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
- package/templates/apps/api/src/modules/commerce.ts +10 -0
- package/templates/apps/api/src/modules/content-write.ts +10 -0
- package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
- package/templates/apps/api/src/modules/scheduling.ts +10 -0
- package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
- package/templates/apps/api/src/modules/workspace.ts +10 -0
- package/templates/apps/api/src/server.ts +117 -1882
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/CLAUDE.md +90 -26
- package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
- package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
- package/templates/apps/mcp/jest.config.cjs +6 -1
- package/templates/apps/mcp/package.json +5 -13
- package/templates/apps/mcp/src/app.ts +8 -7
- package/templates/apps/mcp/src/index.ts +6 -34
- package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
- package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
- package/templates/apps/mcp/src/site.ts +12 -0
- package/templates/apps/web/.env.example +1 -1
- package/templates/apps/web/CLAUDE.md +31 -3
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
- package/templates/apps/web/app/[locale]/layout.tsx +33 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
- package/templates/apps/web/app/api/revalidate/route.ts +3 -3
- package/templates/apps/web/auth.ts +10 -6
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/auth-email.ts +103 -21
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +2 -2
- package/templates/apps/web/lib/release-scope.ts +47 -0
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
- package/templates/customizations.yaml +37 -0
- package/templates/database/prisma.config.ts +10 -14
- package/templates/database/seed-test.ts +2 -2
- package/templates/database/seed.ts +5 -5
- package/templates/package.json +12 -6
- package/templates/pnpm-workspace.yaml +0 -1
- package/templates/project.yaml +16 -8
- package/templates/tools/jest/reporters.cjs +65 -0
- package/templates/tools/lint-architecture.ts +553 -7
- package/templates/tsconfig.json +1 -2
- package/versions.json +49 -36
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
- package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
- package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
- package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
- package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
- package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
- package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
- package/templates/apps/api/src/bin/spokes.ts +0 -198
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
- package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
- package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
- package/templates/apps/api/src/config/index.ts +0 -596
- package/templates/apps/api/src/config/modules.ts +0 -83
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
- package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
- package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
- package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
- package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
- package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
- package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
- package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
- package/templates/apps/api/src/lib/agent-store.ts +0 -613
- package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
- package/templates/apps/api/src/lib/consent-store.ts +0 -363
- package/templates/apps/api/src/lib/content-adapter.ts +0 -112
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
- package/templates/apps/api/src/lib/error-serializer.ts +0 -164
- package/templates/apps/api/src/lib/errors.ts +0 -95
- package/templates/apps/api/src/lib/file-fallback.ts +0 -87
- package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
- package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
- package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
- package/templates/apps/api/src/lib/license-key-store.ts +0 -45
- package/templates/apps/api/src/lib/locales.ts +0 -201
- package/templates/apps/api/src/lib/outbound-http.ts +0 -127
- package/templates/apps/api/src/lib/prisma.ts +0 -21
- package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
- package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
- package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
- package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
- package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
- package/templates/apps/api/src/lib/site.ts +0 -257
- package/templates/apps/api/src/lib/slice-load.ts +0 -90
- package/templates/apps/api/src/lib/spoke-store.ts +0 -180
- package/templates/apps/api/src/lib/stripe.ts +0 -321
- package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
- package/templates/apps/api/src/middleware/auth.ts +0 -864
- package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
- package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
- package/templates/apps/api/src/openapi/index.ts +0 -18
- package/templates/apps/api/src/openapi/spec.ts +0 -2148
- package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
- package/templates/apps/api/src/routes/aeo/index.ts +0 -107
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
- package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
- package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
- package/templates/apps/api/src/routes/auth/user.ts +0 -305
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
- package/templates/apps/api/src/routes/checkout/index.ts +0 -34
- package/templates/apps/api/src/routes/checkout/session.ts +0 -171
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
- package/templates/apps/api/src/routes/consent/account.ts +0 -268
- package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
- package/templates/apps/api/src/routes/consent/index.ts +0 -26
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
- package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
- package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
- package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
- package/templates/apps/api/src/routes/content/get-page.ts +0 -183
- package/templates/apps/api/src/routes/content/index.ts +0 -49
- package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
- package/templates/apps/api/src/routes/content/list-types.ts +0 -19
- package/templates/apps/api/src/routes/content/offerings.ts +0 -184
- package/templates/apps/api/src/routes/content/pricing.ts +0 -176
- package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
- package/templates/apps/api/src/routes/content/search.ts +0 -100
- package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
- package/templates/apps/api/src/routes/leads/index.ts +0 -639
- package/templates/apps/api/src/routes/leads/list.ts +0 -104
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
- package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
- package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
- package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
- package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
- package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
- package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
- package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
- package/templates/apps/api/src/routes/requests/index.ts +0 -54
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
- package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
- package/templates/apps/api/src/routes/webhooks/README.md +0 -398
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
- package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
- package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
- package/templates/apps/api/src/services/abandon-email.ts +0 -336
- package/templates/apps/api/src/services/aeo-score.ts +0 -325
- package/templates/apps/api/src/services/answer-distributions.ts +0 -138
- package/templates/apps/api/src/services/audit-log.ts +0 -961
- package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
- package/templates/apps/api/src/services/consent.ts +0 -834
- package/templates/apps/api/src/services/default-tenant.ts +0 -416
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
- package/templates/apps/api/src/services/dunning-policy.ts +0 -294
- package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
- package/templates/apps/api/src/services/entitlements.ts +0 -338
- package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
- package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
- package/templates/apps/api/src/services/lead-promotion.ts +0 -160
- package/templates/apps/api/src/services/lead-routing.ts +0 -1372
- package/templates/apps/api/src/services/leads-inbox.ts +0 -312
- package/templates/apps/api/src/services/listing-config.ts +0 -178
- package/templates/apps/api/src/services/magnet-completion.ts +0 -242
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
- package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
- package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
- package/templates/apps/api/src/services/offerings/index.ts +0 -614
- package/templates/apps/api/src/services/offerings/source.ts +0 -140
- package/templates/apps/api/src/services/offerings/store.ts +0 -150
- package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
- package/templates/apps/api/src/services/person.ts +0 -1211
- package/templates/apps/api/src/services/plan-offerings.ts +0 -125
- package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
- package/templates/apps/api/src/services/project-listings.ts +0 -2085
- package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
- package/templates/apps/api/src/services/request-confirm.ts +0 -698
- package/templates/apps/api/src/services/revalidation.ts +0 -235
- package/templates/apps/api/src/services/slack-notify.ts +0 -291
- package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
- package/templates/apps/api/src/services/usage-credits.ts +0 -340
- package/templates/apps/api/src/services/verified-email.ts +0 -71
- package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
- package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
- package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
- package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
- package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
- package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
- package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
- package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
- package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
- package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
- package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
- package/templates/apps/mcp/__tests__/index.test.ts +0 -195
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
- package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
- package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
- package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
- package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
- package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
- package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
- package/templates/apps/mcp/__tests__/server.test.ts +0 -388
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
- package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
- package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
- package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
- package/templates/apps/mcp/src/abuse/index.ts +0 -349
- package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
- package/templates/apps/mcp/src/boot.ts +0 -167
- package/templates/apps/mcp/src/config/modules.ts +0 -43
- package/templates/apps/mcp/src/http-transport.ts +0 -564
- package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
- package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
- package/templates/apps/mcp/src/lib/errors.ts +0 -61
- package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
- package/templates/apps/mcp/src/lib/locales.ts +0 -171
- package/templates/apps/mcp/src/lib/prisma.ts +0 -39
- package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
- package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
- package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
- package/templates/apps/mcp/src/lib/site-config.ts +0 -53
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
- package/templates/apps/mcp/src/request-context.ts +0 -52
- package/templates/apps/mcp/src/server.ts +0 -343
- package/templates/apps/mcp/src/serverless-app.ts +0 -88
- package/templates/apps/mcp/src/tools/audit.ts +0 -127
- package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
- package/templates/apps/mcp/src/tools/create_project.ts +0 -134
- package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
- package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
- package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
- package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
- package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
- package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
- package/templates/apps/mcp/src/tools/index.ts +0 -138
- package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
- package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
- package/templates/apps/mcp/src/tools/proposals.ts +0 -27
- package/templates/apps/mcp/src/tools/purchase.ts +0 -205
- package/templates/apps/mcp/src/tools/read_content.ts +0 -81
- package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
- package/templates/apps/mcp/src/tools/search_content.ts +0 -63
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
- package/templates/apps/mcp/src/workspace-server.ts +0 -224
- package/templates/database/CHANGELOG.md +0 -981
- package/templates/database/LICENSE +0 -202
- package/templates/database/NOTICE +0 -2
- package/templates/database/README.md +0 -243
- package/templates/database/__tests__/agents.db.test.ts +0 -320
- package/templates/database/__tests__/appointment.db.test.ts +0 -106
- package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
- package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
- package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
- package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
- package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
- package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
- package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
- package/templates/database/__tests__/flows-presets.test.ts +0 -173
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
- package/templates/database/__tests__/member-area.db.test.ts +0 -257
- package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
- package/templates/database/__tests__/member-contract.db.test.ts +0 -413
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
- package/templates/database/__tests__/offerings-migration.test.ts +0 -197
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
- package/templates/database/__tests__/offerings.db.test.ts +0 -303
- package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
- package/templates/database/__tests__/person-contact.db.test.ts +0 -563
- package/templates/database/__tests__/person-notes.db.test.ts +0 -249
- package/templates/database/__tests__/person.db.test.ts +0 -586
- package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
- package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
- package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
- package/templates/database/__tests__/seed.test.ts +0 -272
- package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
- package/templates/database/__tests__/tenancy.test.ts +0 -141
- package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
- package/templates/database/__tests__/user-cascade.test.ts +0 -140
- package/templates/database/_gitignore +0 -8
- package/templates/database/auth/.gitkeep +0 -0
- package/templates/database/auth/schema.prisma +0 -641
- package/templates/database/config/.gitkeep +0 -0
- package/templates/database/config/schema.prisma +0 -441
- package/templates/database/content/.gitkeep +0 -0
- package/templates/database/content/schema.prisma +0 -339
- package/templates/database/eslint.config.mjs +0 -69
- package/templates/database/flows/listing-diagnostic.ts +0 -182
- package/templates/database/flows/presets.ts +0 -315
- package/templates/database/inbox/.gitkeep +0 -0
- package/templates/database/inbox/README.md +0 -3
- package/templates/database/inbox/__tests__/schema.test.ts +0 -44
- package/templates/database/inbox/schema.prisma +0 -1447
- package/templates/database/jest.config.cjs +0 -36
- package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
- package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
- package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
- package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
- package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
- package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
- package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
- package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
- package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
- package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
- package/templates/database/migrations/migration_lock.toml +0 -3
- package/templates/database/ops/.gitkeep +0 -0
- package/templates/database/ops/schema.prisma +0 -43
- package/templates/database/package.json +0 -89
- package/templates/database/schema.prisma +0 -71
- package/templates/database/scripts/db-generate-locked.sh +0 -80
- package/templates/database/scripts/person-backfill.sql +0 -132
- package/templates/database/tenancy.ts +0 -183
- package/templates/database/tsconfig.build.json +0 -16
- package/templates/database/tsconfig.flows.json +0 -15
- package/templates/database/tsconfig.json +0 -16
- package/templates/database/tsconfig.tenancy.json +0 -14
|
@@ -1,2660 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file lead-routing.test.ts
|
|
3
|
-
* @description Unit tests for the lead-routing service (ADR 0009 D3 service-layer pattern).
|
|
4
|
-
*
|
|
5
|
-
* External dependency boundaries mocked:
|
|
6
|
-
* - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
|
|
7
|
-
* - @working-theory/email sendLeadNotification
|
|
8
|
-
*
|
|
9
|
-
* Internal modules run real:
|
|
10
|
-
* - apps/api/src/services/enrichment-shim — real; @working-theory/license catches + returns lead unchanged
|
|
11
|
-
* - apps/api/src/lib/file-fallback — real; uses AILK_INBOX_FALLBACK_DIR temp dir set per test
|
|
12
|
-
* - apps/api/src/lib/prisma — uses __mocks__/prisma.ts (manual mock via jest.mock without factory)
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { RouteLeadResult } from "../lead-routing";
|
|
16
|
-
|
|
17
|
-
// ─── Mock: Prisma ─────────────────────────────────────────────────────────────
|
|
18
|
-
// lib/prisma.ts is a thin wrapper that instantiates PrismaClient (external DB
|
|
19
|
-
// boundary). The manual mock at src/lib/__mocks__/prisma.ts provides a full
|
|
20
|
-
// in-memory stub — this call auto-resolves to that file.
|
|
21
|
-
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
22
|
-
jest.mock("../../lib/prisma.js");
|
|
23
|
-
|
|
24
|
-
// ─── Mock: email (external boundary) ─────────────────────────────────────────
|
|
25
|
-
const mockSendLeadNotification = jest.fn();
|
|
26
|
-
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
27
|
-
jest.mock("@working-theory/email", () => ({
|
|
28
|
-
sendLeadNotification: (...args: unknown[]) =>
|
|
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),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
// ─── Import after mocks ───────────────────────────────────────────────────────
|
|
42
|
-
import * as fs from "fs/promises";
|
|
43
|
-
import * as os from "os";
|
|
44
|
-
import * as path from "path";
|
|
45
|
-
|
|
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";
|
|
53
|
-
|
|
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";
|
|
59
|
-
import { prisma } from "../../lib/prisma.js";
|
|
60
|
-
import { routeLead } from "../lead-routing";
|
|
61
|
-
import * as consentService from "../consent";
|
|
62
|
-
import * as personService from "../person";
|
|
63
|
-
|
|
64
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
65
|
-
const sourceMeta = {
|
|
66
|
-
ip: "127.0.0.1",
|
|
67
|
-
userAgent: "jest",
|
|
68
|
-
receivedAt: new Date().toISOString(),
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const leadInput = {
|
|
72
|
-
kind: "submit" as const,
|
|
73
|
-
siteId: "site-abc",
|
|
74
|
-
source: "contact",
|
|
75
|
-
email: "test@example.com",
|
|
76
|
-
payload: { message: "hello" },
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const subscribeInput = {
|
|
80
|
-
kind: "subscribe" as const,
|
|
81
|
-
siteId: "site-abc",
|
|
82
|
-
email: "subscriber@example.com",
|
|
83
|
-
source: "footer",
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const callbackInput = {
|
|
87
|
-
kind: "callback" as const,
|
|
88
|
-
siteId: "site-abc",
|
|
89
|
-
name: "Alice",
|
|
90
|
-
phone: "+15551234567",
|
|
91
|
-
payload: { notes: "urgent" },
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
95
|
-
describe("routeLead", () => {
|
|
96
|
-
let tmpDir: string;
|
|
97
|
-
|
|
98
|
-
beforeAll(async () => {
|
|
99
|
-
// Real file-fallback module uses AILK_INBOX_FALLBACK_DIR for writes.
|
|
100
|
-
// Point it at a temp dir so tests remain hermetic.
|
|
101
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-lead-test-"));
|
|
102
|
-
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
afterAll(async () => {
|
|
106
|
-
delete process.env.AILK_INBOX_FALLBACK_DIR;
|
|
107
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
beforeEach(() => {
|
|
111
|
-
jest.clearAllMocks();
|
|
112
|
-
// Default: DB up, email up
|
|
113
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
114
|
-
id: "lead_00000000-0000-7000-8000-0000000000c1",
|
|
115
|
-
siteId: "site-abc",
|
|
116
|
-
source: "contact",
|
|
117
|
-
email: "test@example.com",
|
|
118
|
-
payload: {},
|
|
119
|
-
createdAt: new Date(),
|
|
120
|
-
});
|
|
121
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
122
|
-
id: "subscr_00000000-0000-7000-8000-0000000000c2",
|
|
123
|
-
siteId: "site-abc",
|
|
124
|
-
email: "subscriber@example.com",
|
|
125
|
-
source: "footer",
|
|
126
|
-
createdAt: new Date(),
|
|
127
|
-
});
|
|
128
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
129
|
-
id: "callbk_00000000-0000-7000-8000-0000000000c3",
|
|
130
|
-
siteId: "site-abc",
|
|
131
|
-
name: "Alice",
|
|
132
|
-
phone: "+15551234567",
|
|
133
|
-
payload: {},
|
|
134
|
-
createdAt: new Date(),
|
|
135
|
-
});
|
|
136
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
137
|
-
ok: true,
|
|
138
|
-
messageId: "msg-123",
|
|
139
|
-
});
|
|
140
|
-
// Set required env
|
|
141
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
142
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
afterEach(() => {
|
|
146
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
147
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// ── 1. Happy path lead — DB up, email up ──────────────────────────────────
|
|
151
|
-
it("happy path lead — returns ok:true with persisted:db and email:ok", async () => {
|
|
152
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
153
|
-
|
|
154
|
-
expect(result).toMatchObject({
|
|
155
|
-
ok: true,
|
|
156
|
-
id: "lead_00000000-0000-7000-8000-0000000000c1",
|
|
157
|
-
persisted: "db",
|
|
158
|
-
email: { ok: true, messageId: "msg-123" },
|
|
159
|
-
});
|
|
160
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// ── 2. DB down → fallback engages → persisted:file ───────────────────────
|
|
164
|
-
it("DB down → file fallback → persisted:file", async () => {
|
|
165
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
166
|
-
new Error("connection refused"),
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
170
|
-
RouteLeadResult,
|
|
171
|
-
{ ok: true }
|
|
172
|
-
>;
|
|
173
|
-
|
|
174
|
-
expect(result.ok).toBe(true);
|
|
175
|
-
expect(result.persisted).toBe("file");
|
|
176
|
-
// Verify a file was actually written to the temp dir
|
|
177
|
-
const files = await fs.readdir(tmpDir);
|
|
178
|
-
expect(files.length).toBeGreaterThan(0);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// ── 3. DB down + fallback fails → persist_failed ─────────────────────────
|
|
182
|
-
it("DB down + fallback fails → ok:false persist_failed", async () => {
|
|
183
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db error"));
|
|
184
|
-
// Point fallback at a nonexistent dir so mkdir fails
|
|
185
|
-
process.env.AILK_INBOX_FALLBACK_DIR = "/nonexistent-ailk-test-dir/inbox";
|
|
186
|
-
|
|
187
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
188
|
-
RouteLeadResult,
|
|
189
|
-
{ ok: false }
|
|
190
|
-
>;
|
|
191
|
-
|
|
192
|
-
// Restore good temp dir for other tests
|
|
193
|
-
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
194
|
-
|
|
195
|
-
expect(result.ok).toBe(false);
|
|
196
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ── 4. Email down → persist succeeds, email error returned ───────────────
|
|
200
|
-
it("email failure does NOT roll back DB write — returns ok:true with email error", async () => {
|
|
201
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
202
|
-
ok: false,
|
|
203
|
-
error: { kind: "send_failed", message: "timeout" },
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
207
|
-
RouteLeadResult,
|
|
208
|
-
{ ok: true }
|
|
209
|
-
>;
|
|
210
|
-
|
|
211
|
-
expect(result.ok).toBe(true);
|
|
212
|
-
expect(result.persisted).toBe("db");
|
|
213
|
-
expect(result.email).toMatchObject({
|
|
214
|
-
ok: false,
|
|
215
|
-
error: "send_failed: timeout",
|
|
216
|
-
});
|
|
217
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1); // DB write happened
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
// ── 5. Resend env unset → email skipped ──────────────────────────────────
|
|
221
|
-
it("AILK_LEAD_NOTIFICATION_TO unset → email skipped with reason env_unset", async () => {
|
|
222
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
223
|
-
|
|
224
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
225
|
-
RouteLeadResult,
|
|
226
|
-
{ ok: true }
|
|
227
|
-
>;
|
|
228
|
-
|
|
229
|
-
expect(result.ok).toBe(true);
|
|
230
|
-
expect(result.email).toMatchObject({ skipped: true, reason: "env_unset" });
|
|
231
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("config_missing from Resend → email coerced to skipped:resend_key_unset", async () => {
|
|
235
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
236
|
-
ok: false,
|
|
237
|
-
error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
241
|
-
RouteLeadResult,
|
|
242
|
-
{ ok: true }
|
|
243
|
-
>;
|
|
244
|
-
|
|
245
|
-
expect(result.ok).toBe(true);
|
|
246
|
-
expect(result.email).toMatchObject({
|
|
247
|
-
skipped: true,
|
|
248
|
-
reason: "resend_key_unset",
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
// ── 6. Subscribe duplicate (P2002) → sentinel id, email skipped:duplicate ─
|
|
253
|
-
it("subscribe duplicate P2002 → ok:true with email.skipped.reason duplicate", async () => {
|
|
254
|
-
const p2002 = Object.assign(new Error("Unique constraint"), {
|
|
255
|
-
code: "P2002",
|
|
256
|
-
});
|
|
257
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(p2002);
|
|
258
|
-
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
259
|
-
id: "existing-sub-id",
|
|
260
|
-
siteId: "site-abc",
|
|
261
|
-
email: "subscriber@example.com",
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
const result = (await routeLead(subscribeInput, sourceMeta)) as Extract<
|
|
265
|
-
RouteLeadResult,
|
|
266
|
-
{ ok: true }
|
|
267
|
-
>;
|
|
268
|
-
|
|
269
|
-
expect(result.ok).toBe(true);
|
|
270
|
-
expect(result.id).toBe("existing-sub-id");
|
|
271
|
-
expect(result.persisted).toBe("db");
|
|
272
|
-
expect(result.email).toMatchObject({ skipped: true, reason: "duplicate" });
|
|
273
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("subscribe duplicate P2002 with lookup miss → sentinel id duplicate-no-id", async () => {
|
|
277
|
-
const p2002 = Object.assign(new Error("Unique constraint"), {
|
|
278
|
-
code: "P2002",
|
|
279
|
-
});
|
|
280
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(p2002);
|
|
281
|
-
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue(null);
|
|
282
|
-
|
|
283
|
-
const result = (await routeLead(subscribeInput, sourceMeta)) as Extract<
|
|
284
|
-
RouteLeadResult,
|
|
285
|
-
{ ok: true }
|
|
286
|
-
>;
|
|
287
|
-
|
|
288
|
-
expect(result.ok).toBe(true);
|
|
289
|
-
expect(result.id).toBe("duplicate-no-id");
|
|
290
|
-
expect(result.email).toMatchObject({ skipped: true, reason: "duplicate" });
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
// ── 7. Validation failure → invalid_input ────────────────────────────────
|
|
294
|
-
it("malformed input (missing email) → ok:false invalid_input", async () => {
|
|
295
|
-
const bad = {
|
|
296
|
-
kind: "submit" as const,
|
|
297
|
-
siteId: "site",
|
|
298
|
-
source: "contact",
|
|
299
|
-
email: "not-an-email",
|
|
300
|
-
payload: {},
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const result = (await routeLead(bad, sourceMeta)) as Extract<
|
|
304
|
-
RouteLeadResult,
|
|
305
|
-
{ ok: false }
|
|
306
|
-
>;
|
|
307
|
-
|
|
308
|
-
expect(result.ok).toBe(false);
|
|
309
|
-
expect(result.error.kind).toBe("invalid_input");
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// ── 8. PII non-leak — log spy never sees email/phone/name/payload ─────────
|
|
313
|
-
it("PII non-leak — log output never contains email, phone, name, or payload values", async () => {
|
|
314
|
-
const logLines: string[] = [];
|
|
315
|
-
const infoSpy = jest
|
|
316
|
-
.spyOn(console, "info")
|
|
317
|
-
.mockImplementation((...args) => {
|
|
318
|
-
logLines.push(args.map(String).join(" "));
|
|
319
|
-
});
|
|
320
|
-
const warnSpy = jest
|
|
321
|
-
.spyOn(console, "warn")
|
|
322
|
-
.mockImplementation((...args) => {
|
|
323
|
-
logLines.push(args.map(String).join(" "));
|
|
324
|
-
});
|
|
325
|
-
const logSpy = jest.spyOn(console, "log").mockImplementation((...args) => {
|
|
326
|
-
logLines.push(args.map(String).join(" "));
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
await routeLead(leadInput, sourceMeta);
|
|
330
|
-
await routeLead(callbackInput, sourceMeta);
|
|
331
|
-
|
|
332
|
-
const combined = logLines.join("\n");
|
|
333
|
-
|
|
334
|
-
// PII values must not appear in any log output
|
|
335
|
-
expect(combined).not.toContain("test@example.com");
|
|
336
|
-
expect(combined).not.toContain("+15551234567");
|
|
337
|
-
expect(combined).not.toContain("Alice");
|
|
338
|
-
// payload values must not appear
|
|
339
|
-
expect(combined).not.toContain("hello");
|
|
340
|
-
expect(combined).not.toContain("urgent");
|
|
341
|
-
|
|
342
|
-
infoSpy.mockRestore();
|
|
343
|
-
warnSpy.mockRestore();
|
|
344
|
-
logSpy.mockRestore();
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// ── Callback happy path ───────────────────────────────────────────────────
|
|
348
|
-
it("callback happy path → persisted:db", async () => {
|
|
349
|
-
const result = (await routeLead(callbackInput, sourceMeta)) as Extract<
|
|
350
|
-
RouteLeadResult,
|
|
351
|
-
{ ok: true }
|
|
352
|
-
>;
|
|
353
|
-
|
|
354
|
-
expect(result.ok).toBe(true);
|
|
355
|
-
expect(result.id).toBe("callbk_00000000-0000-7000-8000-0000000000c3");
|
|
356
|
-
expect(result.persisted).toBe("db");
|
|
357
|
-
expect(prisma.callback.create).toHaveBeenCalledTimes(1);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// ── routeLead never throws ────────────────────────────────────────────────
|
|
361
|
-
it("routeLead never throws even when all dependencies fail", async () => {
|
|
362
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
|
|
363
|
-
// Point fallback at a bad dir so file write also fails
|
|
364
|
-
process.env.AILK_INBOX_FALLBACK_DIR = "/nonexistent-ailk-test-dir-2/inbox";
|
|
365
|
-
|
|
366
|
-
// Should not throw — returns ok:false instead
|
|
367
|
-
await expect(routeLead(leadInput, sourceMeta)).resolves.toBeDefined();
|
|
368
|
-
|
|
369
|
-
// Restore
|
|
370
|
-
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
// ── Regression: HIGH-1 — extra field in input must not be passed through ──
|
|
374
|
-
// The route handler must NOT spread unknown fields from the request body into
|
|
375
|
-
// the service call. The service's Zod validation via .strict() must reject
|
|
376
|
-
// unknown keys; the id field is controlled server-side only.
|
|
377
|
-
it("regression HIGH-1: input with extra 'id' field is rejected by strict schema validation", async () => {
|
|
378
|
-
const inputWithExtraId = {
|
|
379
|
-
kind: "submit" as const,
|
|
380
|
-
siteId: "site-abc",
|
|
381
|
-
source: "contact",
|
|
382
|
-
email: "test@example.com",
|
|
383
|
-
payload: { message: "hello" },
|
|
384
|
-
id: "attacker-supplied-id", // extra field — must be rejected by .strict()
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
// .strict() on leadSubmitSchema rejects unknown keys → invalid_input
|
|
388
|
-
// Omit the extra `id` field from the type so only one cast is needed.
|
|
389
|
-
const { id: _id, ...inputWithoutId } = inputWithExtraId;
|
|
390
|
-
const inputForService = {
|
|
391
|
-
...inputWithoutId,
|
|
392
|
-
// Re-attach as a plain unknown property so Zod's strict() can reject it.
|
|
393
|
-
...({ id: inputWithExtraId.id } as Record<string, unknown>),
|
|
394
|
-
} as Parameters<typeof routeLead>[0];
|
|
395
|
-
const result = (await routeLead(inputForService, sourceMeta)) as Extract<
|
|
396
|
-
RouteLeadResult,
|
|
397
|
-
{ ok: false }
|
|
398
|
-
>;
|
|
399
|
-
|
|
400
|
-
expect(result.ok).toBe(false);
|
|
401
|
-
expect(result.error.kind).toBe("invalid_input");
|
|
402
|
-
// The DB must not have been called with an attacker-supplied id
|
|
403
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// ── Regression: MEDIUM-4 — CRLF injection in siteId must be rejected ─────
|
|
407
|
-
// siteId is constrained to /^[a-zA-Z0-9._-]+$/ which excludes \r\n.
|
|
408
|
-
it("regression MEDIUM-4: CRLF injection in siteId returns invalid_input", async () => {
|
|
409
|
-
const inputWithCrlf = {
|
|
410
|
-
kind: "subscribe" as const,
|
|
411
|
-
siteId: "x\r\nBcc: attacker@x.com", // header injection attempt
|
|
412
|
-
email: "victim@example.com",
|
|
413
|
-
source: "footer",
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
const result = (await routeLead(inputWithCrlf, sourceMeta)) as Extract<
|
|
417
|
-
RouteLeadResult,
|
|
418
|
-
{ ok: false }
|
|
419
|
-
>;
|
|
420
|
-
|
|
421
|
-
expect(result.ok).toBe(false);
|
|
422
|
-
expect(result.error.kind).toBe("invalid_input");
|
|
423
|
-
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
|
|
427
|
-
//
|
|
428
|
-
// routeLead(kind: "submit") has FOUR callers in this repo, and as of
|
|
429
|
-
// #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
|
|
430
|
-
// before calling in — the three that once passed an unresolved alias
|
|
431
|
-
// (flow-engine.ts, recommender-capture.ts, project-listings.ts's
|
|
432
|
-
// submitProjectListingDraft) now resolve it themselves
|
|
433
|
-
// (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
|
|
434
|
-
// `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
|
|
435
|
-
// database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
|
|
436
|
-
// no longer stamp `null` for an unresolved alias the way #6203 once did —
|
|
437
|
-
// it copies `siteId` unconditionally, and a caller that still somehow
|
|
438
|
-
// passed an unresolved alias hits the pre-existing
|
|
439
|
-
// `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
|
|
440
|
-
// file's other describe block proves) instead of landing a
|
|
441
|
-
// silently-unfenced row.
|
|
442
|
-
describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
|
|
443
|
-
it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
|
|
444
|
-
await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
|
|
445
|
-
|
|
446
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
447
|
-
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
448
|
-
expect(data.siteId).toBe("site-abc");
|
|
449
|
-
expect(data.workspaceId).toBe("site-abc");
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
|
|
453
|
-
const resolvedInput = {
|
|
454
|
-
...leadInput,
|
|
455
|
-
siteId: "wksp_00000000-0000-7000-8000-000000000001",
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
await routeLead(resolvedInput, sourceMeta);
|
|
459
|
-
|
|
460
|
-
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
461
|
-
expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
462
|
-
expect(data.workspaceId).toBe(
|
|
463
|
-
"wksp_00000000-0000-7000-8000-000000000001",
|
|
464
|
-
);
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
|
|
469
|
-
// `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
|
|
470
|
-
// never be swallowed into the file fallback and a silent `ok:true`: it
|
|
471
|
-
// means OUR OWN derivation produced a row Postgres itself refuses, which
|
|
472
|
-
// is a bug to surface, not an outage to degrade through. A GENUINE
|
|
473
|
-
// infrastructure failure (the existing "DB down → file fallback" test
|
|
474
|
-
// above, a plain `Error("connection refused")` that carries none of the
|
|
475
|
-
// signals below) must still take the file fallback, unchanged.
|
|
476
|
-
describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
|
|
477
|
-
const listTmpDirFiles = () => fs.readdir(tmpDir);
|
|
478
|
-
|
|
479
|
-
it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
480
|
-
const before = await listTmpDirFiles();
|
|
481
|
-
const constraintErr = Object.assign(
|
|
482
|
-
new Error("Null constraint violation on the field: (`workspace_id`)"),
|
|
483
|
-
{ code: "P2011" },
|
|
484
|
-
);
|
|
485
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
|
|
486
|
-
|
|
487
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
488
|
-
RouteLeadResult,
|
|
489
|
-
{ ok: false }
|
|
490
|
-
>;
|
|
491
|
-
|
|
492
|
-
expect(result.ok).toBe(false);
|
|
493
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
494
|
-
// No file was written — the fallback was never attempted.
|
|
495
|
-
const after = await listTmpDirFiles();
|
|
496
|
-
expect(after).toEqual(before);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
500
|
-
const before = await listTmpDirFiles();
|
|
501
|
-
const checkErr = new Error(
|
|
502
|
-
'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
|
|
503
|
-
);
|
|
504
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
|
|
505
|
-
|
|
506
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
507
|
-
RouteLeadResult,
|
|
508
|
-
{ ok: false }
|
|
509
|
-
>;
|
|
510
|
-
|
|
511
|
-
expect(result.ok).toBe(false);
|
|
512
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
513
|
-
const after = await listTmpDirFiles();
|
|
514
|
-
expect(after).toEqual(before);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
|
|
518
|
-
const before = await listTmpDirFiles();
|
|
519
|
-
const notNullErr = Object.assign(
|
|
520
|
-
new Error(
|
|
521
|
-
'null value in column "workspace_id" violates not-null constraint (23502)',
|
|
522
|
-
),
|
|
523
|
-
{},
|
|
524
|
-
);
|
|
525
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
|
|
526
|
-
|
|
527
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
528
|
-
RouteLeadResult,
|
|
529
|
-
{ ok: false }
|
|
530
|
-
>;
|
|
531
|
-
|
|
532
|
-
expect(result.ok).toBe(false);
|
|
533
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
534
|
-
const after = await listTmpDirFiles();
|
|
535
|
-
expect(after).toEqual(before);
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
|
|
539
|
-
// Regression guard for the branch above: an ordinary connection error
|
|
540
|
-
// must not be misclassified as a constraint violation.
|
|
541
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
542
|
-
new Error("connection refused"),
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
546
|
-
RouteLeadResult,
|
|
547
|
-
{ ok: true }
|
|
548
|
-
>;
|
|
549
|
-
|
|
550
|
-
expect(result.ok).toBe(true);
|
|
551
|
-
expect(result.persisted).toBe("file");
|
|
552
|
-
});
|
|
553
|
-
});
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
|
|
560
|
-
* READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
|
|
561
|
-
* the resolved workspace id at every caller (the same guarantee the three
|
|
562
|
-
* `create` sites above stamp on), so the lookup is scoped by it rather than
|
|
563
|
-
* resolving a second time.
|
|
564
|
-
*/
|
|
565
|
-
describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
|
|
566
|
-
beforeEach(() => {
|
|
567
|
-
jest.clearAllMocks();
|
|
568
|
-
process.env.DATABASE_URL = "postgres://test";
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
|
|
572
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
573
|
-
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
|
574
|
-
);
|
|
575
|
-
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
576
|
-
id: "sub_1",
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
await routeLead(
|
|
580
|
-
{ ...subscribeInput, email: "dupe@example.com" },
|
|
581
|
-
sourceMeta,
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
|
|
585
|
-
.calls[0][0];
|
|
586
|
-
expect(where.workspaceId).toBe(subscribeInput.siteId);
|
|
587
|
-
expect(where.siteId_email).toEqual({
|
|
588
|
-
siteId: subscribeInput.siteId,
|
|
589
|
-
email: "dupe@example.com",
|
|
590
|
-
});
|
|
591
|
-
});
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
// ─── #6730 D1/D2 — success only for a durable row or an accepted email ───────
|
|
595
|
-
|
|
596
|
-
describe("#6730 — routeLead delivery modes", () => {
|
|
597
|
-
const ENV_KEYS = [
|
|
598
|
-
"DATABASE_URL",
|
|
599
|
-
"NODE_ENV",
|
|
600
|
-
"RESEND_API_KEY",
|
|
601
|
-
"AILK_LEAD_NOTIFICATION_TO",
|
|
602
|
-
"AILK_LEAD_NOTIFICATION_FROM",
|
|
603
|
-
"AILK_INBOX_FALLBACK_DIR",
|
|
604
|
-
"LEAD_CAPTURE_FILE",
|
|
605
|
-
] as const;
|
|
606
|
-
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
607
|
-
let tmpDir: string;
|
|
608
|
-
const listFiles = () => fs.readdir(tmpDir);
|
|
609
|
-
|
|
610
|
-
const EMAIL_ENV = {
|
|
611
|
-
RESEND_API_KEY: "re_test",
|
|
612
|
-
AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
|
|
613
|
-
AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
|
|
614
|
-
};
|
|
615
|
-
|
|
616
|
-
beforeEach(async () => {
|
|
617
|
-
jest.clearAllMocks();
|
|
618
|
-
saved = {};
|
|
619
|
-
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
620
|
-
for (const k of ENV_KEYS) delete process.env[k];
|
|
621
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
|
|
622
|
-
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
623
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
|
|
624
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
625
|
-
ok: true,
|
|
626
|
-
messageId: "msg-1",
|
|
627
|
-
});
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
afterEach(async () => {
|
|
631
|
-
for (const k of ENV_KEYS) {
|
|
632
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
633
|
-
else process.env[k] = saved[k];
|
|
634
|
-
}
|
|
635
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
describe("email delivery (the three email vars, no DATABASE_URL, production)", () => {
|
|
639
|
-
beforeEach(() => {
|
|
640
|
-
Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it("an accepted email → ok:true persisted:email with an email- id; no Prisma, no file fallback", async () => {
|
|
644
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
645
|
-
RouteLeadResult,
|
|
646
|
-
{ ok: true }
|
|
647
|
-
>;
|
|
648
|
-
|
|
649
|
-
expect(result).toMatchObject({
|
|
650
|
-
ok: true,
|
|
651
|
-
persisted: "email",
|
|
652
|
-
email: { ok: true, messageId: "msg-1" },
|
|
653
|
-
});
|
|
654
|
-
expect(result.id).toMatch(/^email-/);
|
|
655
|
-
expect(result.id).toMatch(/^[a-z0-9_-]{8,64}$/i);
|
|
656
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
657
|
-
expect(await listFiles()).toEqual([]);
|
|
658
|
-
expect(mockSendLeadNotification).toHaveBeenCalledTimes(1);
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
it("a refused email → ok:false delivery_failed (never reported as sent)", async () => {
|
|
662
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
663
|
-
ok: false,
|
|
664
|
-
error: { kind: "provider_error", message: "rejected" },
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
668
|
-
RouteLeadResult,
|
|
669
|
-
{ ok: false }
|
|
670
|
-
>;
|
|
671
|
-
|
|
672
|
-
expect(result.ok).toBe(false);
|
|
673
|
-
expect(result.error.kind).toBe("delivery_failed");
|
|
674
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
675
|
-
expect(await listFiles()).toEqual([]);
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
it("a skipped email (provider reports the key missing) → delivery_failed", async () => {
|
|
679
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
680
|
-
ok: false,
|
|
681
|
-
error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
685
|
-
expect(result).toMatchObject({
|
|
686
|
-
ok: false,
|
|
687
|
-
error: { kind: "delivery_failed" },
|
|
688
|
-
});
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
it("a callback is delivered by email too — carrying the name and number to call", async () => {
|
|
692
|
-
const result = await routeLead(
|
|
693
|
-
{ ...callbackInput, preferredTime: "mornings" },
|
|
694
|
-
sourceMeta,
|
|
695
|
-
);
|
|
696
|
-
expect(result).toMatchObject({ ok: true, persisted: "email" });
|
|
697
|
-
expect(prisma.callback.create).not.toHaveBeenCalled();
|
|
698
|
-
const arg = mockSendLeadNotification.mock.calls[0][0];
|
|
699
|
-
expect(arg.lead.payload).toMatchObject({
|
|
700
|
-
name: "Alice",
|
|
701
|
-
phone: "+15551234567",
|
|
702
|
-
preferredTime: "mornings",
|
|
703
|
-
notes: "urgent",
|
|
704
|
-
});
|
|
705
|
-
// No visitor address to reply to — never an empty Reply-To.
|
|
706
|
-
expect(arg.replyToOverride).toBe("owner@example.com");
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
|
|
710
|
-
const result = await routeLead(subscribeInput, sourceMeta);
|
|
711
|
-
expect(result).toMatchObject({
|
|
712
|
-
ok: false,
|
|
713
|
-
error: { kind: "config_missing" },
|
|
714
|
-
});
|
|
715
|
-
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
716
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
717
|
-
});
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
describe("production with neither a database nor the email vars", () => {
|
|
721
|
-
beforeEach(() => {
|
|
722
|
-
process.env.NODE_ENV = "production";
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it("refuses with config_missing before any write", async () => {
|
|
726
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
727
|
-
|
|
728
|
-
expect(result).toMatchObject({
|
|
729
|
-
ok: false,
|
|
730
|
-
error: { kind: "config_missing" },
|
|
731
|
-
});
|
|
732
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
733
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
734
|
-
expect(await listFiles()).toEqual([]);
|
|
735
|
-
});
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
describe("inbox delivery in production with the database failing (not a constraint)", () => {
|
|
739
|
-
beforeEach(() => {
|
|
740
|
-
process.env.DATABASE_URL = "postgres://test";
|
|
741
|
-
process.env.NODE_ENV = "production";
|
|
742
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
743
|
-
new Error("connection refused"),
|
|
744
|
-
);
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
|
|
748
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
749
|
-
|
|
750
|
-
expect(result).toMatchObject({
|
|
751
|
-
ok: false,
|
|
752
|
-
error: { kind: "persist_failed" },
|
|
753
|
-
});
|
|
754
|
-
expect(await listFiles()).toEqual([]);
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
it("email vars set and the send accepted → persisted:email", async () => {
|
|
758
|
-
Object.assign(process.env, EMAIL_ENV);
|
|
759
|
-
|
|
760
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
761
|
-
RouteLeadResult,
|
|
762
|
-
{ ok: true }
|
|
763
|
-
>;
|
|
764
|
-
|
|
765
|
-
expect(result).toMatchObject({ ok: true, persisted: "email" });
|
|
766
|
-
expect(result.id).toMatch(/^email-/);
|
|
767
|
-
expect(await listFiles()).toEqual([]);
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
it("a newsletter signup is never emailed — persist_failed", async () => {
|
|
771
|
-
Object.assign(process.env, EMAIL_ENV);
|
|
772
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
773
|
-
new Error("connection refused"),
|
|
774
|
-
);
|
|
775
|
-
|
|
776
|
-
const result = await routeLead(subscribeInput, sourceMeta);
|
|
777
|
-
|
|
778
|
-
expect(result).toMatchObject({
|
|
779
|
-
ok: false,
|
|
780
|
-
error: { kind: "persist_failed" },
|
|
781
|
-
});
|
|
782
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
it("email vars set but the send refused → persist_failed", async () => {
|
|
786
|
-
Object.assign(process.env, EMAIL_ENV);
|
|
787
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
788
|
-
ok: false,
|
|
789
|
-
error: { kind: "provider_error", message: "rejected" },
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
793
|
-
expect(result).toMatchObject({
|
|
794
|
-
ok: false,
|
|
795
|
-
error: { kind: "persist_failed" },
|
|
796
|
-
});
|
|
797
|
-
});
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
describe("inbox delivery outside production keeps today's file fallback", () => {
|
|
801
|
-
it("DB down in test → persisted:file", async () => {
|
|
802
|
-
process.env.DATABASE_URL = "postgres://test";
|
|
803
|
-
process.env.NODE_ENV = "test";
|
|
804
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
805
|
-
new Error("connection refused"),
|
|
806
|
-
);
|
|
807
|
-
|
|
808
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
809
|
-
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
810
|
-
expect((await listFiles()).length).toBeGreaterThan(0);
|
|
811
|
-
});
|
|
812
|
-
});
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// ── #7869 — the Idempotency-Key on submit (spec D3–D5, D10 cases 9–14) ──────
|
|
816
|
-
//
|
|
817
|
-
// Cases 9–13 run on `inbox` delivery (DATABASE_URL set), where the key is
|
|
818
|
-
// honoured; case 14 drops DATABASE_URL to prove a non-inbox delivery ignores
|
|
819
|
-
// it. A keyed create that loses the race throws P2002 and is answered from a
|
|
820
|
-
// re-read of the winner.
|
|
821
|
-
describe("#7869 — routeLead with an idempotency key", () => {
|
|
822
|
-
const KEY = "fixture-retry-0000-0000-0001";
|
|
823
|
-
const WINNER_ID = "lead_00000000-0000-7000-8000-00000000f001";
|
|
824
|
-
const ENV_KEYS = [
|
|
825
|
-
"DATABASE_URL",
|
|
826
|
-
"NODE_ENV",
|
|
827
|
-
"AILK_INBOX_FALLBACK_DIR",
|
|
828
|
-
"LEAD_CAPTURE_FILE",
|
|
829
|
-
"AILK_LEAD_NOTIFICATION_TO",
|
|
830
|
-
"AILK_LEAD_NOTIFICATION_FROM",
|
|
831
|
-
] as const;
|
|
832
|
-
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
833
|
-
let tmpDir: string;
|
|
834
|
-
|
|
835
|
-
const p2002 = () =>
|
|
836
|
-
Object.assign(
|
|
837
|
-
new Error(
|
|
838
|
-
"Unique constraint failed on the fields: (`workspace_id`,`idempotency_key`)",
|
|
839
|
-
),
|
|
840
|
-
{ code: "P2002" },
|
|
841
|
-
);
|
|
842
|
-
const keyWhere = (workspaceId: string) => ({
|
|
843
|
-
where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey: KEY } },
|
|
844
|
-
select: { id: true, email: true, source: true, payload: true },
|
|
845
|
-
});
|
|
846
|
-
const createData = () =>
|
|
847
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
848
|
-
string,
|
|
849
|
-
unknown
|
|
850
|
-
>;
|
|
851
|
-
|
|
852
|
-
beforeEach(async () => {
|
|
853
|
-
jest.clearAllMocks();
|
|
854
|
-
saved = {};
|
|
855
|
-
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
856
|
-
for (const k of ENV_KEYS) delete process.env[k];
|
|
857
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-idem-test-"));
|
|
858
|
-
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
859
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
860
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
861
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
862
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
|
|
863
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
864
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
865
|
-
ok: true,
|
|
866
|
-
messageId: "msg-1",
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
afterEach(async () => {
|
|
871
|
-
for (const k of ENV_KEYS) {
|
|
872
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
873
|
-
else process.env[k] = saved[k];
|
|
874
|
-
}
|
|
875
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
it("case 9 — a P2002 race whose winner matches answers the winner's id: no fallback, no persist_failed, no notification, no capture record", async () => {
|
|
879
|
-
// Positive control first: a keyed create with no race writes exactly one
|
|
880
|
-
// line to the capture file, so the negative assertion below is load-bearing.
|
|
881
|
-
const controlFile = path.join(tmpDir, "control.jsonl");
|
|
882
|
-
process.env.LEAD_CAPTURE_FILE = controlFile;
|
|
883
|
-
const control = await routeLead(leadInput, sourceMeta, {
|
|
884
|
-
idempotencyKey: KEY,
|
|
885
|
-
});
|
|
886
|
-
expect(control).toMatchObject({
|
|
887
|
-
ok: true,
|
|
888
|
-
id: "lead_db_new",
|
|
889
|
-
persisted: "db",
|
|
890
|
-
});
|
|
891
|
-
expect(
|
|
892
|
-
(await fs.readFile(controlFile, "utf8")).trim().split("\n"),
|
|
893
|
-
).toHaveLength(1);
|
|
894
|
-
expect(createData()).toMatchObject({ idempotencyKey: KEY });
|
|
895
|
-
|
|
896
|
-
jest.clearAllMocks();
|
|
897
|
-
const raceFile = path.join(tmpDir, "race.jsonl");
|
|
898
|
-
process.env.LEAD_CAPTURE_FILE = raceFile;
|
|
899
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
900
|
-
(prisma.lead.findUnique as jest.Mock)
|
|
901
|
-
.mockResolvedValueOnce(null) // the pre-write lookup misses
|
|
902
|
-
.mockResolvedValueOnce({
|
|
903
|
-
id: WINNER_ID,
|
|
904
|
-
email: leadInput.email,
|
|
905
|
-
source: leadInput.source,
|
|
906
|
-
payload: { ...leadInput.payload },
|
|
907
|
-
});
|
|
908
|
-
const before = await fs.readdir(tmpDir);
|
|
909
|
-
|
|
910
|
-
const result = await routeLead(leadInput, sourceMeta, {
|
|
911
|
-
idempotencyKey: KEY,
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
expect(result).toEqual({
|
|
915
|
-
ok: true,
|
|
916
|
-
replayed: true,
|
|
917
|
-
id: WINNER_ID,
|
|
918
|
-
persisted: "db",
|
|
919
|
-
email: { skipped: true, reason: "idempotent_replay" },
|
|
920
|
-
});
|
|
921
|
-
expect(prisma.lead.findUnique).toHaveBeenNthCalledWith(
|
|
922
|
-
2,
|
|
923
|
-
keyWhere(leadInput.siteId),
|
|
924
|
-
);
|
|
925
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
926
|
-
await expect(fs.access(raceFile)).rejects.toThrow();
|
|
927
|
-
expect(await fs.readdir(tmpDir)).toEqual(before); // no file fallback
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
it("case 10 — a P2002 race whose winner carries a different email is idempotency_conflict", async () => {
|
|
931
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
932
|
-
(prisma.lead.findUnique as jest.Mock)
|
|
933
|
-
.mockResolvedValueOnce(null)
|
|
934
|
-
.mockResolvedValueOnce({
|
|
935
|
-
id: WINNER_ID,
|
|
936
|
-
email: "someone-else@example.com",
|
|
937
|
-
source: leadInput.source,
|
|
938
|
-
payload: leadInput.payload,
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
const result = await routeLead(leadInput, sourceMeta, {
|
|
942
|
-
idempotencyKey: KEY,
|
|
943
|
-
});
|
|
944
|
-
|
|
945
|
-
expect(result).toEqual({
|
|
946
|
-
ok: false,
|
|
947
|
-
error: {
|
|
948
|
-
kind: "idempotency_conflict",
|
|
949
|
-
message: "idempotency key reused",
|
|
950
|
-
},
|
|
951
|
-
});
|
|
952
|
-
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
it("case 11 — a P2002 race whose re-read finds nothing takes the existing persist_failed path", async () => {
|
|
956
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
957
|
-
|
|
958
|
-
const result = (await routeLead(leadInput, sourceMeta, {
|
|
959
|
-
idempotencyKey: KEY,
|
|
960
|
-
})) as Extract<RouteLeadResult, { ok: false }>;
|
|
961
|
-
|
|
962
|
-
expect(result.ok).toBe(false);
|
|
963
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
964
|
-
expect(prisma.lead.findUnique).toHaveBeenCalledTimes(2);
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
it("case 12 — a P2002 with no key passed is persist_failed, as today, with no key lookup", async () => {
|
|
968
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
969
|
-
|
|
970
|
-
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
971
|
-
RouteLeadResult,
|
|
972
|
-
{ ok: false }
|
|
973
|
-
>;
|
|
974
|
-
|
|
975
|
-
expect(result.ok).toBe(false);
|
|
976
|
-
expect(result.error.kind).toBe("persist_failed");
|
|
977
|
-
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
978
|
-
expect(createData()).not.toHaveProperty("idempotencyKey");
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
it("case 13 — a lookup that throws still reaches the create (the existing path)", async () => {
|
|
982
|
-
(prisma.lead.findUnique as jest.Mock).mockRejectedValue(
|
|
983
|
-
new Error("connection reset"),
|
|
984
|
-
);
|
|
985
|
-
|
|
986
|
-
const result = await routeLead(leadInput, sourceMeta, {
|
|
987
|
-
idempotencyKey: KEY,
|
|
988
|
-
});
|
|
989
|
-
|
|
990
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
991
|
-
expect(result).toMatchObject({
|
|
992
|
-
ok: true,
|
|
993
|
-
id: "lead_db_new",
|
|
994
|
-
persisted: "db",
|
|
995
|
-
});
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
it("case 14 — a non-inbox delivery ignores the key: no lookup, no key on the create, the existing file path", async () => {
|
|
999
|
-
delete process.env.DATABASE_URL; // `local` delivery outside production
|
|
1000
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
1001
|
-
new Error("connection refused"),
|
|
1002
|
-
);
|
|
1003
|
-
|
|
1004
|
-
const result = await routeLead(leadInput, sourceMeta, {
|
|
1005
|
-
idempotencyKey: KEY,
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
1009
|
-
expect(createData()).not.toHaveProperty("idempotencyKey");
|
|
1010
|
-
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
1011
|
-
});
|
|
1012
|
-
});
|
|
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
|
-
|
|
1148
|
-
// ─── #852 D6 — every submitted lead carries its Person ──────────────────────
|
|
1149
|
-
//
|
|
1150
|
-
// The Person step runs through the shared mock's `person` delegate, whose
|
|
1151
|
-
// `findFirst` default resolves one fixed Person (`MOCK_PERSON_ID`). Lead
|
|
1152
|
-
// capture is a never-gate surface: an unstorable address or a failed step
|
|
1153
|
-
// writes the lead with `personId: null` and changes nothing else.
|
|
1154
|
-
describe("#852 — the lead's Person (AC-3)", () => {
|
|
1155
|
-
const ENV_KEYS = [
|
|
1156
|
-
"DATABASE_URL",
|
|
1157
|
-
"NODE_ENV",
|
|
1158
|
-
"AILK_LEAD_NOTIFICATION_TO",
|
|
1159
|
-
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1160
|
-
] as const;
|
|
1161
|
-
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1162
|
-
const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
|
|
1163
|
-
const createData = () =>
|
|
1164
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
1165
|
-
string,
|
|
1166
|
-
unknown
|
|
1167
|
-
>;
|
|
1168
|
-
const personDelegate = () => ({
|
|
1169
|
-
findFirst: jest.mocked(prisma.person.findFirst),
|
|
1170
|
-
createMany: jest.mocked(prisma.person.createMany),
|
|
1171
|
-
updateMany: jest.mocked(prisma.person.updateMany),
|
|
1172
|
-
});
|
|
1173
|
-
const personCalls = () =>
|
|
1174
|
-
Object.values(personDelegate()).reduce(
|
|
1175
|
-
(n, fn) => n + fn.mock.calls.length,
|
|
1176
|
-
0,
|
|
1177
|
-
);
|
|
1178
|
-
let warn: jest.SpyInstance;
|
|
1179
|
-
let error: jest.SpyInstance;
|
|
1180
|
-
|
|
1181
|
-
beforeEach(() => {
|
|
1182
|
-
jest.clearAllMocks();
|
|
1183
|
-
saved = {};
|
|
1184
|
-
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1185
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
1186
|
-
delete process.env.NODE_ENV;
|
|
1187
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1188
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1189
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
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);
|
|
1200
|
-
});
|
|
1201
|
-
|
|
1202
|
-
afterEach(() => {
|
|
1203
|
-
for (const k of ENV_KEYS) {
|
|
1204
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
1205
|
-
else process.env[k] = saved[k];
|
|
1206
|
-
}
|
|
1207
|
-
warn.mockRestore();
|
|
1208
|
-
error.mockRestore();
|
|
1209
|
-
});
|
|
1210
|
-
|
|
1211
|
-
it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
|
|
1212
|
-
const result = await routeLead(
|
|
1213
|
-
{
|
|
1214
|
-
...leadInput,
|
|
1215
|
-
email: "Test@Example.com",
|
|
1216
|
-
payload: { name: "Tess", message: "hi" },
|
|
1217
|
-
},
|
|
1218
|
-
sourceMeta,
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1222
|
-
expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
|
|
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 }),
|
|
1229
|
-
);
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
// ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
|
|
1233
|
-
const LINKED = {
|
|
1234
|
-
ok: true as const,
|
|
1235
|
-
value: {
|
|
1236
|
-
personId: MOCK_PERSON_ID as never,
|
|
1237
|
-
created: false,
|
|
1238
|
-
billing: "unchanged" as const,
|
|
1239
|
-
},
|
|
1240
|
-
};
|
|
1241
|
-
const findSpies: jest.SpyInstance[] = [];
|
|
1242
|
-
const spyFind = () => {
|
|
1243
|
-
const spy = jest.spyOn(personService, "findOrCreatePerson");
|
|
1244
|
-
findSpies.push(spy);
|
|
1245
|
-
return spy;
|
|
1246
|
-
};
|
|
1247
|
-
afterEach(() => {
|
|
1248
|
-
while (findSpies.length > 0) findSpies.pop()?.mockRestore();
|
|
1249
|
-
});
|
|
1250
|
-
const subscriberData = () =>
|
|
1251
|
-
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
|
|
1252
|
-
string,
|
|
1253
|
-
unknown
|
|
1254
|
-
>;
|
|
1255
|
-
const callbackData = () =>
|
|
1256
|
-
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
|
|
1257
|
-
string,
|
|
1258
|
-
unknown
|
|
1259
|
-
>;
|
|
1260
|
-
|
|
1261
|
-
it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
|
|
1262
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1263
|
-
id: "subscr_x",
|
|
1264
|
-
});
|
|
1265
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1266
|
-
await routeLead(subscribeInput, sourceMeta);
|
|
1267
|
-
expect(find).toHaveBeenCalledTimes(1);
|
|
1268
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1269
|
-
email: "subscriber@example.com",
|
|
1270
|
-
});
|
|
1271
|
-
expect(subscriberData()).toEqual(
|
|
1272
|
-
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1273
|
-
);
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
|
|
1277
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1278
|
-
id: "subscr_x",
|
|
1279
|
-
});
|
|
1280
|
-
jest
|
|
1281
|
-
.spyOn(personService, "findOrCreatePerson")
|
|
1282
|
-
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1283
|
-
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1284
|
-
expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
|
|
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
|
-
);
|
|
1291
|
-
expect(skipped).toHaveLength(1);
|
|
1292
|
-
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1293
|
-
});
|
|
1294
|
-
|
|
1295
|
-
it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
|
|
1296
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1297
|
-
id: "subscr_x",
|
|
1298
|
-
});
|
|
1299
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1300
|
-
const linked = await routeLead(subscribeInput, sourceMeta);
|
|
1301
|
-
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1302
|
-
|
|
1303
|
-
find.mockResolvedValueOnce({
|
|
1304
|
-
ok: false,
|
|
1305
|
-
error: { kind: "internal_error" },
|
|
1306
|
-
});
|
|
1307
|
-
const failed = await routeLead(subscribeInput, sourceMeta);
|
|
1308
|
-
expect(failed).toEqual(linked);
|
|
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
|
-
});
|
|
1316
|
-
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1317
|
-
|
|
1318
|
-
find.mockRejectedValueOnce(new Error("boom"));
|
|
1319
|
-
const thrown = await routeLead(subscribeInput, sourceMeta);
|
|
1320
|
-
expect(thrown).toEqual(linked);
|
|
1321
|
-
expect(subscriberData()).toEqual(
|
|
1322
|
-
expect.objectContaining({ personId: null }),
|
|
1323
|
-
);
|
|
1324
|
-
});
|
|
1325
|
-
|
|
1326
|
-
it("a duplicate subscribe (P2002) makes no second Person call", async () => {
|
|
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
|
-
});
|
|
1333
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1334
|
-
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1335
|
-
expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
|
|
1336
|
-
expect(find).toHaveBeenCalledTimes(1);
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
it("a callback with payload.email and a spaced phone links its Person, carrying the E.164 telephone", async () => {
|
|
1340
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1341
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1342
|
-
await routeLead(
|
|
1343
|
-
{
|
|
1344
|
-
...callbackInput,
|
|
1345
|
-
phone: "+1 415 555 0123",
|
|
1346
|
-
payload: { email: "c@x.io" },
|
|
1347
|
-
},
|
|
1348
|
-
sourceMeta,
|
|
1349
|
-
);
|
|
1350
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1351
|
-
email: "c@x.io",
|
|
1352
|
-
name: "Alice",
|
|
1353
|
-
contact: { telephone: "+14155550123" },
|
|
1354
|
-
});
|
|
1355
|
-
expect(callbackData()).toEqual(
|
|
1356
|
-
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1357
|
-
);
|
|
1358
|
-
});
|
|
1359
|
-
|
|
1360
|
-
it.each([
|
|
1361
|
-
["no payload.email", { notes: "urgent" }],
|
|
1362
|
-
["a numeric payload.email", { email: 12345 }],
|
|
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
|
-
);
|
|
1374
|
-
|
|
1375
|
-
// ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
|
|
1376
|
-
it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
|
|
1377
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1378
|
-
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
|
|
1379
|
-
contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
|
|
1380
|
-
});
|
|
1381
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1382
|
-
email: "test@example.com",
|
|
1383
|
-
name: "Ann",
|
|
1384
|
-
contact: { givenName: "Ann", telephone: "+14155550123" },
|
|
1385
|
-
});
|
|
1386
|
-
// the lead's payload is the raw answers, unchanged
|
|
1387
|
-
expect(createData()["payload"]).toEqual({ name: "Bob" });
|
|
1388
|
-
});
|
|
1389
|
-
|
|
1390
|
-
it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
|
|
1391
|
-
const find = spyFind().mockResolvedValue(LINKED);
|
|
1392
|
-
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
|
|
1393
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1394
|
-
email: "test@example.com",
|
|
1395
|
-
name: "Bob",
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
|
|
1400
|
-
// `leadInputSchema`'s email check refuses most unstorable addresses before
|
|
1401
|
-
// this step, so the Person result is stubbed to reach the branch.
|
|
1402
|
-
jest
|
|
1403
|
-
.spyOn(personService, "findOrCreatePerson")
|
|
1404
|
-
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1405
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
1406
|
-
|
|
1407
|
-
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1408
|
-
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1409
|
-
const skipped = warn.mock.calls.filter(
|
|
1410
|
-
([fields]) => (fields as { event?: string }).event === "person-skipped",
|
|
1411
|
-
);
|
|
1412
|
-
expect(skipped).toHaveLength(1);
|
|
1413
|
-
expect(skipped[0]![0]).toEqual({
|
|
1414
|
-
event: "person-skipped",
|
|
1415
|
-
kind: "invalid_email",
|
|
1416
|
-
siteId: "site-abc",
|
|
1417
|
-
});
|
|
1418
|
-
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
it("an internal_error Person result is the same never-gate path as a throwing delegate", async () => {
|
|
1422
|
-
jest
|
|
1423
|
-
.spyOn(personService, "findOrCreatePerson")
|
|
1424
|
-
.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
|
|
1425
|
-
const result = await routeLead(leadInput, sourceMeta);
|
|
1426
|
-
|
|
1427
|
-
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1428
|
-
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1429
|
-
expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
|
|
1430
|
-
event: "person-failed",
|
|
1431
|
-
siteId: "site-abc",
|
|
1432
|
-
});
|
|
1433
|
-
});
|
|
1434
|
-
|
|
1435
|
-
it("a Person step that fails writes the lead with personId: null, logs person-failed with no email, and answers like a successful capture", async () => {
|
|
1436
|
-
const ok = await routeLead(leadInput, sourceMeta);
|
|
1437
|
-
jest.mocked(prisma.lead.create).mockClear();
|
|
1438
|
-
mockSendLeadNotification.mockClear();
|
|
1439
|
-
personDelegate()["findFirst"]!.mockRejectedValueOnce(
|
|
1440
|
-
new Error('relation "people" does not exist'),
|
|
1441
|
-
);
|
|
1442
|
-
|
|
1443
|
-
const failed = await routeLead(leadInput, sourceMeta);
|
|
1444
|
-
|
|
1445
|
-
// The answer is the same as a successful capture, apart from the internal
|
|
1446
|
-
// `personId` (#8438 D11) the route never puts on a wire.
|
|
1447
|
-
expect(failed).toEqual({ ...ok, personId: null });
|
|
1448
|
-
expect(failed).toMatchObject({
|
|
1449
|
-
ok: true,
|
|
1450
|
-
id: LEAD_ID,
|
|
1451
|
-
persisted: "db",
|
|
1452
|
-
email: { ok: true },
|
|
1453
|
-
});
|
|
1454
|
-
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1455
|
-
const lines = error.mock.calls.filter(
|
|
1456
|
-
([fields]) => (fields as { event?: string }).event === "person-failed",
|
|
1457
|
-
);
|
|
1458
|
-
expect(lines).toHaveLength(1);
|
|
1459
|
-
expect(lines[0]![0]).toEqual({
|
|
1460
|
-
event: "person-failed",
|
|
1461
|
-
siteId: "site-abc",
|
|
1462
|
-
});
|
|
1463
|
-
expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
|
|
1464
|
-
});
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
// ─── #7877 D10 — a spoke's checkout lead sets the Person's plan (AC-5) ──────
|
|
1468
|
-
//
|
|
1469
|
-
// The plan step runs only after this request's own `lead.create`, for a LIVE,
|
|
1470
|
-
// paid checkout lead a registered spoke's server posted with the session id
|
|
1471
|
-
// as its key. Every other lead makes no plan call; a failing plan step never
|
|
1472
|
-
// changes the lead's result.
|
|
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)", () => {
|
|
1478
|
-
const ENV_KEYS = [
|
|
1479
|
-
"DATABASE_URL",
|
|
1480
|
-
"NODE_ENV",
|
|
1481
|
-
"AILK_LEAD_NOTIFICATION_TO",
|
|
1482
|
-
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1483
|
-
] as const;
|
|
1484
|
-
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1485
|
-
const LEAD_ID = "lead_00000000-0000-7000-8000-000000007877";
|
|
1486
|
-
const WS = "wksp_00000000-0000-7000-8000-000000007877";
|
|
1487
|
-
const KEY = "cs_live_a1B2c3"; // gitleaks:allow -- a synthetic Checkout Session id shape (#7877 AC-5), not a credential
|
|
1488
|
-
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
1489
|
-
const checkoutLead = {
|
|
1490
|
-
kind: "submit" as const,
|
|
1491
|
-
siteId: WS,
|
|
1492
|
-
source: "web",
|
|
1493
|
-
email: "cleo@x.io",
|
|
1494
|
-
payload: {
|
|
1495
|
-
site: "wendi",
|
|
1496
|
-
page: "checkout",
|
|
1497
|
-
offering: "wendi_retainer",
|
|
1498
|
-
} as Record<string, unknown>,
|
|
1499
|
-
};
|
|
1500
|
-
let setPlan: jest.SpyInstance;
|
|
1501
|
-
let info: jest.SpyInstance;
|
|
1502
|
-
let error: jest.SpyInstance;
|
|
1503
|
-
|
|
1504
|
-
beforeEach(() => {
|
|
1505
|
-
jest.clearAllMocks();
|
|
1506
|
-
saved = {};
|
|
1507
|
-
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1508
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
1509
|
-
delete process.env.NODE_ENV;
|
|
1510
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1511
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1512
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1513
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
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);
|
|
1527
|
-
});
|
|
1528
|
-
|
|
1529
|
-
afterEach(() => {
|
|
1530
|
-
for (const k of ENV_KEYS) {
|
|
1531
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
1532
|
-
else process.env[k] = saved[k];
|
|
1533
|
-
}
|
|
1534
|
-
setPlan.mockRestore();
|
|
1535
|
-
info.mockRestore();
|
|
1536
|
-
error.mockRestore();
|
|
1537
|
-
});
|
|
1538
|
-
|
|
1539
|
-
const planLines = () =>
|
|
1540
|
-
[...info.mock.calls, ...error.mock.calls]
|
|
1541
|
-
.map(([fields]) => fields as Record<string, unknown>)
|
|
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
|
-
};
|
|
1556
|
-
|
|
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());
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
it("a bare slug offering is held as it is", async () => {
|
|
1571
|
-
await routeLead(
|
|
1572
|
-
{
|
|
1573
|
-
...checkoutLead,
|
|
1574
|
-
payload: { ...checkoutLead.payload, offering: "retainer" },
|
|
1575
|
-
},
|
|
1576
|
-
spokeMeta,
|
|
1577
|
-
{ idempotencyKey: KEY },
|
|
1578
|
-
);
|
|
1579
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
1580
|
-
expect(planLines()).toEqual([
|
|
1581
|
-
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1582
|
-
]);
|
|
1583
|
-
});
|
|
1584
|
-
|
|
1585
|
-
it("a positive integer amountTotal still qualifies", async () => {
|
|
1586
|
-
await routeLead(
|
|
1587
|
-
{
|
|
1588
|
-
...checkoutLead,
|
|
1589
|
-
payload: { ...checkoutLead.payload, amountTotal: 4900 },
|
|
1590
|
-
},
|
|
1591
|
-
spokeMeta,
|
|
1592
|
-
{ idempotencyKey: KEY },
|
|
1593
|
-
);
|
|
1594
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
1595
|
-
expect(planLines()).toEqual([
|
|
1596
|
-
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1597
|
-
]);
|
|
1598
|
-
});
|
|
1599
|
-
|
|
1600
|
-
it.each([
|
|
1601
|
-
["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
|
|
1602
|
-
["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
|
|
1603
|
-
[
|
|
1604
|
-
"amountTotal 0",
|
|
1605
|
-
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
|
|
1606
|
-
spokeMeta,
|
|
1607
|
-
KEY,
|
|
1608
|
-
],
|
|
1609
|
-
[
|
|
1610
|
-
"a negative amountTotal",
|
|
1611
|
-
{
|
|
1612
|
-
...checkoutLead,
|
|
1613
|
-
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1614
|
-
},
|
|
1615
|
-
spokeMeta,
|
|
1616
|
-
KEY,
|
|
1617
|
-
],
|
|
1618
|
-
[
|
|
1619
|
-
"a key that is not a session id",
|
|
1620
|
-
checkoutLead,
|
|
1621
|
-
spokeMeta,
|
|
1622
|
-
"abcdefghijklmnop",
|
|
1623
|
-
],
|
|
1624
|
-
["no key", checkoutLead, spokeMeta, undefined],
|
|
1625
|
-
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1626
|
-
[
|
|
1627
|
-
"page contact",
|
|
1628
|
-
{
|
|
1629
|
-
...checkoutLead,
|
|
1630
|
-
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1631
|
-
},
|
|
1632
|
-
spokeMeta,
|
|
1633
|
-
KEY,
|
|
1634
|
-
],
|
|
1635
|
-
[
|
|
1636
|
-
"a numeric offering",
|
|
1637
|
-
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
|
|
1638
|
-
spokeMeta,
|
|
1639
|
-
KEY,
|
|
1640
|
-
],
|
|
1641
|
-
] as const)("no plan call for %s", async (_label, input, meta, key) => {
|
|
1642
|
-
const result = await routeLead(
|
|
1643
|
-
input as typeof checkoutLead,
|
|
1644
|
-
meta,
|
|
1645
|
-
key === undefined ? {} : { idempotencyKey: key },
|
|
1646
|
-
);
|
|
1647
|
-
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
1648
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
1649
|
-
expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
|
|
1650
|
-
expect(planLines()).toEqual([]);
|
|
1651
|
-
});
|
|
1652
|
-
|
|
1653
|
-
// #8185 — the hub stores its conclusion that a lead is a spoke's live paid
|
|
1654
|
-
// checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
|
|
1655
|
-
// relayed". An unmarked create names no `spokeCheckout` key at all.
|
|
1656
|
-
const createData = () =>
|
|
1657
|
-
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
|
|
1658
|
-
string,
|
|
1659
|
-
unknown
|
|
1660
|
-
>;
|
|
1661
|
-
|
|
1662
|
-
it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
|
|
1663
|
-
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1664
|
-
expect(createData()).toMatchObject({ spokeCheckout: true });
|
|
1665
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
1666
|
-
expect(planLines()).toHaveLength(1);
|
|
1667
|
-
});
|
|
1668
|
-
|
|
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
|
-
);
|
|
1675
|
-
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1676
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
1677
|
-
expect(planLines()).toEqual([]);
|
|
1678
|
-
});
|
|
1679
|
-
|
|
1680
|
-
it.each([
|
|
1681
|
-
["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
|
|
1682
|
-
["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
|
|
1683
|
-
[
|
|
1684
|
-
"amountTotal 0",
|
|
1685
|
-
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
|
|
1686
|
-
spokeMeta,
|
|
1687
|
-
KEY,
|
|
1688
|
-
],
|
|
1689
|
-
[
|
|
1690
|
-
"a negative amountTotal",
|
|
1691
|
-
{
|
|
1692
|
-
...checkoutLead,
|
|
1693
|
-
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1694
|
-
},
|
|
1695
|
-
spokeMeta,
|
|
1696
|
-
KEY,
|
|
1697
|
-
],
|
|
1698
|
-
[
|
|
1699
|
-
"a key that is not a session id",
|
|
1700
|
-
checkoutLead,
|
|
1701
|
-
spokeMeta,
|
|
1702
|
-
"abcdefghijklmnop",
|
|
1703
|
-
],
|
|
1704
|
-
["no key", checkoutLead, spokeMeta, undefined],
|
|
1705
|
-
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1706
|
-
[
|
|
1707
|
-
"page contact",
|
|
1708
|
-
{
|
|
1709
|
-
...checkoutLead,
|
|
1710
|
-
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1711
|
-
},
|
|
1712
|
-
spokeMeta,
|
|
1713
|
-
KEY,
|
|
1714
|
-
],
|
|
1715
|
-
[
|
|
1716
|
-
"a numeric offering",
|
|
1717
|
-
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
|
|
1718
|
-
spokeMeta,
|
|
1719
|
-
KEY,
|
|
1720
|
-
],
|
|
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
|
-
);
|
|
1733
|
-
|
|
1734
|
-
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1735
|
-
const result = await routeLead(
|
|
1736
|
-
{
|
|
1737
|
-
...checkoutLead,
|
|
1738
|
-
payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
|
|
1739
|
-
},
|
|
1740
|
-
spokeMeta,
|
|
1741
|
-
{ idempotencyKey: KEY },
|
|
1742
|
-
);
|
|
1743
|
-
expect(result).toEqual(answered);
|
|
1744
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
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);
|
|
1763
|
-
});
|
|
1764
|
-
|
|
1765
|
-
it("a replayed key writes nothing and sets nothing", async () => {
|
|
1766
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1767
|
-
id: LEAD_ID,
|
|
1768
|
-
email: checkoutLead.email,
|
|
1769
|
-
source: checkoutLead.source,
|
|
1770
|
-
payload: checkoutLead.payload,
|
|
1771
|
-
});
|
|
1772
|
-
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1773
|
-
idempotencyKey: KEY,
|
|
1774
|
-
});
|
|
1775
|
-
expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
|
|
1776
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1777
|
-
expect(setPlan).not.toHaveBeenCalled();
|
|
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
|
-
});
|
|
2357
|
-
|
|
2358
|
-
it.each([
|
|
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
|
-
]);
|
|
2479
|
-
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
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
|
-
);
|
|
2659
|
-
});
|
|
2660
|
-
});
|