create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -23,9 +23,19 @@ jest.mock("../../lib/prisma.js");
|
|
|
23
23
|
|
|
24
24
|
// ─── Mock: email (external boundary) ─────────────────────────────────────────
|
|
25
25
|
const mockSendLeadNotification = jest.fn();
|
|
26
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
26
27
|
jest.mock("@working-theory/email", () => ({
|
|
27
28
|
sendLeadNotification: (...args: unknown[]) =>
|
|
28
29
|
mockSendLeadNotification(...args),
|
|
30
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
31
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// ─── Mock: the plan-offerings lister (a content read) ────────────────────────
|
|
35
|
+
const mockListPlanOfferings = jest.fn();
|
|
36
|
+
// eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam routes/leads/__tests__/magnet-completion.test.ts mocks.
|
|
37
|
+
jest.mock("../plan-offerings.js", () => ({
|
|
38
|
+
listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
|
|
29
39
|
}));
|
|
30
40
|
|
|
31
41
|
// ─── Import after mocks ───────────────────────────────────────────────────────
|
|
@@ -34,8 +44,17 @@ import * as os from "os";
|
|
|
34
44
|
import * as path from "path";
|
|
35
45
|
|
|
36
46
|
import { logger } from "@working-theory/observability";
|
|
47
|
+
import {
|
|
48
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
49
|
+
consentPurposeById,
|
|
50
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
51
|
+
} from "@working-theory/validation";
|
|
37
52
|
|
|
38
53
|
import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
|
|
54
|
+
import {
|
|
55
|
+
armConsentLedger,
|
|
56
|
+
type ConsentLedgerDouble,
|
|
57
|
+
} from "../../lib/__tests__/consent-ledger-double.js";
|
|
39
58
|
import { prisma } from "../../lib/prisma.js";
|
|
40
59
|
import { routeLead } from "../lead-routing";
|
|
41
60
|
import * as personService from "../person";
|
|
@@ -141,7 +160,9 @@ describe("routeLead", () => {
|
|
|
141
160
|
|
|
142
161
|
// ── 2. DB down → fallback engages → persisted:file ───────────────────────
|
|
143
162
|
it("DB down → file fallback → persisted:file", async () => {
|
|
144
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
163
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
164
|
+
new Error("connection refused"),
|
|
165
|
+
);
|
|
145
166
|
|
|
146
167
|
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
147
168
|
RouteLeadResult,
|
|
@@ -367,9 +388,12 @@ describe("routeLead", () => {
|
|
|
367
388
|
const inputForService = {
|
|
368
389
|
...inputWithoutId,
|
|
369
390
|
// Re-attach as a plain unknown property so Zod's strict() can reject it.
|
|
370
|
-
...({
|
|
391
|
+
...({ id: inputWithExtraId.id } as Record<string, unknown>),
|
|
371
392
|
} as Parameters<typeof routeLead>[0];
|
|
372
|
-
const result = (await routeLead(inputForService, sourceMeta)) as Extract<
|
|
393
|
+
const result = (await routeLead(inputForService, sourceMeta)) as Extract<
|
|
394
|
+
RouteLeadResult,
|
|
395
|
+
{ ok: false }
|
|
396
|
+
>;
|
|
373
397
|
|
|
374
398
|
expect(result.ok).toBe(false);
|
|
375
399
|
expect(result.error.kind).toBe("invalid_input");
|
|
@@ -433,7 +457,9 @@ describe("routeLead", () => {
|
|
|
433
457
|
|
|
434
458
|
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
435
459
|
expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
436
|
-
expect(data.workspaceId).toBe(
|
|
460
|
+
expect(data.workspaceId).toBe(
|
|
461
|
+
"wksp_00000000-0000-7000-8000-000000000001",
|
|
462
|
+
);
|
|
437
463
|
});
|
|
438
464
|
});
|
|
439
465
|
|
|
@@ -456,10 +482,10 @@ describe("routeLead", () => {
|
|
|
456
482
|
);
|
|
457
483
|
(prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
|
|
458
484
|
|
|
459
|
-
const result = (await routeLead(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
485
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
486
|
+
RouteLeadResult,
|
|
487
|
+
{ ok: false }
|
|
488
|
+
>;
|
|
463
489
|
|
|
464
490
|
expect(result.ok).toBe(false);
|
|
465
491
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -475,10 +501,10 @@ describe("routeLead", () => {
|
|
|
475
501
|
);
|
|
476
502
|
(prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
|
|
477
503
|
|
|
478
|
-
const result = (await routeLead(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
504
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
505
|
+
RouteLeadResult,
|
|
506
|
+
{ ok: false }
|
|
507
|
+
>;
|
|
482
508
|
|
|
483
509
|
expect(result.ok).toBe(false);
|
|
484
510
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -489,15 +515,17 @@ describe("routeLead", () => {
|
|
|
489
515
|
it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
|
|
490
516
|
const before = await listTmpDirFiles();
|
|
491
517
|
const notNullErr = Object.assign(
|
|
492
|
-
new Error(
|
|
518
|
+
new Error(
|
|
519
|
+
'null value in column "workspace_id" violates not-null constraint (23502)',
|
|
520
|
+
),
|
|
493
521
|
{},
|
|
494
522
|
);
|
|
495
523
|
(prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
|
|
496
524
|
|
|
497
|
-
const result = (await routeLead(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
525
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
526
|
+
RouteLeadResult,
|
|
527
|
+
{ ok: false }
|
|
528
|
+
>;
|
|
501
529
|
|
|
502
530
|
expect(result.ok).toBe(false);
|
|
503
531
|
expect(result.error.kind).toBe("persist_failed");
|
|
@@ -512,10 +540,10 @@ describe("routeLead", () => {
|
|
|
512
540
|
new Error("connection refused"),
|
|
513
541
|
);
|
|
514
542
|
|
|
515
|
-
const result = (await routeLead(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
543
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
544
|
+
RouteLeadResult,
|
|
545
|
+
{ ok: true }
|
|
546
|
+
>;
|
|
519
547
|
|
|
520
548
|
expect(result.ok).toBe(true);
|
|
521
549
|
expect(result.persisted).toBe("file");
|
|
@@ -542,14 +570,17 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
|
|
|
542
570
|
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
543
571
|
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
|
544
572
|
);
|
|
545
|
-
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
573
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
574
|
+
id: "sub_1",
|
|
575
|
+
});
|
|
546
576
|
|
|
547
577
|
await routeLead(
|
|
548
578
|
{ ...subscribeInput, email: "dupe@example.com" },
|
|
549
579
|
sourceMeta,
|
|
550
580
|
);
|
|
551
581
|
|
|
552
|
-
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
|
|
582
|
+
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
|
|
583
|
+
.calls[0][0];
|
|
553
584
|
expect(where.workspaceId).toBe(subscribeInput.siteId);
|
|
554
585
|
expect(where.siteId_email).toEqual({
|
|
555
586
|
siteId: subscribeInput.siteId,
|
|
@@ -588,7 +619,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
588
619
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
|
|
589
620
|
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
590
621
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
|
|
591
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
622
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
623
|
+
ok: true,
|
|
624
|
+
messageId: "msg-1",
|
|
625
|
+
});
|
|
592
626
|
});
|
|
593
627
|
|
|
594
628
|
afterEach(async () => {
|
|
@@ -646,7 +680,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
646
680
|
});
|
|
647
681
|
|
|
648
682
|
const result = await routeLead(leadInput, sourceMeta);
|
|
649
|
-
expect(result).toMatchObject({
|
|
683
|
+
expect(result).toMatchObject({
|
|
684
|
+
ok: false,
|
|
685
|
+
error: { kind: "delivery_failed" },
|
|
686
|
+
});
|
|
650
687
|
});
|
|
651
688
|
|
|
652
689
|
it("a callback is delivered by email too — carrying the name and number to call", async () => {
|
|
@@ -669,7 +706,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
669
706
|
|
|
670
707
|
it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
|
|
671
708
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
672
|
-
expect(result).toMatchObject({
|
|
709
|
+
expect(result).toMatchObject({
|
|
710
|
+
ok: false,
|
|
711
|
+
error: { kind: "config_missing" },
|
|
712
|
+
});
|
|
673
713
|
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
674
714
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
675
715
|
});
|
|
@@ -683,7 +723,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
683
723
|
it("refuses with config_missing before any write", async () => {
|
|
684
724
|
const result = await routeLead(leadInput, sourceMeta);
|
|
685
725
|
|
|
686
|
-
expect(result).toMatchObject({
|
|
726
|
+
expect(result).toMatchObject({
|
|
727
|
+
ok: false,
|
|
728
|
+
error: { kind: "config_missing" },
|
|
729
|
+
});
|
|
687
730
|
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
688
731
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
689
732
|
expect(await listFiles()).toEqual([]);
|
|
@@ -694,13 +737,18 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
694
737
|
beforeEach(() => {
|
|
695
738
|
process.env.DATABASE_URL = "postgres://test";
|
|
696
739
|
process.env.NODE_ENV = "production";
|
|
697
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
740
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
741
|
+
new Error("connection refused"),
|
|
742
|
+
);
|
|
698
743
|
});
|
|
699
744
|
|
|
700
745
|
it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
|
|
701
746
|
const result = await routeLead(leadInput, sourceMeta);
|
|
702
747
|
|
|
703
|
-
expect(result).toMatchObject({
|
|
748
|
+
expect(result).toMatchObject({
|
|
749
|
+
ok: false,
|
|
750
|
+
error: { kind: "persist_failed" },
|
|
751
|
+
});
|
|
704
752
|
expect(await listFiles()).toEqual([]);
|
|
705
753
|
});
|
|
706
754
|
|
|
@@ -719,11 +767,16 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
719
767
|
|
|
720
768
|
it("a newsletter signup is never emailed — persist_failed", async () => {
|
|
721
769
|
Object.assign(process.env, EMAIL_ENV);
|
|
722
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
770
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
771
|
+
new Error("connection refused"),
|
|
772
|
+
);
|
|
723
773
|
|
|
724
774
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
725
775
|
|
|
726
|
-
expect(result).toMatchObject({
|
|
776
|
+
expect(result).toMatchObject({
|
|
777
|
+
ok: false,
|
|
778
|
+
error: { kind: "persist_failed" },
|
|
779
|
+
});
|
|
727
780
|
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
728
781
|
});
|
|
729
782
|
|
|
@@ -735,7 +788,10 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
735
788
|
});
|
|
736
789
|
|
|
737
790
|
const result = await routeLead(leadInput, sourceMeta);
|
|
738
|
-
expect(result).toMatchObject({
|
|
791
|
+
expect(result).toMatchObject({
|
|
792
|
+
ok: false,
|
|
793
|
+
error: { kind: "persist_failed" },
|
|
794
|
+
});
|
|
739
795
|
});
|
|
740
796
|
});
|
|
741
797
|
|
|
@@ -743,7 +799,9 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
743
799
|
it("DB down in test → persisted:file", async () => {
|
|
744
800
|
process.env.DATABASE_URL = "postgres://test";
|
|
745
801
|
process.env.NODE_ENV = "test";
|
|
746
|
-
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
802
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
803
|
+
new Error("connection refused"),
|
|
804
|
+
);
|
|
747
805
|
|
|
748
806
|
const result = await routeLead(leadInput, sourceMeta);
|
|
749
807
|
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
@@ -967,14 +1025,20 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
967
1025
|
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
968
1026
|
const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
|
|
969
1027
|
const createData = () =>
|
|
970
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
1028
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
1029
|
+
string,
|
|
1030
|
+
unknown
|
|
1031
|
+
>;
|
|
971
1032
|
const personDelegate = () => ({
|
|
972
1033
|
findFirst: jest.mocked(prisma.person.findFirst),
|
|
973
1034
|
createMany: jest.mocked(prisma.person.createMany),
|
|
974
1035
|
updateMany: jest.mocked(prisma.person.updateMany),
|
|
975
1036
|
});
|
|
976
1037
|
const personCalls = () =>
|
|
977
|
-
Object.values(personDelegate()).reduce(
|
|
1038
|
+
Object.values(personDelegate()).reduce(
|
|
1039
|
+
(n, fn) => n + fn.mock.calls.length,
|
|
1040
|
+
0,
|
|
1041
|
+
);
|
|
978
1042
|
let warn: jest.SpyInstance;
|
|
979
1043
|
let error: jest.SpyInstance;
|
|
980
1044
|
|
|
@@ -987,9 +1051,16 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
987
1051
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
988
1052
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
989
1053
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
990
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
991
|
-
|
|
992
|
-
|
|
1054
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1055
|
+
ok: true,
|
|
1056
|
+
messageId: "msg-852",
|
|
1057
|
+
});
|
|
1058
|
+
warn = jest
|
|
1059
|
+
.spyOn(logger, "warn")
|
|
1060
|
+
.mockImplementation(() => undefined as never);
|
|
1061
|
+
error = jest
|
|
1062
|
+
.spyOn(logger, "error")
|
|
1063
|
+
.mockImplementation(() => undefined as never);
|
|
993
1064
|
});
|
|
994
1065
|
|
|
995
1066
|
afterEach(() => {
|
|
@@ -1003,21 +1074,33 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1003
1074
|
|
|
1004
1075
|
it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
|
|
1005
1076
|
const result = await routeLead(
|
|
1006
|
-
{
|
|
1077
|
+
{
|
|
1078
|
+
...leadInput,
|
|
1079
|
+
email: "Test@Example.com",
|
|
1080
|
+
payload: { name: "Tess", message: "hi" },
|
|
1081
|
+
},
|
|
1007
1082
|
sourceMeta,
|
|
1008
1083
|
);
|
|
1009
1084
|
|
|
1010
1085
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1011
1086
|
expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
|
|
1012
|
-
expect.objectContaining({
|
|
1087
|
+
expect.objectContaining({
|
|
1088
|
+
where: { workspaceId: "site-abc", email: "test@example.com" },
|
|
1089
|
+
}),
|
|
1090
|
+
);
|
|
1091
|
+
expect(createData()).toEqual(
|
|
1092
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1013
1093
|
);
|
|
1014
|
-
expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1015
1094
|
});
|
|
1016
1095
|
|
|
1017
1096
|
// ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
|
|
1018
1097
|
const LINKED = {
|
|
1019
1098
|
ok: true as const,
|
|
1020
|
-
value: {
|
|
1099
|
+
value: {
|
|
1100
|
+
personId: MOCK_PERSON_ID as never,
|
|
1101
|
+
created: false,
|
|
1102
|
+
billing: "unchanged" as const,
|
|
1103
|
+
},
|
|
1021
1104
|
};
|
|
1022
1105
|
const findSpies: jest.SpyInstance[] = [];
|
|
1023
1106
|
const spyFind = () => {
|
|
@@ -1029,54 +1112,88 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1029
1112
|
while (findSpies.length > 0) findSpies.pop()?.mockRestore();
|
|
1030
1113
|
});
|
|
1031
1114
|
const subscriberData = () =>
|
|
1032
|
-
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
|
|
1115
|
+
jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
|
|
1116
|
+
string,
|
|
1117
|
+
unknown
|
|
1118
|
+
>;
|
|
1033
1119
|
const callbackData = () =>
|
|
1034
|
-
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
|
|
1120
|
+
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
|
|
1121
|
+
string,
|
|
1122
|
+
unknown
|
|
1123
|
+
>;
|
|
1035
1124
|
|
|
1036
1125
|
it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
|
|
1037
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1126
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1127
|
+
id: "subscr_x",
|
|
1128
|
+
});
|
|
1038
1129
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1039
1130
|
await routeLead(subscribeInput, sourceMeta);
|
|
1040
1131
|
expect(find).toHaveBeenCalledTimes(1);
|
|
1041
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1042
|
-
|
|
1132
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1133
|
+
email: "subscriber@example.com",
|
|
1134
|
+
});
|
|
1135
|
+
expect(subscriberData()).toEqual(
|
|
1136
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1137
|
+
);
|
|
1043
1138
|
});
|
|
1044
1139
|
|
|
1045
1140
|
it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
|
|
1046
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1141
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1142
|
+
id: "subscr_x",
|
|
1143
|
+
});
|
|
1047
1144
|
jest
|
|
1048
1145
|
.spyOn(personService, "findOrCreatePerson")
|
|
1049
1146
|
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1050
1147
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1051
1148
|
expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
|
|
1052
|
-
expect(subscriberData()).toEqual(
|
|
1053
|
-
|
|
1149
|
+
expect(subscriberData()).toEqual(
|
|
1150
|
+
expect.objectContaining({ personId: null }),
|
|
1151
|
+
);
|
|
1152
|
+
const skipped = warn.mock.calls.filter(
|
|
1153
|
+
([fields]) => (fields as { event?: string }).event === "person-skipped",
|
|
1154
|
+
);
|
|
1054
1155
|
expect(skipped).toHaveLength(1);
|
|
1055
1156
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1056
1157
|
});
|
|
1057
1158
|
|
|
1058
1159
|
it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
|
|
1059
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1160
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1161
|
+
id: "subscr_x",
|
|
1162
|
+
});
|
|
1060
1163
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1061
1164
|
const linked = await routeLead(subscribeInput, sourceMeta);
|
|
1062
1165
|
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1063
1166
|
|
|
1064
|
-
find.mockResolvedValueOnce({
|
|
1167
|
+
find.mockResolvedValueOnce({
|
|
1168
|
+
ok: false,
|
|
1169
|
+
error: { kind: "internal_error" },
|
|
1170
|
+
});
|
|
1065
1171
|
const failed = await routeLead(subscribeInput, sourceMeta);
|
|
1066
1172
|
expect(failed).toEqual(linked);
|
|
1067
|
-
expect(subscriberData()).toEqual(
|
|
1068
|
-
|
|
1173
|
+
expect(subscriberData()).toEqual(
|
|
1174
|
+
expect.objectContaining({ personId: null }),
|
|
1175
|
+
);
|
|
1176
|
+
expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
|
|
1177
|
+
event: "person-failed",
|
|
1178
|
+
siteId: "site-abc",
|
|
1179
|
+
});
|
|
1069
1180
|
jest.mocked(prisma.subscriber.create).mockClear();
|
|
1070
1181
|
|
|
1071
1182
|
find.mockRejectedValueOnce(new Error("boom"));
|
|
1072
1183
|
const thrown = await routeLead(subscribeInput, sourceMeta);
|
|
1073
1184
|
expect(thrown).toEqual(linked);
|
|
1074
|
-
expect(subscriberData()).toEqual(
|
|
1185
|
+
expect(subscriberData()).toEqual(
|
|
1186
|
+
expect.objectContaining({ personId: null }),
|
|
1187
|
+
);
|
|
1075
1188
|
});
|
|
1076
1189
|
|
|
1077
1190
|
it("a duplicate subscribe (P2002) makes no second Person call", async () => {
|
|
1078
|
-
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
1079
|
-
|
|
1191
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
1192
|
+
Object.assign(new Error("dup"), { code: "P2002" }),
|
|
1193
|
+
);
|
|
1194
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
|
|
1195
|
+
id: "subscr_existing",
|
|
1196
|
+
});
|
|
1080
1197
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1081
1198
|
const result = await routeLead(subscribeInput, sourceMeta);
|
|
1082
1199
|
expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
|
|
@@ -1087,7 +1204,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1087
1204
|
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1088
1205
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1089
1206
|
await routeLead(
|
|
1090
|
-
{
|
|
1207
|
+
{
|
|
1208
|
+
...callbackInput,
|
|
1209
|
+
phone: "+1 415 555 0123",
|
|
1210
|
+
payload: { email: "c@x.io" },
|
|
1211
|
+
},
|
|
1091
1212
|
sourceMeta,
|
|
1092
1213
|
);
|
|
1093
1214
|
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
@@ -1095,27 +1216,32 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1095
1216
|
name: "Alice",
|
|
1096
1217
|
contact: { telephone: "+14155550123" },
|
|
1097
1218
|
});
|
|
1098
|
-
expect(callbackData()).toEqual(
|
|
1219
|
+
expect(callbackData()).toEqual(
|
|
1220
|
+
expect.objectContaining({ personId: MOCK_PERSON_ID }),
|
|
1221
|
+
);
|
|
1099
1222
|
});
|
|
1100
1223
|
|
|
1101
1224
|
it.each([
|
|
1102
1225
|
["no payload.email", { notes: "urgent" }],
|
|
1103
1226
|
["a numeric payload.email", { email: 12345 }],
|
|
1104
|
-
])(
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1227
|
+
])(
|
|
1228
|
+
"a callback with %s makes no person delegate call and writes no personId key",
|
|
1229
|
+
async (_label, payload) => {
|
|
1230
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1231
|
+
id: "callbk_x",
|
|
1232
|
+
});
|
|
1233
|
+
await routeLead({ ...callbackInput, payload }, sourceMeta);
|
|
1234
|
+
expect(personCalls()).toBe(0);
|
|
1235
|
+
expect(Object.keys(callbackData())).not.toContain("personId");
|
|
1236
|
+
},
|
|
1237
|
+
);
|
|
1110
1238
|
|
|
1111
1239
|
// ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
|
|
1112
1240
|
it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
|
|
1113
1241
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1114
|
-
await routeLead(
|
|
1115
|
-
{
|
|
1116
|
-
|
|
1117
|
-
{ contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" } },
|
|
1118
|
-
);
|
|
1242
|
+
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
|
|
1243
|
+
contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
|
|
1244
|
+
});
|
|
1119
1245
|
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1120
1246
|
email: "test@example.com",
|
|
1121
1247
|
name: "Ann",
|
|
@@ -1128,7 +1254,10 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1128
1254
|
it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
|
|
1129
1255
|
const find = spyFind().mockResolvedValue(LINKED);
|
|
1130
1256
|
await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
|
|
1131
|
-
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1257
|
+
expect(find).toHaveBeenCalledWith("site-abc", {
|
|
1258
|
+
email: "test@example.com",
|
|
1259
|
+
name: "Bob",
|
|
1260
|
+
});
|
|
1132
1261
|
});
|
|
1133
1262
|
|
|
1134
1263
|
it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
|
|
@@ -1141,9 +1270,15 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1141
1270
|
|
|
1142
1271
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1143
1272
|
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1144
|
-
const skipped = warn.mock.calls.filter(
|
|
1273
|
+
const skipped = warn.mock.calls.filter(
|
|
1274
|
+
([fields]) => (fields as { event?: string }).event === "person-skipped",
|
|
1275
|
+
);
|
|
1145
1276
|
expect(skipped).toHaveLength(1);
|
|
1146
|
-
expect(skipped[0]![0]).toEqual({
|
|
1277
|
+
expect(skipped[0]![0]).toEqual({
|
|
1278
|
+
event: "person-skipped",
|
|
1279
|
+
kind: "invalid_email",
|
|
1280
|
+
siteId: "site-abc",
|
|
1281
|
+
});
|
|
1147
1282
|
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1148
1283
|
});
|
|
1149
1284
|
|
|
@@ -1165,18 +1300,30 @@ describe("#852 — the lead's Person (AC-3)", () => {
|
|
|
1165
1300
|
const ok = await routeLead(leadInput, sourceMeta);
|
|
1166
1301
|
jest.mocked(prisma.lead.create).mockClear();
|
|
1167
1302
|
mockSendLeadNotification.mockClear();
|
|
1168
|
-
personDelegate()["findFirst"]!.mockRejectedValueOnce(
|
|
1303
|
+
personDelegate()["findFirst"]!.mockRejectedValueOnce(
|
|
1304
|
+
new Error('relation "people" does not exist'),
|
|
1305
|
+
);
|
|
1169
1306
|
|
|
1170
1307
|
const failed = await routeLead(leadInput, sourceMeta);
|
|
1171
1308
|
|
|
1172
1309
|
// The answer is the same as a successful capture, apart from the internal
|
|
1173
1310
|
// `personId` (#8438 D11) the route never puts on a wire.
|
|
1174
1311
|
expect(failed).toEqual({ ...ok, personId: null });
|
|
1175
|
-
expect(failed).toMatchObject({
|
|
1312
|
+
expect(failed).toMatchObject({
|
|
1313
|
+
ok: true,
|
|
1314
|
+
id: LEAD_ID,
|
|
1315
|
+
persisted: "db",
|
|
1316
|
+
email: { ok: true },
|
|
1317
|
+
});
|
|
1176
1318
|
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1177
|
-
const lines = error.mock.calls.filter(
|
|
1319
|
+
const lines = error.mock.calls.filter(
|
|
1320
|
+
([fields]) => (fields as { event?: string }).event === "person-failed",
|
|
1321
|
+
);
|
|
1178
1322
|
expect(lines).toHaveLength(1);
|
|
1179
|
-
expect(lines[0]![0]).toEqual({
|
|
1323
|
+
expect(lines[0]![0]).toEqual({
|
|
1324
|
+
event: "person-failed",
|
|
1325
|
+
siteId: "site-abc",
|
|
1326
|
+
});
|
|
1180
1327
|
expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
|
|
1181
1328
|
});
|
|
1182
1329
|
});
|
|
@@ -1204,7 +1351,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1204
1351
|
siteId: WS,
|
|
1205
1352
|
source: "web",
|
|
1206
1353
|
email: "cleo@x.io",
|
|
1207
|
-
payload: {
|
|
1354
|
+
payload: {
|
|
1355
|
+
site: "wendi",
|
|
1356
|
+
page: "checkout",
|
|
1357
|
+
offering: "wendi_retainer",
|
|
1358
|
+
} as Record<string, unknown>,
|
|
1208
1359
|
};
|
|
1209
1360
|
let setPlan: jest.SpyInstance;
|
|
1210
1361
|
let info: jest.SpyInstance;
|
|
@@ -1220,10 +1371,19 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1220
1371
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1221
1372
|
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1222
1373
|
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1223
|
-
mockSendLeadNotification.mockResolvedValue({
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1374
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1375
|
+
ok: true,
|
|
1376
|
+
messageId: "msg-7877",
|
|
1377
|
+
});
|
|
1378
|
+
setPlan = jest
|
|
1379
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
1380
|
+
.mockResolvedValue("set");
|
|
1381
|
+
info = jest
|
|
1382
|
+
.spyOn(logger, "info")
|
|
1383
|
+
.mockImplementation(() => undefined as never);
|
|
1384
|
+
error = jest
|
|
1385
|
+
.spyOn(logger, "error")
|
|
1386
|
+
.mockImplementation(() => undefined as never);
|
|
1227
1387
|
});
|
|
1228
1388
|
|
|
1229
1389
|
afterEach(() => {
|
|
@@ -1242,16 +1402,23 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1242
1402
|
.filter((fields) => fields.event === "checkout-plan");
|
|
1243
1403
|
|
|
1244
1404
|
it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
|
|
1245
|
-
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1405
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1406
|
+
idempotencyKey: KEY,
|
|
1407
|
+
});
|
|
1246
1408
|
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1247
1409
|
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1248
1410
|
expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
|
|
1249
|
-
expect(planLines()).toEqual([
|
|
1411
|
+
expect(planLines()).toEqual([
|
|
1412
|
+
{ event: "checkout-plan", outcome: "set", siteId: WS },
|
|
1413
|
+
]);
|
|
1250
1414
|
});
|
|
1251
1415
|
|
|
1252
1416
|
it("a bare slug offering is taken as it is", async () => {
|
|
1253
1417
|
await routeLead(
|
|
1254
|
-
{
|
|
1418
|
+
{
|
|
1419
|
+
...checkoutLead,
|
|
1420
|
+
payload: { ...checkoutLead.payload, offering: "retainer" },
|
|
1421
|
+
},
|
|
1255
1422
|
spokeMeta,
|
|
1256
1423
|
{ idempotencyKey: KEY },
|
|
1257
1424
|
);
|
|
@@ -1260,7 +1427,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1260
1427
|
|
|
1261
1428
|
it("a positive integer amountTotal still qualifies", async () => {
|
|
1262
1429
|
await routeLead(
|
|
1263
|
-
{
|
|
1430
|
+
{
|
|
1431
|
+
...checkoutLead,
|
|
1432
|
+
payload: { ...checkoutLead.payload, amountTotal: 4900 },
|
|
1433
|
+
},
|
|
1264
1434
|
spokeMeta,
|
|
1265
1435
|
{ idempotencyKey: KEY },
|
|
1266
1436
|
);
|
|
@@ -1278,16 +1448,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1278
1448
|
],
|
|
1279
1449
|
[
|
|
1280
1450
|
"a negative amountTotal",
|
|
1281
|
-
{
|
|
1451
|
+
{
|
|
1452
|
+
...checkoutLead,
|
|
1453
|
+
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1454
|
+
},
|
|
1282
1455
|
spokeMeta,
|
|
1283
1456
|
KEY,
|
|
1284
1457
|
],
|
|
1285
|
-
[
|
|
1458
|
+
[
|
|
1459
|
+
"a key that is not a session id",
|
|
1460
|
+
checkoutLead,
|
|
1461
|
+
spokeMeta,
|
|
1462
|
+
"abcdefghijklmnop",
|
|
1463
|
+
],
|
|
1286
1464
|
["no key", checkoutLead, spokeMeta, undefined],
|
|
1287
1465
|
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1288
1466
|
[
|
|
1289
1467
|
"page contact",
|
|
1290
|
-
{
|
|
1468
|
+
{
|
|
1469
|
+
...checkoutLead,
|
|
1470
|
+
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1471
|
+
},
|
|
1291
1472
|
spokeMeta,
|
|
1292
1473
|
KEY,
|
|
1293
1474
|
],
|
|
@@ -1313,7 +1494,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1313
1494
|
// checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
|
|
1314
1495
|
// relayed". An unmarked create names no `spokeCheckout` key at all.
|
|
1315
1496
|
const createData = () =>
|
|
1316
|
-
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
|
|
1497
|
+
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
|
|
1498
|
+
string,
|
|
1499
|
+
unknown
|
|
1500
|
+
>;
|
|
1317
1501
|
|
|
1318
1502
|
it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
|
|
1319
1503
|
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
@@ -1322,7 +1506,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1322
1506
|
});
|
|
1323
1507
|
|
|
1324
1508
|
it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
|
|
1325
|
-
await routeLead(
|
|
1509
|
+
await routeLead(
|
|
1510
|
+
checkoutLead,
|
|
1511
|
+
{ ...spokeMeta, relayed: true },
|
|
1512
|
+
{ idempotencyKey: KEY },
|
|
1513
|
+
);
|
|
1326
1514
|
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1327
1515
|
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1328
1516
|
});
|
|
@@ -1338,16 +1526,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1338
1526
|
],
|
|
1339
1527
|
[
|
|
1340
1528
|
"a negative amountTotal",
|
|
1341
|
-
{
|
|
1529
|
+
{
|
|
1530
|
+
...checkoutLead,
|
|
1531
|
+
payload: { ...checkoutLead.payload, amountTotal: -5 },
|
|
1532
|
+
},
|
|
1342
1533
|
spokeMeta,
|
|
1343
1534
|
KEY,
|
|
1344
1535
|
],
|
|
1345
|
-
[
|
|
1536
|
+
[
|
|
1537
|
+
"a key that is not a session id",
|
|
1538
|
+
checkoutLead,
|
|
1539
|
+
spokeMeta,
|
|
1540
|
+
"abcdefghijklmnop",
|
|
1541
|
+
],
|
|
1346
1542
|
["no key", checkoutLead, spokeMeta, undefined],
|
|
1347
1543
|
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1348
1544
|
[
|
|
1349
1545
|
"page contact",
|
|
1350
|
-
{
|
|
1546
|
+
{
|
|
1547
|
+
...checkoutLead,
|
|
1548
|
+
payload: { ...checkoutLead.payload, page: "contact" },
|
|
1549
|
+
},
|
|
1351
1550
|
spokeMeta,
|
|
1352
1551
|
KEY,
|
|
1353
1552
|
],
|
|
@@ -1357,24 +1556,32 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1357
1556
|
spokeMeta,
|
|
1358
1557
|
KEY,
|
|
1359
1558
|
],
|
|
1360
|
-
] as const)(
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1559
|
+
] as const)(
|
|
1560
|
+
"no spokeCheckout key in the create data for %s",
|
|
1561
|
+
async (_label, input, meta, key) => {
|
|
1562
|
+
await routeLead(
|
|
1563
|
+
input as typeof checkoutLead,
|
|
1564
|
+
meta,
|
|
1565
|
+
key === undefined ? {} : { idempotencyKey: key },
|
|
1566
|
+
);
|
|
1567
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1568
|
+
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1569
|
+
},
|
|
1570
|
+
);
|
|
1369
1571
|
|
|
1370
1572
|
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1371
1573
|
await routeLead(
|
|
1372
|
-
{
|
|
1574
|
+
{
|
|
1575
|
+
...checkoutLead,
|
|
1576
|
+
payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
|
|
1577
|
+
},
|
|
1373
1578
|
spokeMeta,
|
|
1374
1579
|
{ idempotencyKey: KEY },
|
|
1375
1580
|
);
|
|
1376
1581
|
expect(setPlan).not.toHaveBeenCalled();
|
|
1377
|
-
expect(planLines()).toEqual([
|
|
1582
|
+
expect(planLines()).toEqual([
|
|
1583
|
+
{ event: "checkout-plan", outcome: "skipped", siteId: WS },
|
|
1584
|
+
]);
|
|
1378
1585
|
});
|
|
1379
1586
|
|
|
1380
1587
|
it("a replayed key writes nothing and sets nothing", async () => {
|
|
@@ -1384,7 +1591,9 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1384
1591
|
source: checkoutLead.source,
|
|
1385
1592
|
payload: checkoutLead.payload,
|
|
1386
1593
|
});
|
|
1387
|
-
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1594
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1595
|
+
idempotencyKey: KEY,
|
|
1596
|
+
});
|
|
1388
1597
|
expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
|
|
1389
1598
|
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1390
1599
|
expect(setPlan).not.toHaveBeenCalled();
|
|
@@ -1393,18 +1602,729 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1393
1602
|
it.each([
|
|
1394
1603
|
["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
|
|
1395
1604
|
["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
|
|
1396
|
-
])(
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
+
});
|
|
1629
|
+
|
|
1630
|
+
describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
|
|
1631
|
+
const ENV_KEYS = [
|
|
1632
|
+
"DATABASE_URL",
|
|
1633
|
+
"NODE_ENV",
|
|
1634
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
1635
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1636
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
1637
|
+
] as const;
|
|
1638
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1639
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000008757";
|
|
1640
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008757";
|
|
1641
|
+
const KEY = "cs_live_d14Purchase"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8757 AC-11), not a credential
|
|
1642
|
+
const ORIGIN = "https://spoke.example";
|
|
1643
|
+
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
1644
|
+
const checkoutLead = {
|
|
1645
|
+
kind: "submit" as const,
|
|
1646
|
+
siteId: WS,
|
|
1647
|
+
source: "web",
|
|
1648
|
+
email: "Cleo@X.io",
|
|
1649
|
+
payload: {
|
|
1650
|
+
site: "wendi",
|
|
1651
|
+
page: "checkout",
|
|
1652
|
+
offering: "wendi_retainer",
|
|
1653
|
+
amountTotal: 4900,
|
|
1654
|
+
currency: "usd",
|
|
1655
|
+
} as Record<string, unknown>,
|
|
1656
|
+
};
|
|
1657
|
+
// The lead table's count — the per-recipient cap's one read. The shared
|
|
1658
|
+
// prisma double does not carry it, so this suite arranges it.
|
|
1659
|
+
const leadCount = jest.fn();
|
|
1660
|
+
let setPlan: jest.SpyInstance;
|
|
1661
|
+
let info: jest.SpyInstance;
|
|
1662
|
+
let error: jest.SpyInstance;
|
|
1663
|
+
|
|
1664
|
+
/** Let the fired (void) email step run to its end. */
|
|
1665
|
+
const settle = async () => {
|
|
1666
|
+
for (let i = 0; i < 5; i += 1)
|
|
1667
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1668
|
+
};
|
|
1669
|
+
const interactionCreates = () =>
|
|
1670
|
+
jest.mocked(prisma.interaction.create).mock.calls;
|
|
1671
|
+
const emailCalls = () => mockSendPurchaseConfirmationEmail.mock.calls;
|
|
1672
|
+
|
|
1673
|
+
beforeEach(() => {
|
|
1674
|
+
jest.clearAllMocks();
|
|
1675
|
+
saved = {};
|
|
1676
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1677
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1678
|
+
delete process.env.NODE_ENV;
|
|
1679
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1680
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1681
|
+
delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
|
|
1682
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1683
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1684
|
+
Object.assign(prisma.lead, { count: leadCount });
|
|
1685
|
+
leadCount.mockReset().mockResolvedValue(1);
|
|
1686
|
+
jest
|
|
1687
|
+
.mocked(prisma.interaction.create)
|
|
1688
|
+
.mockResolvedValue({ id: "intx_1" } as never);
|
|
1689
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
1690
|
+
ok: true,
|
|
1691
|
+
messageId: "msg-8757",
|
|
1692
|
+
});
|
|
1693
|
+
mockSendPurchaseConfirmationEmail.mockResolvedValue({
|
|
1694
|
+
ok: true,
|
|
1695
|
+
messageId: "msg-buy",
|
|
1696
|
+
});
|
|
1697
|
+
mockListPlanOfferings.mockResolvedValue([
|
|
1698
|
+
{ key: "retainer", name: "Retainer", type: "service" },
|
|
1699
|
+
]);
|
|
1700
|
+
setPlan = jest
|
|
1701
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
1702
|
+
.mockResolvedValue("set");
|
|
1703
|
+
info = jest
|
|
1704
|
+
.spyOn(logger, "info")
|
|
1705
|
+
.mockImplementation(() => undefined as never);
|
|
1706
|
+
error = jest
|
|
1707
|
+
.spyOn(logger, "error")
|
|
1708
|
+
.mockImplementation(() => undefined as never);
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
afterEach(() => {
|
|
1712
|
+
for (const k of ENV_KEYS) {
|
|
1713
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
1714
|
+
else process.env[k] = saved[k];
|
|
1715
|
+
}
|
|
1716
|
+
Reflect.deleteProperty(prisma.lead, "count");
|
|
1717
|
+
setPlan.mockRestore();
|
|
1718
|
+
info.mockRestore();
|
|
1719
|
+
error.mockRestore();
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
it("the first write sends one purchase email with the spoke's account link and writes one Inbox message", async () => {
|
|
1723
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1724
|
+
idempotencyKey: KEY,
|
|
1725
|
+
accountOrigin: ORIGIN,
|
|
1726
|
+
});
|
|
1727
|
+
await settle();
|
|
1728
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1729
|
+
|
|
1730
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1731
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1732
|
+
expect(email).toMatchObject({
|
|
1733
|
+
to: "Cleo@X.io",
|
|
1734
|
+
fromAddress: "noreply@example.com",
|
|
1735
|
+
subject: "Your purchase",
|
|
1736
|
+
cta: "Open your account",
|
|
1737
|
+
accountLink: `${ORIGIN}/api/hub-session/sign-in?next=%2Faccount&email=Cleo%40X.io`,
|
|
1738
|
+
});
|
|
1739
|
+
expect(email.body).toContain("Retainer");
|
|
1740
|
+
expect(email.body).toContain("$49.00");
|
|
1741
|
+
|
|
1742
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1743
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1744
|
+
{ data: Record<string, unknown> },
|
|
1745
|
+
];
|
|
1746
|
+
expect(data).toEqual({
|
|
1747
|
+
workspaceId: WS,
|
|
1748
|
+
personId: MOCK_PERSON_ID,
|
|
1749
|
+
channel: "member",
|
|
1750
|
+
direction: "outbound",
|
|
1751
|
+
subject: "Your purchase",
|
|
1752
|
+
body: email.body,
|
|
1753
|
+
createdBy: "system:checkout",
|
|
1754
|
+
});
|
|
1755
|
+
expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it("an offering that is not listed is named 'your purchase' in both, never the raw string", async () => {
|
|
1759
|
+
await routeLead(
|
|
1760
|
+
{
|
|
1761
|
+
...checkoutLead,
|
|
1762
|
+
payload: { ...checkoutLead.payload, offering: "wendi_<b>Mystery</b>" },
|
|
1763
|
+
},
|
|
1764
|
+
spokeMeta,
|
|
1765
|
+
{ idempotencyKey: KEY, accountOrigin: ORIGIN },
|
|
1766
|
+
);
|
|
1767
|
+
await settle();
|
|
1768
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1769
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1770
|
+
{ data: Record<string, string> },
|
|
1771
|
+
];
|
|
1772
|
+
for (const body of [email.body, data.body]) {
|
|
1773
|
+
expect(body).toContain("your purchase");
|
|
1774
|
+
expect(body).not.toContain("Mystery");
|
|
1775
|
+
expect(body).not.toContain("wendi_");
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it.each([
|
|
1780
|
+
[
|
|
1781
|
+
"the lister fails",
|
|
1782
|
+
() => mockListPlanOfferings.mockResolvedValueOnce({ failed: "Error" }),
|
|
1783
|
+
],
|
|
1784
|
+
[
|
|
1785
|
+
"the lister throws",
|
|
1786
|
+
() => mockListPlanOfferings.mockRejectedValueOnce(new Error("down")),
|
|
1787
|
+
],
|
|
1788
|
+
])(
|
|
1789
|
+
"when %s, the offering is 'your purchase' and both still go out",
|
|
1790
|
+
async (_label, arrange) => {
|
|
1791
|
+
arrange();
|
|
1792
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1793
|
+
idempotencyKey: KEY,
|
|
1794
|
+
accountOrigin: ORIGIN,
|
|
1795
|
+
});
|
|
1796
|
+
await settle();
|
|
1797
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1798
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1799
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1800
|
+
{ data: Record<string, string> },
|
|
1801
|
+
];
|
|
1802
|
+
expect(data.body).toContain("your purchase");
|
|
1803
|
+
},
|
|
1804
|
+
);
|
|
1805
|
+
|
|
1806
|
+
it("a listed name holding a bidi override comes out with it stripped", async () => {
|
|
1807
|
+
mockListPlanOfferings.mockResolvedValue([
|
|
1808
|
+
{ key: "retainer", name: "Re\u202Etainer\u0007", type: "service" },
|
|
1809
|
+
]);
|
|
1810
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1811
|
+
idempotencyKey: KEY,
|
|
1812
|
+
accountOrigin: ORIGIN,
|
|
1813
|
+
});
|
|
1814
|
+
await settle();
|
|
1815
|
+
const [email] = emailCalls()[0] as [Record<string, string>];
|
|
1816
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1817
|
+
{ data: Record<string, string> },
|
|
1818
|
+
];
|
|
1819
|
+
for (const body of [email.body, data.body]) {
|
|
1820
|
+
expect(body).toContain("Retainer");
|
|
1821
|
+
|
|
1822
|
+
// The two code points by name, not in a regex: a scaffold's standalone
|
|
1823
|
+
// lint refuses a control character in a regular expression.
|
|
1824
|
+
expect(body).not.toContain("\u202E");
|
|
1825
|
+
expect(body).not.toContain("\u0007");
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it("with no amount, the body names the offering alone", async () => {
|
|
1830
|
+
const { amountTotal: _a, currency: _c, ...payload } = checkoutLead.payload;
|
|
1831
|
+
await routeLead({ ...checkoutLead, payload }, spokeMeta, {
|
|
1832
|
+
idempotencyKey: KEY,
|
|
1833
|
+
accountOrigin: ORIGIN,
|
|
1834
|
+
});
|
|
1835
|
+
await settle();
|
|
1836
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1837
|
+
{ data: Record<string, string> },
|
|
1838
|
+
];
|
|
1839
|
+
expect(data.body).toBe("Thanks for your purchase of Retainer.");
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it("a Stripe special-case currency (ISK) leaves the amount out rather than misstate it", async () => {
|
|
1843
|
+
await routeLead(
|
|
1844
|
+
{
|
|
1845
|
+
...checkoutLead,
|
|
1846
|
+
payload: {
|
|
1847
|
+
...checkoutLead.payload,
|
|
1848
|
+
amountTotal: 990000,
|
|
1849
|
+
currency: "isk",
|
|
1850
|
+
},
|
|
1851
|
+
},
|
|
1852
|
+
spokeMeta,
|
|
1853
|
+
{ idempotencyKey: KEY, accountOrigin: ORIGIN },
|
|
1854
|
+
);
|
|
1855
|
+
await settle();
|
|
1856
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1857
|
+
{ data: Record<string, string> },
|
|
1858
|
+
];
|
|
1859
|
+
expect(data.body).toBe("Thanks for your purchase of Retainer.");
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it("an offering read that outlasts its budget names 'your purchase' and does not hold the lead", async () => {
|
|
1863
|
+
mockListPlanOfferings.mockReturnValue(new Promise(() => undefined));
|
|
1864
|
+
const started = Date.now();
|
|
1865
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1866
|
+
idempotencyKey: KEY,
|
|
1867
|
+
accountOrigin: ORIGIN,
|
|
1868
|
+
});
|
|
1869
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
1870
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1871
|
+
const [{ data }] = interactionCreates()[0] as [
|
|
1872
|
+
{ data: Record<string, string> },
|
|
1873
|
+
];
|
|
1874
|
+
expect(data.body).toContain("your purchase");
|
|
1875
|
+
}, 10_000);
|
|
1876
|
+
|
|
1877
|
+
it("with no account origin, the email carries no link", async () => {
|
|
1878
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1879
|
+
await settle();
|
|
1880
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1881
|
+
expect(emailCalls()[0]?.[0]).not.toHaveProperty("accountLink");
|
|
1882
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it("a replay of the same Idempotency-Key makes neither", async () => {
|
|
1886
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1887
|
+
id: LEAD_ID,
|
|
1888
|
+
email: checkoutLead.email,
|
|
1889
|
+
source: checkoutLead.source,
|
|
1890
|
+
payload: checkoutLead.payload,
|
|
1891
|
+
});
|
|
1892
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1893
|
+
idempotencyKey: KEY,
|
|
1894
|
+
accountOrigin: ORIGIN,
|
|
1895
|
+
});
|
|
1896
|
+
await settle();
|
|
1897
|
+
expect(result).toMatchObject({ ok: true, replayed: true });
|
|
1898
|
+
expect(emailCalls()).toHaveLength(0);
|
|
1899
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
it.each([
|
|
1903
|
+
["a test-mode checkout lead", spokeMeta, "cs_test_d14Purchase"],
|
|
1904
|
+
["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
|
|
1905
|
+
["an anonymous (non-spoke) lead", sourceMeta, KEY],
|
|
1906
|
+
] as const)("%s makes neither", async (_label, meta, key) => {
|
|
1907
|
+
const result = await routeLead(checkoutLead, meta, {
|
|
1908
|
+
idempotencyKey: key,
|
|
1909
|
+
accountOrigin: ORIGIN,
|
|
1910
|
+
});
|
|
1911
|
+
await settle();
|
|
1912
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
1913
|
+
expect(emailCalls()).toHaveLength(0);
|
|
1914
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
it("a lead with no Person makes neither", async () => {
|
|
1918
|
+
const find = jest
|
|
1919
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
1920
|
+
.mockResolvedValue({
|
|
1921
|
+
ok: false,
|
|
1922
|
+
error: { kind: "invalid_email" },
|
|
1923
|
+
} as never);
|
|
1924
|
+
try {
|
|
1925
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
1926
|
+
idempotencyKey: KEY,
|
|
1927
|
+
accountOrigin: ORIGIN,
|
|
1928
|
+
});
|
|
1929
|
+
await settle();
|
|
1930
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
1931
|
+
expect(emailCalls()).toHaveLength(0);
|
|
1932
|
+
expect(interactionCreates()).toHaveLength(0);
|
|
1933
|
+
} finally {
|
|
1934
|
+
find.mockRestore();
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it("with five marked leads for the address in the last day, the sixth sends no email and still writes the message", async () => {
|
|
1939
|
+
const now = Date.now();
|
|
1940
|
+
leadCount.mockResolvedValue(6); // the five before, and this one
|
|
1941
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1942
|
+
idempotencyKey: KEY,
|
|
1943
|
+
accountOrigin: ORIGIN,
|
|
1944
|
+
});
|
|
1945
|
+
await settle();
|
|
1946
|
+
expect(emailCalls()).toHaveLength(0);
|
|
1947
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1948
|
+
const [{ where }] = leadCount.mock.calls[0] as [
|
|
1949
|
+
{ where: Record<string, unknown> },
|
|
1950
|
+
];
|
|
1951
|
+
expect(where).toMatchObject({
|
|
1952
|
+
workspaceId: WS,
|
|
1953
|
+
spokeCheckout: true,
|
|
1954
|
+
email: { equals: "Cleo@X.io", mode: "insensitive" },
|
|
1955
|
+
});
|
|
1956
|
+
const since = (where.createdAt as { gte: Date }).gte.getTime();
|
|
1957
|
+
expect(now - since).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000 - 1000);
|
|
1958
|
+
expect(now - since).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000);
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
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 () => {
|
|
1962
|
+
const now = Date.now();
|
|
1963
|
+
// The recipient count first, then the workspace's hourly count.
|
|
1964
|
+
leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(51);
|
|
1965
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1966
|
+
idempotencyKey: KEY,
|
|
1967
|
+
accountOrigin: ORIGIN,
|
|
1968
|
+
});
|
|
1969
|
+
await settle();
|
|
1970
|
+
expect(emailCalls()).toHaveLength(0);
|
|
1971
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
1972
|
+
const [{ where }] = leadCount.mock.calls[1] as [
|
|
1973
|
+
{ where: Record<string, unknown> },
|
|
1974
|
+
];
|
|
1975
|
+
expect(where).toMatchObject({ workspaceId: WS, spokeCheckout: true });
|
|
1976
|
+
expect(where).not.toHaveProperty("email");
|
|
1977
|
+
const since = (where.createdAt as { gte: Date }).gte.getTime();
|
|
1978
|
+
expect(now - since).toBeGreaterThanOrEqual(60 * 60 * 1000 - 1000);
|
|
1979
|
+
expect(now - since).toBeLessThanOrEqual(60 * 60 * 1000 + 5000);
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
it("with exactly 50 in the hour, the email still goes", async () => {
|
|
1983
|
+
leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(50);
|
|
1984
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1985
|
+
idempotencyKey: KEY,
|
|
1986
|
+
accountOrigin: ORIGIN,
|
|
1987
|
+
});
|
|
1988
|
+
await settle();
|
|
1989
|
+
expect(emailCalls()).toHaveLength(1);
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
it("with five in the day including this one, the email still goes", async () => {
|
|
1993
|
+
leadCount.mockResolvedValue(5);
|
|
1994
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
1995
|
+
idempotencyKey: KEY,
|
|
1996
|
+
accountOrigin: ORIGIN,
|
|
1997
|
+
});
|
|
1998
|
+
await settle();
|
|
1999
|
+
expect(emailCalls()).toHaveLength(1);
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it.each([
|
|
2003
|
+
[
|
|
2004
|
+
"the Inbox write throws",
|
|
2005
|
+
() =>
|
|
2006
|
+
jest
|
|
2007
|
+
.mocked(prisma.interaction.create)
|
|
2008
|
+
.mockRejectedValueOnce(new Error("down")),
|
|
2009
|
+
],
|
|
2010
|
+
[
|
|
2011
|
+
"the cap read throws",
|
|
2012
|
+
() => leadCount.mockRejectedValueOnce(new Error("down")),
|
|
2013
|
+
],
|
|
2014
|
+
[
|
|
2015
|
+
"the email send rejects",
|
|
2016
|
+
() =>
|
|
2017
|
+
mockSendPurchaseConfirmationEmail.mockRejectedValueOnce(
|
|
2018
|
+
new Error("down"),
|
|
2019
|
+
),
|
|
2020
|
+
],
|
|
2021
|
+
])(
|
|
2022
|
+
"a purchase step that fails (%s) never changes the lead's answer",
|
|
2023
|
+
async (_label, arrange) => {
|
|
2024
|
+
arrange();
|
|
2025
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2026
|
+
idempotencyKey: KEY,
|
|
2027
|
+
accountOrigin: ORIGIN,
|
|
2028
|
+
});
|
|
2029
|
+
await settle();
|
|
2030
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2031
|
+
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
2032
|
+
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
2033
|
+
expect(logged).not.toContain("Cleo@X.io");
|
|
2034
|
+
expect(logged).not.toContain("retainer");
|
|
2035
|
+
},
|
|
2036
|
+
);
|
|
2037
|
+
|
|
2038
|
+
it("with no sender configured, no email is sent and the message is still written", async () => {
|
|
2039
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
2040
|
+
await routeLead(checkoutLead, spokeMeta, {
|
|
2041
|
+
idempotencyKey: KEY,
|
|
2042
|
+
accountOrigin: ORIGIN,
|
|
2043
|
+
});
|
|
2044
|
+
await settle();
|
|
2045
|
+
expect(emailCalls()).toHaveLength(0);
|
|
2046
|
+
expect(interactionCreates()).toHaveLength(1);
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
describe("#8778 — a spoke checkout's terms grant", () => {
|
|
2051
|
+
const ENV_KEYS = [
|
|
2052
|
+
"DATABASE_URL",
|
|
2053
|
+
"NODE_ENV",
|
|
2054
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
2055
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
2056
|
+
] as const;
|
|
2057
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
2058
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000008778";
|
|
2059
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008778";
|
|
2060
|
+
const KEY = "cs_live_g8778Grant"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8778), not a credential
|
|
2061
|
+
const ACCOUNT_TERMS = consentPurposeById(
|
|
2062
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
2063
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
2064
|
+
)!;
|
|
2065
|
+
const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
|
|
2066
|
+
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
2067
|
+
const checkoutLead = {
|
|
2068
|
+
kind: "submit" as const,
|
|
2069
|
+
siteId: WS,
|
|
2070
|
+
source: "web",
|
|
2071
|
+
email: "Cleo@X.io",
|
|
2072
|
+
payload: {
|
|
2073
|
+
site: "wendi",
|
|
2074
|
+
page: "checkout",
|
|
2075
|
+
offering: "wendi_retainer",
|
|
2076
|
+
amountTotal: 4900,
|
|
2077
|
+
currency: "usd",
|
|
2078
|
+
checkoutSession: KEY,
|
|
2079
|
+
termsAccepted: STAMP,
|
|
2080
|
+
} as Record<string, unknown>,
|
|
2081
|
+
};
|
|
2082
|
+
const leadCount = jest.fn();
|
|
2083
|
+
let double: ConsentLedgerDouble;
|
|
2084
|
+
let setPlan: jest.SpyInstance;
|
|
2085
|
+
let info: jest.SpyInstance;
|
|
2086
|
+
let error: jest.SpyInstance;
|
|
2087
|
+
|
|
2088
|
+
/** The ledger double, plus the per-session replay read it does not model. */
|
|
2089
|
+
function armLedger(): void {
|
|
2090
|
+
double = armConsentLedger(prisma);
|
|
2091
|
+
const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
|
|
2092
|
+
(prisma.$queryRaw as jest.Mock).mockImplementation(
|
|
2093
|
+
(strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
2094
|
+
if (strings.join("?").includes(`"source" = 'checkout'`)) {
|
|
2095
|
+
const [workspaceId, personId, purposeId, sessionId] =
|
|
2096
|
+
values as string[];
|
|
2097
|
+
return Promise.resolve(
|
|
2098
|
+
double.ledger
|
|
2099
|
+
.filter(
|
|
2100
|
+
(r) =>
|
|
2101
|
+
r.workspaceId === workspaceId &&
|
|
2102
|
+
r.personId === personId &&
|
|
2103
|
+
r.purposeId === purposeId &&
|
|
2104
|
+
r.source === "checkout" &&
|
|
2105
|
+
r.sourceRef === sessionId,
|
|
2106
|
+
)
|
|
2107
|
+
.map(() => ({ found: 1 })),
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
return ledgerRead(strings, ...values);
|
|
2111
|
+
},
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
const consentLines = () =>
|
|
2116
|
+
[...info.mock.calls, ...error.mock.calls]
|
|
2117
|
+
.map(([obj]) => obj as Record<string, unknown>)
|
|
2118
|
+
.filter((obj) => obj?.["event"] === "checkout-consent");
|
|
2119
|
+
|
|
2120
|
+
const settle = async () => {
|
|
2121
|
+
for (let i = 0; i < 5; i += 1)
|
|
2122
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
beforeEach(() => {
|
|
2126
|
+
jest.clearAllMocks();
|
|
2127
|
+
saved = {};
|
|
2128
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
2129
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
2130
|
+
delete process.env.NODE_ENV;
|
|
2131
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
2132
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
2133
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
2134
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
2135
|
+
Object.assign(prisma.lead, { count: leadCount });
|
|
2136
|
+
leadCount.mockReset().mockResolvedValue(1);
|
|
2137
|
+
jest
|
|
2138
|
+
.mocked(prisma.interaction.create)
|
|
2139
|
+
.mockResolvedValue({ id: "intx_1" } as never);
|
|
2140
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
|
|
2141
|
+
mockSendPurchaseConfirmationEmail.mockResolvedValue({
|
|
2142
|
+
ok: true,
|
|
2143
|
+
messageId: "m2",
|
|
2144
|
+
});
|
|
2145
|
+
mockListPlanOfferings.mockResolvedValue([]);
|
|
2146
|
+
setPlan = jest
|
|
2147
|
+
.spyOn(personService, "setPersonPlanOffering")
|
|
2148
|
+
.mockResolvedValue("set");
|
|
2149
|
+
info = jest
|
|
2150
|
+
.spyOn(logger, "info")
|
|
2151
|
+
.mockImplementation(() => undefined as never);
|
|
2152
|
+
error = jest
|
|
2153
|
+
.spyOn(logger, "error")
|
|
2154
|
+
.mockImplementation(() => undefined as never);
|
|
2155
|
+
armLedger();
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
afterEach(() => {
|
|
2159
|
+
for (const k of ENV_KEYS) {
|
|
2160
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
2161
|
+
else process.env[k] = saved[k];
|
|
2162
|
+
}
|
|
2163
|
+
Reflect.deleteProperty(prisma.lead, "count");
|
|
2164
|
+
for (const fn of [
|
|
2165
|
+
prisma.$transaction,
|
|
2166
|
+
prisma.$executeRaw,
|
|
2167
|
+
prisma.$queryRaw,
|
|
2168
|
+
prisma.consentGrant.createMany,
|
|
2169
|
+
prisma.consentVisitorLink.createMany,
|
|
2170
|
+
]) {
|
|
2171
|
+
(fn as jest.Mock).mockReset();
|
|
2172
|
+
}
|
|
2173
|
+
setPlan.mockRestore();
|
|
2174
|
+
info.mockRestore();
|
|
2175
|
+
error.mockRestore();
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
it("a lead whose session accepted the current terms writes one dated checkout grant for the buyer's Person", async () => {
|
|
2179
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2180
|
+
idempotencyKey: KEY,
|
|
2181
|
+
});
|
|
2182
|
+
await settle();
|
|
2183
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2184
|
+
|
|
2185
|
+
expect(double.ledger).toHaveLength(1);
|
|
2186
|
+
const [row] = double.ledger;
|
|
2187
|
+
expect(row).toMatchObject({
|
|
2188
|
+
workspaceId: WS,
|
|
2189
|
+
subjectType: "person",
|
|
2190
|
+
subjectId: MOCK_PERSON_ID,
|
|
2191
|
+
personId: MOCK_PERSON_ID,
|
|
2192
|
+
purposeId: ACCOUNT_TERMS_PURPOSE_ID,
|
|
2193
|
+
granted: true,
|
|
2194
|
+
source: "checkout",
|
|
2195
|
+
sourceRef: KEY,
|
|
2196
|
+
policyVersion: ACCOUNT_TERMS.policyVersion,
|
|
2197
|
+
locale: "en",
|
|
2198
|
+
});
|
|
2199
|
+
expect(row!.createdAt).toBeInstanceOf(Date);
|
|
2200
|
+
expect(consentLines()).toEqual([
|
|
2201
|
+
expect.objectContaining({ outcome: "recorded", siteId: WS }),
|
|
2202
|
+
]);
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
it.each([
|
|
2206
|
+
["termsAccepted: null (the box was not accepted)", null, "consent_absent"],
|
|
2207
|
+
["no termsAccepted key (an older spoke)", undefined, "consent_absent"],
|
|
2208
|
+
["an older version's stamp", `${ACCOUNT_TERMS_PURPOSE_ID}@1`, "terms_unstamped"],
|
|
2209
|
+
["another purpose's stamp", "wt_platform_terms@1", "terms_unstamped"],
|
|
2210
|
+
["a non-string value", true, "terms_unstamped"],
|
|
2211
|
+
])("a lead with %s writes no grant", async (_label, terms, outcome) => {
|
|
2212
|
+
const { termsAccepted: _t, ...rest } = checkoutLead.payload;
|
|
2213
|
+
const payload = terms === undefined ? rest : { ...rest, termsAccepted: terms };
|
|
2214
|
+
const result = await routeLead({ ...checkoutLead, payload }, spokeMeta, {
|
|
2215
|
+
idempotencyKey: KEY,
|
|
2216
|
+
});
|
|
2217
|
+
await settle();
|
|
2218
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2219
|
+
expect(double.ledger).toHaveLength(0);
|
|
2220
|
+
expect(consentLines()).toEqual([expect.objectContaining({ outcome })]);
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
it("a replayed spoke webhook (the same Idempotency-Key and body) writes no second grant", async () => {
|
|
2224
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2225
|
+
expect(double.ledger).toHaveLength(1);
|
|
2226
|
+
|
|
2227
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2228
|
+
id: LEAD_ID,
|
|
2229
|
+
email: checkoutLead.email,
|
|
2230
|
+
source: checkoutLead.source,
|
|
2231
|
+
payload: checkoutLead.payload,
|
|
2232
|
+
});
|
|
2233
|
+
const replay = await routeLead(checkoutLead, spokeMeta, {
|
|
2234
|
+
idempotencyKey: KEY,
|
|
2235
|
+
});
|
|
2236
|
+
await settle();
|
|
2237
|
+
expect(replay).toMatchObject({ ok: true, replayed: true });
|
|
2238
|
+
expect(double.ledger).toHaveLength(1);
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
it("a redelivery whose stored body predates termsAccepted is a replay, not a conflict, and writes no grant", async () => {
|
|
2242
|
+
const { termsAccepted: _t, ...storedPayload } = checkoutLead.payload;
|
|
2243
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2244
|
+
id: LEAD_ID,
|
|
2245
|
+
email: checkoutLead.email,
|
|
2246
|
+
source: checkoutLead.source,
|
|
2247
|
+
payload: storedPayload,
|
|
2248
|
+
});
|
|
2249
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2250
|
+
idempotencyKey: KEY,
|
|
2251
|
+
});
|
|
2252
|
+
await settle();
|
|
2253
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, replayed: true });
|
|
2254
|
+
expect(double.ledger).toHaveLength(0);
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
it("a redelivery that differs in any other payload key is still a conflict", async () => {
|
|
2258
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
2259
|
+
id: LEAD_ID,
|
|
2260
|
+
email: checkoutLead.email,
|
|
2261
|
+
source: checkoutLead.source,
|
|
2262
|
+
payload: { ...checkoutLead.payload, amountTotal: 1 },
|
|
2263
|
+
});
|
|
2264
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2265
|
+
idempotencyKey: KEY,
|
|
2266
|
+
});
|
|
2267
|
+
expect(result).toMatchObject({
|
|
2268
|
+
ok: false,
|
|
2269
|
+
error: { kind: "idempotency_conflict" },
|
|
2270
|
+
});
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
it("a second write for a session whose grant the Person already holds writes none (the store's own guard)", async () => {
|
|
2274
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2275
|
+
// The lead row lost (the lookup misses), the ledger kept: the grant is
|
|
2276
|
+
// still written at most once per Person and session.
|
|
2277
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2278
|
+
await settle();
|
|
2279
|
+
expect(double.ledger).toHaveLength(1);
|
|
2280
|
+
expect(consentLines().map((l) => l["outcome"])).toEqual([
|
|
2281
|
+
"recorded",
|
|
2282
|
+
"unchanged",
|
|
2283
|
+
]);
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
it.each([
|
|
2287
|
+
["a test-mode checkout lead", spokeMeta, "cs_test_g8778Grant"],
|
|
2288
|
+
["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
|
|
2289
|
+
["an anonymous (non-spoke) lead", sourceMeta, KEY],
|
|
2290
|
+
] as const)("%s writes no grant", async (_label, meta, key) => {
|
|
2291
|
+
const result = await routeLead(checkoutLead, meta, { idempotencyKey: key });
|
|
2292
|
+
await settle();
|
|
2293
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
2294
|
+
expect(double.ledger).toHaveLength(0);
|
|
2295
|
+
expect(consentLines()).toEqual([]);
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
it("a lead with no Person writes no grant", async () => {
|
|
2299
|
+
const find = jest
|
|
2300
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
2301
|
+
.mockResolvedValue({
|
|
2302
|
+
ok: false,
|
|
2303
|
+
error: { kind: "invalid_email" },
|
|
2304
|
+
} as never);
|
|
2305
|
+
try {
|
|
2306
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
2307
|
+
await settle();
|
|
2308
|
+
expect(double.ledger).toHaveLength(0);
|
|
2309
|
+
} finally {
|
|
2310
|
+
find.mockRestore();
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
it("a ledger write that fails never changes the lead's answer, and logs no address or Person id", async () => {
|
|
2315
|
+
(prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(
|
|
2316
|
+
new Error("down Cleo@X.io"),
|
|
2317
|
+
);
|
|
2318
|
+
const result = await routeLead(checkoutLead, spokeMeta, {
|
|
2319
|
+
idempotencyKey: KEY,
|
|
2320
|
+
});
|
|
2321
|
+
await settle();
|
|
2322
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
2323
|
+
expect(consentLines()).toEqual([
|
|
2324
|
+
expect.objectContaining({ outcome: "failed", errName: "Error" }),
|
|
2325
|
+
]);
|
|
1405
2326
|
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
1406
|
-
expect(logged).not.toContain("
|
|
1407
|
-
expect(logged).not.toContain(
|
|
1408
|
-
expect(logged).not.toContain(KEY);
|
|
2327
|
+
expect(logged).not.toContain("Cleo@X.io");
|
|
2328
|
+
expect(logged).not.toContain(MOCK_PERSON_ID);
|
|
1409
2329
|
});
|
|
1410
2330
|
});
|