create-ailk 0.22.0 → 0.24.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 +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -46,6 +46,7 @@ import * as path from "path";
|
|
|
46
46
|
import { logger } from "@working-theory/observability";
|
|
47
47
|
import {
|
|
48
48
|
ACCOUNT_TERMS_PURPOSE_ID,
|
|
49
|
+
CONSENT_LEAD_SOURCE_LIST,
|
|
49
50
|
consentPurposeById,
|
|
50
51
|
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
51
52
|
} from "@working-theory/validation";
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
} from "../../lib/__tests__/consent-ledger-double.js";
|
|
58
59
|
import { prisma } from "../../lib/prisma.js";
|
|
59
60
|
import { routeLead } from "../lead-routing";
|
|
61
|
+
import * as consentService from "../consent";
|
|
60
62
|
import * as personService from "../person";
|
|
61
63
|
|
|
62
64
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -1009,6 +1011,140 @@ describe("#7869 — routeLead with an idempotency key", () => {
|
|
|
1009
1011
|
});
|
|
1010
1012
|
});
|
|
1011
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
|
+
|
|
1012
1148
|
// ─── #852 D6 — every submitted lead carries its Person ──────────────────────
|
|
1013
1149
|
//
|
|
1014
1150
|
// The Person step runs through the shared mock's `person` delegate, whose
|
|
@@ -1334,7 +1470,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1334
1470
|
// paid checkout lead a registered spoke's server posted with the session id
|
|
1335
1471
|
// as its key. Every other lead makes no plan call; a failing plan step never
|
|
1336
1472
|
// changes the lead's result.
|
|
1337
|
-
|
|
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)", () => {
|
|
1338
1478
|
const ENV_KEYS = [
|
|
1339
1479
|
"DATABASE_URL",
|
|
1340
1480
|
"NODE_ENV",
|
|
@@ -1400,20 +1540,34 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1400
1540
|
[...info.mock.calls, ...error.mock.calls]
|
|
1401
1541
|
.map(([fields]) => fields as Record<string, unknown>)
|
|
1402
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
|
+
};
|
|
1403
1556
|
|
|
1404
|
-
it("a live spoke checkout lead
|
|
1557
|
+
it("a live spoke checkout lead holds the plan: no plan write, one held line", async () => {
|
|
1405
1558
|
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1406
1559
|
idempotencyKey: KEY,
|
|
1407
1560
|
});
|
|
1408
|
-
expect(result).
|
|
1409
|
-
expect(setPlan).
|
|
1410
|
-
expect(
|
|
1411
|
-
expect(
|
|
1412
|
-
{ event: "checkout-plan", outcome: "
|
|
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 },
|
|
1413
1566
|
]);
|
|
1567
|
+
expect(planLines()).toEqual(infoPlanLines());
|
|
1414
1568
|
});
|
|
1415
1569
|
|
|
1416
|
-
it("a bare slug offering is
|
|
1570
|
+
it("a bare slug offering is held as it is", async () => {
|
|
1417
1571
|
await routeLead(
|
|
1418
1572
|
{
|
|
1419
1573
|
...checkoutLead,
|
|
@@ -1422,7 +1576,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1422
1576
|
spokeMeta,
|
|
1423
1577
|
{ idempotencyKey: KEY },
|
|
1424
1578
|
);
|
|
1425
|
-
expect(setPlan).
|
|
1579
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1580
|
+
expect(planLines()).toEqual([
|
|
1581
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1582
|
+
]);
|
|
1426
1583
|
});
|
|
1427
1584
|
|
|
1428
1585
|
it("a positive integer amountTotal still qualifies", async () => {
|
|
@@ -1434,7 +1591,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1434
1591
|
spokeMeta,
|
|
1435
1592
|
{ idempotencyKey: KEY },
|
|
1436
1593
|
);
|
|
1437
|
-
expect(setPlan).
|
|
1594
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1595
|
+
expect(planLines()).toEqual([
|
|
1596
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
1597
|
+
]);
|
|
1438
1598
|
});
|
|
1439
1599
|
|
|
1440
1600
|
it.each([
|
|
@@ -1499,20 +1659,22 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1499
1659
|
unknown
|
|
1500
1660
|
>;
|
|
1501
1661
|
|
|
1502
|
-
it("a live spoke checkout lead is created with spokeCheckout true and
|
|
1662
|
+
it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
|
|
1503
1663
|
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1504
1664
|
expect(createData()).toMatchObject({ spokeCheckout: true });
|
|
1505
|
-
expect(setPlan).
|
|
1665
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1666
|
+
expect(planLines()).toHaveLength(1);
|
|
1506
1667
|
});
|
|
1507
1668
|
|
|
1508
|
-
it("the same lead relayed for a visitor is created with no spokeCheckout key and
|
|
1669
|
+
it("the same lead relayed for a visitor is created with no spokeCheckout key and holds no plan", async () => {
|
|
1509
1670
|
await routeLead(
|
|
1510
1671
|
checkoutLead,
|
|
1511
1672
|
{ ...spokeMeta, relayed: true },
|
|
1512
1673
|
{ idempotencyKey: KEY },
|
|
1513
1674
|
);
|
|
1514
1675
|
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1515
|
-
expect(setPlan).
|
|
1676
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1677
|
+
expect(planLines()).toEqual([]);
|
|
1516
1678
|
});
|
|
1517
1679
|
|
|
1518
1680
|
it.each([
|
|
@@ -1570,7 +1732,7 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1570
1732
|
);
|
|
1571
1733
|
|
|
1572
1734
|
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1573
|
-
await routeLead(
|
|
1735
|
+
const result = await routeLead(
|
|
1574
1736
|
{
|
|
1575
1737
|
...checkoutLead,
|
|
1576
1738
|
payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
|
|
@@ -1578,10 +1740,26 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1578
1740
|
spokeMeta,
|
|
1579
1741
|
{ idempotencyKey: KEY },
|
|
1580
1742
|
);
|
|
1743
|
+
expect(result).toEqual(answered);
|
|
1581
1744
|
expect(setPlan).not.toHaveBeenCalled();
|
|
1582
|
-
expect(
|
|
1745
|
+
expect(infoPlanLines()).toEqual([
|
|
1583
1746
|
{ event: "checkout-plan", outcome: "skipped", siteId: WS },
|
|
1584
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);
|
|
1585
1763
|
});
|
|
1586
1764
|
|
|
1587
1765
|
it("a replayed key writes nothing and sets nothing", async () => {
|
|
@@ -1598,33 +1776,6 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1598
1776
|
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1599
1777
|
expect(setPlan).not.toHaveBeenCalled();
|
|
1600
1778
|
});
|
|
1601
|
-
|
|
1602
|
-
it.each([
|
|
1603
|
-
["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
|
|
1604
|
-
["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
|
|
1605
|
-
])(
|
|
1606
|
-
"a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key",
|
|
1607
|
-
async (_label, arrange) => {
|
|
1608
|
-
const ok = await routeLead(checkoutLead, spokeMeta, {
|
|
1609
|
-
idempotencyKey: KEY,
|
|
1610
|
-
});
|
|
1611
|
-
info.mockClear();
|
|
1612
|
-
error.mockClear();
|
|
1613
|
-
arrange();
|
|
1614
|
-
const failed = await routeLead(checkoutLead, spokeMeta, {
|
|
1615
|
-
idempotencyKey: KEY,
|
|
1616
|
-
});
|
|
1617
|
-
expect(failed).toEqual(ok);
|
|
1618
|
-
expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1619
|
-
expect(planLines()).toEqual([
|
|
1620
|
-
{ event: "checkout-plan", outcome: "failed", siteId: WS },
|
|
1621
|
-
]);
|
|
1622
|
-
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
1623
|
-
expect(logged).not.toContain("retainer");
|
|
1624
|
-
expect(logged).not.toContain("cleo@x.io");
|
|
1625
|
-
expect(logged).not.toContain(KEY);
|
|
1626
|
-
},
|
|
1627
|
-
);
|
|
1628
1779
|
});
|
|
1629
1780
|
|
|
1630
1781
|
describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
|
|
@@ -2028,7 +2179,7 @@ describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-
|
|
|
2028
2179
|
});
|
|
2029
2180
|
await settle();
|
|
2030
2181
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2031
|
-
expect(setPlan).
|
|
2182
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
2032
2183
|
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
2033
2184
|
expect(logged).not.toContain("Cleo@X.io");
|
|
2034
2185
|
expect(logged).not.toContain("retainer");
|
|
@@ -2200,6 +2351,8 @@ describe("#8778 — a spoke checkout's terms grant", () => {
|
|
|
2200
2351
|
expect(consentLines()).toEqual([
|
|
2201
2352
|
expect.objectContaining({ outcome: "recorded", siteId: WS }),
|
|
2202
2353
|
]);
|
|
2354
|
+
// #8793 D1 — the grant is written; the plan is held, never set here.
|
|
2355
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
2203
2356
|
});
|
|
2204
2357
|
|
|
2205
2358
|
it.each([
|
|
@@ -2328,3 +2481,180 @@ describe("#8778 — a spoke checkout's terms grant", () => {
|
|
|
2328
2481
|
expect(logged).not.toContain(MOCK_PERSON_ID);
|
|
2329
2482
|
});
|
|
2330
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
|
+
});
|