create-ailk 0.17.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -1,1620 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file index.test.ts
|
|
3
|
-
* Route-level inject tests for POST /api/leads/:kind
|
|
4
|
-
*
|
|
5
|
-
* Property under test: attacker-supplied `id` in the request body is never
|
|
6
|
-
* persisted. buildInput() explicitly destructures only known fields (first wall).
|
|
7
|
-
* The discriminatedUnion .strict() schema rejects unknown keys (second wall).
|
|
8
|
-
*
|
|
9
|
-
* These tests exercise both walls together at the HTTP boundary.
|
|
10
|
-
*
|
|
11
|
-
* Real dependencies (Issue #507):
|
|
12
|
-
* - appendLeadRecord (lead-capture-store): real — writes to LEAD_CAPTURE_FILE temp file
|
|
13
|
-
* - writeLeadFallback (file-fallback): real — writes to AILK_INBOX_FALLBACK_DIR temp dir
|
|
14
|
-
* - enrichLead (enrichment-shim): real — pass-through shim (no network calls in tests)
|
|
15
|
-
*
|
|
16
|
-
* Mocked:
|
|
17
|
-
* - prisma (lib/prisma.js): manual mock via __mocks__/prisma.ts — inbox models added
|
|
18
|
-
* - @working-theory/email: external package boundary — stays mocked
|
|
19
|
-
* - @working-theory/license: consumed by enrichment-shim; stays mocked to avoid license key dep
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import * as fs from "fs/promises";
|
|
23
|
-
|
|
24
|
-
import type { FastifyInstance } from "fastify";
|
|
25
|
-
|
|
26
|
-
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
27
|
-
jest.mock("../../../lib/prisma.js");
|
|
28
|
-
|
|
29
|
-
jest.mock("@working-theory/email", () => ({
|
|
30
|
-
sendLeadNotification: jest.fn().mockResolvedValue({
|
|
31
|
-
ok: true,
|
|
32
|
-
messageId: "msg-test",
|
|
33
|
-
}),
|
|
34
|
-
// #5970 — the request-and-confirm tenant sender. Declared here because the
|
|
35
|
-
// callback route now reaches it, and because without it the feature-ON cases
|
|
36
|
-
// below would pass for the wrong reason: with the flag unset,
|
|
37
|
-
// `sendRequestConfirmLink` no-ops before it ever touches this import.
|
|
38
|
-
sendRequestConfirmEmail: jest.fn().mockResolvedValue({
|
|
39
|
-
ok: true,
|
|
40
|
-
messageId: "msg-confirm",
|
|
41
|
-
}),
|
|
42
|
-
sendRequestPaidLinkEmail: jest.fn(),
|
|
43
|
-
}));
|
|
44
|
-
|
|
45
|
-
// @working-theory/license: consumed transitively by enrichment-shim; mocked to avoid
|
|
46
|
-
// needing a license key in tests.
|
|
47
|
-
jest.mock("@working-theory/license", () => ({
|
|
48
|
-
checkLicense: jest.fn().mockResolvedValue({
|
|
49
|
-
tier: "free",
|
|
50
|
-
plan: null,
|
|
51
|
-
features: [],
|
|
52
|
-
expiresAt: null,
|
|
53
|
-
revoked: false,
|
|
54
|
-
}),
|
|
55
|
-
}));
|
|
56
|
-
|
|
57
|
-
// #7869 — the Idempotency-Key suite asserts a replay records no new capture
|
|
58
|
-
// event; `track` is fire-and-forget and never throws, so mocking it changes
|
|
59
|
-
// nothing for the cases above.
|
|
60
|
-
jest.mock("@working-theory/analytics/server", () => ({ track: jest.fn() }));
|
|
61
|
-
|
|
62
|
-
import {
|
|
63
|
-
leadSubmitRoute,
|
|
64
|
-
leadSubscribeRoute,
|
|
65
|
-
leadCallbackRoute,
|
|
66
|
-
} from "../index";
|
|
67
|
-
import {
|
|
68
|
-
MOCK_PERSON_ID,
|
|
69
|
-
mockNoWorkspace,
|
|
70
|
-
mockSentinelWorkspaceExists,
|
|
71
|
-
} from "../../../lib/__mocks__/prisma.js";
|
|
72
|
-
import { prisma } from "../../../lib/prisma.js";
|
|
73
|
-
import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
|
|
74
|
-
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
75
|
-
// #3979 AC1 — the single shared fixture (packages/validation, which apps/api
|
|
76
|
-
// may_import_from per architecture.yaml) for the NewsletterFormInline
|
|
77
|
-
// emitted-body shape, also consumed by the layer-a block test
|
|
78
|
-
// (packages/ui/src/blocks/__tests__/NewsletterFormInline.test.tsx), so a
|
|
79
|
-
// change to the block's emitted keys can't silently leave this layer
|
|
80
|
-
// asserting a stale contract. #4017 D4 extends the same fixture module with
|
|
81
|
-
// the ContactForm `submit`-kind body, consumed by the layer-a section test
|
|
82
|
-
// (packages/ui/src/sections/__tests__/ContactForm.test.tsx).
|
|
83
|
-
import {
|
|
84
|
-
AGENCY_WAITLIST_LEAD_BODY,
|
|
85
|
-
CONTACT_FORM_LEAD_BODY,
|
|
86
|
-
NEWSLETTER_DEFAULT_LEAD_BODY,
|
|
87
|
-
} from "../../../../../../packages/validation/src/__tests__/fixtures/newsletter-lead-body.fixture.js";
|
|
88
|
-
|
|
89
|
-
const fileEnv = setupLeadFileEnv("index");
|
|
90
|
-
|
|
91
|
-
// #6203 — every lead-capture kind now resolves its tenant SERVER-SIDE
|
|
92
|
-
// (routes/leads/index.ts), never from a body-supplied `siteId`. A test's
|
|
93
|
-
// body may still send a `siteId` (a client-embed artifact, or a spoof
|
|
94
|
-
// attempt) but it is silently ignored — the SEEDED default workspace id
|
|
95
|
-
// (`database/tenancy.ts DEFAULT_WORKSPACE_ID`) is what actually lands, on
|
|
96
|
-
// both `siteId` and the new `workspaceId` column.
|
|
97
|
-
//
|
|
98
|
-
// #6335 — that resolution used to come off the shared prisma mock's
|
|
99
|
-
// PERMISSIVE `workspace.findUnique` default, which resolved any id it was
|
|
100
|
-
// handed. The default is gone, so this suite states the workspace it
|
|
101
|
-
// expects: `stateSentinelWorkspaceExists()` below, called wherever this
|
|
102
|
-
// file clears its mocks.
|
|
103
|
-
const RESOLVED_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* A second, real workspace the deployment is NOT serving. The three spoof
|
|
107
|
-
* cases below make it resolvable for their own duration, so "the row did
|
|
108
|
-
* not land there" is a statement about what the route READ rather than a
|
|
109
|
-
* side effect of the id resolving to nothing.
|
|
110
|
-
*/
|
|
111
|
-
const FOREIGN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-00000000dead";
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* #6335 — the ONE workspace every case in this file expects to exist: the
|
|
115
|
-
* seeded sentinel `currentSiteId()`'s "default" alias names. Keyed on the
|
|
116
|
-
* requested id inside the helper, so a body-supplied foreign `siteId`
|
|
117
|
-
* resolves to nothing — which is what makes the spoof cases below prove
|
|
118
|
-
* the route never reads it, rather than proving a permissive mock resolves
|
|
119
|
-
* everything. The resolver's memo is cleared alongside, so no case answers
|
|
120
|
-
* out of a previous one's successful resolution.
|
|
121
|
-
*/
|
|
122
|
-
function stateSentinelWorkspaceExists(): void {
|
|
123
|
-
__resetSiteWorkspaceCacheForTests();
|
|
124
|
-
mockSentinelWorkspaceExists(prisma);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ─── Suite setup ──────────────────────────────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
describe("POST /api/leads — id isolation", () => {
|
|
130
|
-
let app: FastifyInstance;
|
|
131
|
-
|
|
132
|
-
beforeEach(async () => {
|
|
133
|
-
jest.clearAllMocks();
|
|
134
|
-
stateSentinelWorkspaceExists();
|
|
135
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
136
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
137
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
138
|
-
|
|
139
|
-
const fastify = require("fastify")();
|
|
140
|
-
await fastify.register(leadSubmitRoute);
|
|
141
|
-
await fastify.register(leadSubscribeRoute);
|
|
142
|
-
await fastify.register(leadCallbackRoute);
|
|
143
|
-
app = fastify;
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
afterEach(async () => {
|
|
147
|
-
await app.close();
|
|
148
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
149
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
150
|
-
delete process.env.DATABASE_URL;
|
|
151
|
-
delete process.env.LEAD_CAPTURE_FILE_DISABLED;
|
|
152
|
-
delete process.env.LEAD_CAPTURE_INCLUDE_IP;
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("POST /api/leads/submit — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
156
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a1";
|
|
157
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
158
|
-
id: serverGeneratedId,
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const res = await app.inject({
|
|
162
|
-
method: "POST",
|
|
163
|
-
url: "/v1/leads/submit",
|
|
164
|
-
payload: {
|
|
165
|
-
id: "attacker",
|
|
166
|
-
kind: "submit",
|
|
167
|
-
siteId: "site-abc",
|
|
168
|
-
source: "contact",
|
|
169
|
-
email: "test@example.com",
|
|
170
|
-
payload: { message: "hello" },
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// buildInput drops `id` before it reaches validation or persistence.
|
|
175
|
-
// The schema's .strict() would also reject it — both walls active.
|
|
176
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
177
|
-
if (res.statusCode === 201) {
|
|
178
|
-
const body = res.json();
|
|
179
|
-
expect(body.id).not.toBe("attacker");
|
|
180
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("POST /api/leads/subscribe — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
185
|
-
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000a2";
|
|
186
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
187
|
-
id: serverGeneratedId,
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
const res = await app.inject({
|
|
191
|
-
method: "POST",
|
|
192
|
-
url: "/v1/leads/subscribe",
|
|
193
|
-
payload: {
|
|
194
|
-
id: "attacker",
|
|
195
|
-
kind: "subscribe",
|
|
196
|
-
siteId: "site-abc",
|
|
197
|
-
email: "sub@example.com",
|
|
198
|
-
source: "footer",
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
203
|
-
if (res.statusCode === 201) {
|
|
204
|
-
const body = res.json();
|
|
205
|
-
expect(body.id).not.toBe("attacker");
|
|
206
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// #3965 D4 layer (b) — the block's exact emitted shape (layer (a) pins it
|
|
211
|
-
// as { siteId, source, email } via application/json) persists through the
|
|
212
|
-
// REAL registered /v1/leads/subscribe plugin. #3979 AC1 retargets the body
|
|
213
|
-
// literal at the shared fixture (packages/validation) — it had already
|
|
214
|
-
// drifted from layer-a's own hand-copied literal (different `email`
|
|
215
|
-
// values), with nothing catching it.
|
|
216
|
-
it("POST /v1/leads/subscribe — the block's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
|
|
217
|
-
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000b1";
|
|
218
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
219
|
-
id: serverGeneratedId,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// The exact body NewsletterFormInline emits at its defaults (#3965 D1) —
|
|
223
|
-
// no `kind` key: buildInput() injects `kind` server-side from the route,
|
|
224
|
-
// never from the body.
|
|
225
|
-
const res = await app.inject({
|
|
226
|
-
method: "POST",
|
|
227
|
-
url: "/v1/leads/subscribe",
|
|
228
|
-
headers: { "content-type": "application/json" },
|
|
229
|
-
payload: JSON.stringify(NEWSLETTER_DEFAULT_LEAD_BODY),
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
expect(res.statusCode).toBe(201);
|
|
233
|
-
const body = res.json();
|
|
234
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
235
|
-
expect(body.persisted).toBe("db");
|
|
236
|
-
expect(prisma.subscriber.create).toHaveBeenCalledTimes(1);
|
|
237
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
238
|
-
expect(record.id).toBe(serverGeneratedId);
|
|
239
|
-
expect(record.source).toBe(NEWSLETTER_DEFAULT_LEAD_BODY.source);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// #3979 AC2 — the agency-waitlist instance (content/en/LandingPage/
|
|
243
|
-
// agency-waitlist.mdx: action "/v1/leads/submit", source "agency-waitlist",
|
|
244
|
-
// siteId unset → "default") had NO layer-b case at all before this issue —
|
|
245
|
-
// only /v1/leads/subscribe was covered. Unconditional 201 (not the
|
|
246
|
-
// permissive [201, 400] idiom used by the id-isolation tests above), since
|
|
247
|
-
// this asserts the block's real emitted shape is accepted, not merely that
|
|
248
|
-
// the id-isolation walls hold regardless of validation outcome.
|
|
249
|
-
it("POST /v1/leads/submit — the agency-waitlist instance's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
|
|
250
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b2";
|
|
251
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
252
|
-
id: serverGeneratedId,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// The exact body NewsletterFormInline emits for the agency-waitlist
|
|
256
|
-
// instance — no `kind`, no `payload` key: buildInput() injects `kind`
|
|
257
|
-
// server-side from the route and defaults `payload` to {} when absent.
|
|
258
|
-
const res = await app.inject({
|
|
259
|
-
method: "POST",
|
|
260
|
-
url: "/v1/leads/submit",
|
|
261
|
-
headers: { "content-type": "application/json" },
|
|
262
|
-
payload: JSON.stringify(AGENCY_WAITLIST_LEAD_BODY),
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
expect(res.statusCode).toBe(201);
|
|
266
|
-
const body = res.json();
|
|
267
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
268
|
-
expect(body.persisted).toBe("db");
|
|
269
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
270
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
271
|
-
expect(record.id).toBe(serverGeneratedId);
|
|
272
|
-
expect(record.source).toBe(AGENCY_WAITLIST_LEAD_BODY.source);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// #4017 D4 layer (b) — the ContactForm section's exact emitted shape
|
|
276
|
-
// (layer (a) pins it as { siteId, source, email, payload } via
|
|
277
|
-
// application/json) persists through the REAL registered
|
|
278
|
-
// /v1/leads/submit plugin. `email` is lifted by field name; every other
|
|
279
|
-
// declared field lands in `payload` — the shared fixture (packages/validation)
|
|
280
|
-
// is the single definition both layers assert against (#3979 AC1's
|
|
281
|
-
// pattern), so a change to the section's emitted keys can't silently leave
|
|
282
|
-
// this layer asserting a stale contract.
|
|
283
|
-
it("POST /v1/leads/submit — the ContactForm section's emitted JSON shape ({ siteId, source, email, payload }) is accepted and persisted", async () => {
|
|
284
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b3";
|
|
285
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
286
|
-
id: serverGeneratedId,
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// The exact body ContactForm emits at its component defaults (#4017 D1) —
|
|
290
|
-
// no `kind` key: buildInput() injects `kind` server-side from the route,
|
|
291
|
-
// never from the body.
|
|
292
|
-
const res = await app.inject({
|
|
293
|
-
method: "POST",
|
|
294
|
-
url: "/v1/leads/submit",
|
|
295
|
-
headers: { "content-type": "application/json" },
|
|
296
|
-
payload: JSON.stringify(CONTACT_FORM_LEAD_BODY),
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
expect(res.statusCode).toBe(201);
|
|
300
|
-
const body = res.json();
|
|
301
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
302
|
-
expect(body.persisted).toBe("db");
|
|
303
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
304
|
-
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
305
|
-
data: {
|
|
306
|
-
// #6203 — the RESOLVED workspace, never the body's own `siteId`.
|
|
307
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
308
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
309
|
-
source: CONTACT_FORM_LEAD_BODY.source,
|
|
310
|
-
email: CONTACT_FORM_LEAD_BODY.email,
|
|
311
|
-
payload: CONTACT_FORM_LEAD_BODY.payload,
|
|
312
|
-
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
313
|
-
personId: MOCK_PERSON_ID,
|
|
314
|
-
},
|
|
315
|
-
select: { id: true },
|
|
316
|
-
});
|
|
317
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
318
|
-
expect(record.id).toBe(serverGeneratedId);
|
|
319
|
-
expect(record.source).toBe(CONTACT_FORM_LEAD_BODY.source);
|
|
320
|
-
expect(record.data).toEqual({
|
|
321
|
-
email: CONTACT_FORM_LEAD_BODY.email,
|
|
322
|
-
...CONTACT_FORM_LEAD_BODY.payload,
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it("POST /api/leads/callback — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
327
|
-
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a3";
|
|
328
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
329
|
-
id: serverGeneratedId,
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
const res = await app.inject({
|
|
333
|
-
method: "POST",
|
|
334
|
-
url: "/v1/leads/callback",
|
|
335
|
-
payload: {
|
|
336
|
-
id: "attacker",
|
|
337
|
-
kind: "callback",
|
|
338
|
-
siteId: "site-abc",
|
|
339
|
-
name: "Alice",
|
|
340
|
-
phone: "+15551234567",
|
|
341
|
-
payload: { notes: "urgent" },
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
346
|
-
if (res.statusCode === 201) {
|
|
347
|
-
const body = res.json();
|
|
348
|
-
expect(body.id).not.toBe("attacker");
|
|
349
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
350
|
-
}
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// ── Lead capture store integration cases ──────────────────────────────────
|
|
354
|
-
|
|
355
|
-
it("file-on-success — appendLeadRecord writes to LEAD_CAPTURE_FILE on successful DB persist", async () => {
|
|
356
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a4";
|
|
357
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
358
|
-
id: serverGeneratedId,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const res = await app.inject({
|
|
362
|
-
method: "POST",
|
|
363
|
-
url: "/v1/leads/submit",
|
|
364
|
-
payload: {
|
|
365
|
-
kind: "submit",
|
|
366
|
-
siteId: "site-abc",
|
|
367
|
-
source: "contact",
|
|
368
|
-
email: "test@example.com",
|
|
369
|
-
payload: { message: "hello" },
|
|
370
|
-
},
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
374
|
-
if (res.statusCode === 201) {
|
|
375
|
-
expect(res.json().id).toBe(serverGeneratedId);
|
|
376
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
377
|
-
expect(record.id).toBe(serverGeneratedId);
|
|
378
|
-
expect(record.source).toBe("contact");
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it("dual-write — real lead-capture-store AND DB are both invoked on success", async () => {
|
|
383
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a5";
|
|
384
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
385
|
-
id: serverGeneratedId,
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
const res = await app.inject({
|
|
389
|
-
method: "POST",
|
|
390
|
-
url: "/v1/leads/submit",
|
|
391
|
-
payload: {
|
|
392
|
-
kind: "submit",
|
|
393
|
-
siteId: "site-abc",
|
|
394
|
-
source: "contact",
|
|
395
|
-
email: "dual@example.com",
|
|
396
|
-
payload: { message: "dual-write test" },
|
|
397
|
-
},
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
if (res.statusCode === 201) {
|
|
401
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
402
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
403
|
-
expect(typeof record.id).toBe("string");
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it("disabled — appendLeadRecord is skipped when LEAD_CAPTURE_FILE_DISABLED=true", async () => {
|
|
408
|
-
process.env.LEAD_CAPTURE_FILE_DISABLED = "true";
|
|
409
|
-
|
|
410
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a6";
|
|
411
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
412
|
-
id: serverGeneratedId,
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
const res = await app.inject({
|
|
416
|
-
method: "POST",
|
|
417
|
-
url: "/v1/leads/submit",
|
|
418
|
-
payload: {
|
|
419
|
-
kind: "submit",
|
|
420
|
-
siteId: "site-abc",
|
|
421
|
-
source: "contact",
|
|
422
|
-
email: "disabled@example.com",
|
|
423
|
-
payload: { message: "no file" },
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
428
|
-
if (res.statusCode === 201) {
|
|
429
|
-
// The real lead-capture-store short-circuits when LEAD_CAPTURE_FILE_DISABLED=true.
|
|
430
|
-
// The capture file must not be written.
|
|
431
|
-
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it("config_missing — 503 with lead_capture_not_configured when file disabled and no DATABASE_URL", async () => {
|
|
436
|
-
process.env.LEAD_CAPTURE_FILE_DISABLED = "true";
|
|
437
|
-
delete process.env.DATABASE_URL;
|
|
438
|
-
|
|
439
|
-
const res = await app.inject({
|
|
440
|
-
method: "POST",
|
|
441
|
-
url: "/v1/leads/submit",
|
|
442
|
-
payload: {
|
|
443
|
-
kind: "submit",
|
|
444
|
-
siteId: "site-abc",
|
|
445
|
-
source: "contact",
|
|
446
|
-
email: "nocapture@example.com",
|
|
447
|
-
payload: { message: "no config" },
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
expect(res.statusCode).toBe(503);
|
|
452
|
-
const body = res.json();
|
|
453
|
-
expect(body.error).toBe("lead_capture_not_configured");
|
|
454
|
-
expect(body.message).toBe("Lead capture is not configured.");
|
|
455
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it("IP-include — metadata.ip present in capture record when LEAD_CAPTURE_INCLUDE_IP=true", async () => {
|
|
459
|
-
process.env.LEAD_CAPTURE_INCLUDE_IP = "true";
|
|
460
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a7";
|
|
461
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
462
|
-
id: serverGeneratedId,
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
const res = await app.inject({
|
|
466
|
-
method: "POST",
|
|
467
|
-
url: "/v1/leads/submit",
|
|
468
|
-
headers: { "x-forwarded-for": "1.2.3.4" },
|
|
469
|
-
payload: {
|
|
470
|
-
kind: "submit",
|
|
471
|
-
siteId: "site-abc",
|
|
472
|
-
source: "contact",
|
|
473
|
-
email: "ip@example.com",
|
|
474
|
-
payload: { message: "ip test" },
|
|
475
|
-
},
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
if (res.statusCode === 201) {
|
|
479
|
-
const record = await fileEnv.readLastCaptureRecord();
|
|
480
|
-
expect(record.metadata as Record<string, unknown>).toHaveProperty("ip");
|
|
481
|
-
}
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
// ── buildInput branch coverage ─────────────────────────────────────────────
|
|
485
|
-
|
|
486
|
-
it("lead — payload absent from body defaults to empty object via ?? (payload ?? {} branch)", async () => {
|
|
487
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a8";
|
|
488
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
489
|
-
id: serverGeneratedId,
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
const bodyWithoutPayload: Record<string, unknown> = {
|
|
493
|
-
kind: "submit",
|
|
494
|
-
siteId: "site-abc",
|
|
495
|
-
source: "contact",
|
|
496
|
-
email: "nopayload@example.com",
|
|
497
|
-
payload: undefined,
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
const res = await app.inject({
|
|
501
|
-
method: "POST",
|
|
502
|
-
url: "/v1/leads/submit",
|
|
503
|
-
payload: bodyWithoutPayload,
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
507
|
-
if (res.statusCode === 201) {
|
|
508
|
-
expect(res.json().id).toBe(serverGeneratedId);
|
|
509
|
-
}
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("callback — preferredTime included when provided (preferredTime spread branch)", async () => {
|
|
513
|
-
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a9";
|
|
514
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
515
|
-
id: serverGeneratedId,
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
const res = await app.inject({
|
|
519
|
-
method: "POST",
|
|
520
|
-
url: "/v1/leads/callback",
|
|
521
|
-
payload: {
|
|
522
|
-
kind: "callback",
|
|
523
|
-
siteId: "site-abc",
|
|
524
|
-
name: "Alice",
|
|
525
|
-
phone: "+15551234567",
|
|
526
|
-
preferredTime: "morning",
|
|
527
|
-
payload: { notes: "prefers AM" },
|
|
528
|
-
},
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
532
|
-
if (res.statusCode === 201) {
|
|
533
|
-
expect(res.json().id).toBe(serverGeneratedId);
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
it("callback — payload absent from body defaults to empty object via ?? (callback payload ?? {} branch)", async () => {
|
|
538
|
-
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000aa";
|
|
539
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
540
|
-
id: serverGeneratedId,
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
const bodyWithoutPayload: Record<string, unknown> = {
|
|
544
|
-
kind: "callback",
|
|
545
|
-
siteId: "site-abc",
|
|
546
|
-
name: "Bob",
|
|
547
|
-
phone: "+15559876543",
|
|
548
|
-
payload: undefined,
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
const res = await app.inject({
|
|
552
|
-
method: "POST",
|
|
553
|
-
url: "/v1/leads/callback",
|
|
554
|
-
payload: bodyWithoutPayload,
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
558
|
-
if (res.statusCode === 201) {
|
|
559
|
-
expect(res.json().id).toBe(serverGeneratedId);
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
// ── emailKind branch coverage ──────────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
it("email skipped — emailKind=skipped logged when env vars are unset", async () => {
|
|
566
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
567
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
568
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ab";
|
|
569
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
570
|
-
id: serverGeneratedId,
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
const res = await app.inject({
|
|
574
|
-
method: "POST",
|
|
575
|
-
url: "/v1/leads/submit",
|
|
576
|
-
payload: {
|
|
577
|
-
kind: "submit",
|
|
578
|
-
siteId: "site-abc",
|
|
579
|
-
source: "contact",
|
|
580
|
-
email: "skipped@example.com",
|
|
581
|
-
payload: { message: "test" },
|
|
582
|
-
},
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
586
|
-
if (res.statusCode === 201) {
|
|
587
|
-
const body = res.json();
|
|
588
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
589
|
-
expect(body.email).toHaveProperty("skipped", true);
|
|
590
|
-
}
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
// ── #4985 — step-flow envelope ({ siteId, source, payload: <record> }) ────
|
|
594
|
-
// StepFlow (packages/ui/src/sections/StepFlow.tsx L548) posts email INSIDE
|
|
595
|
-
// `payload`, not top-level. buildInput() now lifts `email` from `payload`
|
|
596
|
-
// when the top-level field is absent.
|
|
597
|
-
|
|
598
|
-
it("envelope submit — { siteId, source, payload: { email, ... } } returns 201, writes one Lead with that email and the full payload, and routes the notification email", async () => {
|
|
599
|
-
const { sendLeadNotification } = jest.requireMock("@working-theory/email");
|
|
600
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e1";
|
|
601
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
602
|
-
id: serverGeneratedId,
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
const envelopePayload = {
|
|
606
|
-
email: "envelope@example.com",
|
|
607
|
-
name: "Envelope Visitor",
|
|
608
|
-
message: "hello from the step-flow",
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
const res = await app.inject({
|
|
612
|
-
method: "POST",
|
|
613
|
-
url: "/v1/leads/submit",
|
|
614
|
-
payload: {
|
|
615
|
-
siteId: "site-abc",
|
|
616
|
-
source: "step-flow",
|
|
617
|
-
payload: envelopePayload,
|
|
618
|
-
},
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
expect(res.statusCode).toBe(201);
|
|
622
|
-
const body = res.json();
|
|
623
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
624
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
625
|
-
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
626
|
-
data: {
|
|
627
|
-
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
628
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
629
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
630
|
-
source: "step-flow",
|
|
631
|
-
email: "envelope@example.com",
|
|
632
|
-
payload: envelopePayload,
|
|
633
|
-
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
634
|
-
personId: MOCK_PERSON_ID,
|
|
635
|
-
},
|
|
636
|
-
select: { id: true },
|
|
637
|
-
});
|
|
638
|
-
expect(sendLeadNotification).toHaveBeenCalledTimes(1);
|
|
639
|
-
expect(body.email).toHaveProperty("ok", true);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it("envelope subscribe — { siteId, source, payload: { email } } returns 201 and writes one Subscriber", async () => {
|
|
643
|
-
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e2";
|
|
644
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
645
|
-
id: serverGeneratedId,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const res = await app.inject({
|
|
649
|
-
method: "POST",
|
|
650
|
-
url: "/v1/leads/subscribe",
|
|
651
|
-
payload: {
|
|
652
|
-
siteId: "site-abc",
|
|
653
|
-
source: "step-flow",
|
|
654
|
-
payload: { email: "sub-envelope@example.com" },
|
|
655
|
-
},
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
expect(res.statusCode).toBe(201);
|
|
659
|
-
const body = res.json();
|
|
660
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
661
|
-
expect(prisma.subscriber.create).toHaveBeenCalledTimes(1);
|
|
662
|
-
expect(prisma.subscriber.create).toHaveBeenCalledWith({
|
|
663
|
-
data: {
|
|
664
|
-
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
665
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
666
|
-
// #6374 — `subscribers.workspace_id` is NOT NULL as of
|
|
667
|
-
// database/migrations/20260922120000_tenancy_fence, and
|
|
668
|
-
// `subscribers_workspace_site_agree` forces it to EQUAL `site_id`.
|
|
669
|
-
// Asserted as the same RESOLVED value, so a raw alias leaking into
|
|
670
|
-
// either column fails this test rather than the database.
|
|
671
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
672
|
-
email: "sub-envelope@example.com",
|
|
673
|
-
source: "step-flow",
|
|
674
|
-
},
|
|
675
|
-
select: { id: true },
|
|
676
|
-
});
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
it("envelope submit — email absent in both the top level and payload → the existing 400 invalid_input", async () => {
|
|
680
|
-
const res = await app.inject({
|
|
681
|
-
method: "POST",
|
|
682
|
-
url: "/v1/leads/submit",
|
|
683
|
-
payload: {
|
|
684
|
-
siteId: "site-abc",
|
|
685
|
-
source: "step-flow",
|
|
686
|
-
payload: { message: "no email anywhere" },
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
expect(res.statusCode).toBe(400);
|
|
691
|
-
const body = res.json();
|
|
692
|
-
expect(body.error).toBe("invalid_input");
|
|
693
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
it("envelope subscribe — email absent in both the top level and payload → the existing 400 invalid_input", async () => {
|
|
697
|
-
const res = await app.inject({
|
|
698
|
-
method: "POST",
|
|
699
|
-
url: "/v1/leads/subscribe",
|
|
700
|
-
payload: {
|
|
701
|
-
siteId: "site-abc",
|
|
702
|
-
source: "step-flow",
|
|
703
|
-
payload: {},
|
|
704
|
-
},
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
expect(res.statusCode).toBe(400);
|
|
708
|
-
const body = res.json();
|
|
709
|
-
expect(body.error).toBe("invalid_input");
|
|
710
|
-
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
it("submit — a top-level email wins over a payload email", async () => {
|
|
714
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e3";
|
|
715
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
716
|
-
id: serverGeneratedId,
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
const res = await app.inject({
|
|
720
|
-
method: "POST",
|
|
721
|
-
url: "/v1/leads/submit",
|
|
722
|
-
payload: {
|
|
723
|
-
siteId: "site-abc",
|
|
724
|
-
source: "step-flow",
|
|
725
|
-
email: "top-level@example.com",
|
|
726
|
-
payload: { email: "shadowed-payload@example.com", message: "hi" },
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
expect(res.statusCode).toBe(201);
|
|
731
|
-
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
732
|
-
data: {
|
|
733
|
-
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
734
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
735
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
736
|
-
source: "step-flow",
|
|
737
|
-
email: "top-level@example.com",
|
|
738
|
-
payload: { email: "shadowed-payload@example.com", message: "hi" },
|
|
739
|
-
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
740
|
-
personId: MOCK_PERSON_ID,
|
|
741
|
-
},
|
|
742
|
-
select: { id: true },
|
|
743
|
-
});
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
it("subscribe — a top-level email wins over a payload email", async () => {
|
|
747
|
-
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e4";
|
|
748
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
749
|
-
id: serverGeneratedId,
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
const res = await app.inject({
|
|
753
|
-
method: "POST",
|
|
754
|
-
url: "/v1/leads/subscribe",
|
|
755
|
-
payload: {
|
|
756
|
-
siteId: "site-abc",
|
|
757
|
-
source: "step-flow",
|
|
758
|
-
email: "top-level-sub@example.com",
|
|
759
|
-
payload: { email: "shadowed-sub@example.com" },
|
|
760
|
-
},
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
expect(res.statusCode).toBe(201);
|
|
764
|
-
expect(prisma.subscriber.create).toHaveBeenCalledWith({
|
|
765
|
-
data: {
|
|
766
|
-
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
767
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
768
|
-
// #6374 — equal to `siteId` by construction; see the note on the
|
|
769
|
-
// envelope-subscribe assertion above.
|
|
770
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
771
|
-
email: "top-level-sub@example.com",
|
|
772
|
-
source: "step-flow",
|
|
773
|
-
},
|
|
774
|
-
select: { id: true },
|
|
775
|
-
});
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
it("email error — emailKind=error logged when sendLeadNotification returns ok:false", async () => {
|
|
779
|
-
const { sendLeadNotification } = jest.requireMock("@working-theory/email");
|
|
780
|
-
(sendLeadNotification as jest.Mock).mockResolvedValueOnce({
|
|
781
|
-
ok: false,
|
|
782
|
-
error: { kind: "send_error", message: "SMTP timeout" },
|
|
783
|
-
});
|
|
784
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ac";
|
|
785
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
786
|
-
id: serverGeneratedId,
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
const res = await app.inject({
|
|
790
|
-
method: "POST",
|
|
791
|
-
url: "/v1/leads/submit",
|
|
792
|
-
payload: {
|
|
793
|
-
kind: "submit",
|
|
794
|
-
siteId: "site-abc",
|
|
795
|
-
source: "contact",
|
|
796
|
-
email: "erroremail@example.com",
|
|
797
|
-
payload: { message: "test" },
|
|
798
|
-
},
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
expect([201, 400]).toContain(res.statusCode);
|
|
802
|
-
if (res.statusCode === 201) {
|
|
803
|
-
const body = res.json();
|
|
804
|
-
expect(body.id).toBe(serverGeneratedId);
|
|
805
|
-
expect(body.email).toHaveProperty("ok", false);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
// ── #4985 — PII-safe logging still holds when email arrives via `payload` ──
|
|
811
|
-
|
|
812
|
-
describe("POST /v1/leads/submit — structured log stays PII-safe for envelope input", () => {
|
|
813
|
-
afterEach(async () => {
|
|
814
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
815
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
816
|
-
delete process.env.DATABASE_URL;
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
it("the 'lead routed' log line carries no email or payload value when email is lifted from payload", async () => {
|
|
820
|
-
jest.clearAllMocks();
|
|
821
|
-
stateSentinelWorkspaceExists();
|
|
822
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
823
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
824
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
825
|
-
|
|
826
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e5";
|
|
827
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
828
|
-
id: serverGeneratedId,
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
const logLines: string[] = [];
|
|
832
|
-
const logCapture = require("fastify")({
|
|
833
|
-
logger: {
|
|
834
|
-
level: "info",
|
|
835
|
-
stream: { write: (msg: string) => logLines.push(msg) },
|
|
836
|
-
},
|
|
837
|
-
}) as FastifyInstance;
|
|
838
|
-
await logCapture.register(leadSubmitRoute);
|
|
839
|
-
await logCapture.ready();
|
|
840
|
-
|
|
841
|
-
try {
|
|
842
|
-
const res = await logCapture.inject({
|
|
843
|
-
method: "POST",
|
|
844
|
-
url: "/v1/leads/submit",
|
|
845
|
-
payload: {
|
|
846
|
-
siteId: "site-abc",
|
|
847
|
-
source: "step-flow",
|
|
848
|
-
payload: {
|
|
849
|
-
email: "secret-envelope@example.com",
|
|
850
|
-
message: "this message body must never be logged",
|
|
851
|
-
},
|
|
852
|
-
},
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
expect(res.statusCode).toBe(201);
|
|
856
|
-
|
|
857
|
-
const routedLine = logLines.find((line) => line.includes("lead routed"));
|
|
858
|
-
expect(routedLine).toBeDefined();
|
|
859
|
-
expect(routedLine).not.toMatch(/secret-envelope@example\.com/);
|
|
860
|
-
expect(routedLine).not.toMatch(/this message body must never be logged/);
|
|
861
|
-
|
|
862
|
-
const logged = JSON.parse(routedLine!) as Record<string, unknown>;
|
|
863
|
-
expect(logged).not.toHaveProperty("email");
|
|
864
|
-
expect(logged).not.toHaveProperty("payload");
|
|
865
|
-
expect(logged).toMatchObject({
|
|
866
|
-
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
867
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
868
|
-
kind: "submit",
|
|
869
|
-
persisted: "db",
|
|
870
|
-
});
|
|
871
|
-
} finally {
|
|
872
|
-
await logCapture.close();
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
// ── #6203 AC-4 — tenant resolution: per-kind, unresolved-rejects, spoof-proof ──
|
|
878
|
-
//
|
|
879
|
-
// Every capture kind resolves its tenant SERVER-SIDE (routes/leads/index.ts's
|
|
880
|
-
// header). These tests assert the two halves that matter: (1) an unresolved
|
|
881
|
-
// resolution — DATABASE_URL set, but the alias names no workspace this
|
|
882
|
-
// database holds — rejects the write with a 503 BEFORE any create call, for
|
|
883
|
-
// each of the three kinds; (2) a client-supplied `siteId` naming a DIFFERENT,
|
|
884
|
-
// otherwise-resolvable workspace never places a row there — the resolved
|
|
885
|
-
// value always wins, proving the body value is truly ignored rather than
|
|
886
|
-
// merely unpreferred.
|
|
887
|
-
|
|
888
|
-
describe("POST /api/leads/:kind — tenant resolution (#6203 AC-4)", () => {
|
|
889
|
-
let app: FastifyInstance;
|
|
890
|
-
|
|
891
|
-
beforeEach(async () => {
|
|
892
|
-
jest.clearAllMocks();
|
|
893
|
-
stateSentinelWorkspaceExists();
|
|
894
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
895
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
896
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
897
|
-
delete process.env.AILK_SITE_ID;
|
|
898
|
-
|
|
899
|
-
const fastify = require("fastify")();
|
|
900
|
-
await fastify.register(leadSubmitRoute);
|
|
901
|
-
await fastify.register(leadSubscribeRoute);
|
|
902
|
-
await fastify.register(leadCallbackRoute);
|
|
903
|
-
app = fastify;
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
afterEach(async () => {
|
|
907
|
-
await app.close();
|
|
908
|
-
__resetSiteWorkspaceCacheForTests();
|
|
909
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
910
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
911
|
-
delete process.env.DATABASE_URL;
|
|
912
|
-
delete process.env.AILK_SITE_ID;
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it("submit: an unresolved workspace is a 503 tenant_unresolved and writes no Lead", async () => {
|
|
916
|
-
mockNoWorkspace(prisma);
|
|
917
|
-
|
|
918
|
-
const res = await app.inject({
|
|
919
|
-
method: "POST",
|
|
920
|
-
url: "/v1/leads/submit",
|
|
921
|
-
payload: {
|
|
922
|
-
siteId: "default",
|
|
923
|
-
source: "contact-form",
|
|
924
|
-
email: "nobody@example.com",
|
|
925
|
-
payload: {},
|
|
926
|
-
},
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
expect(res.statusCode).toBe(503);
|
|
930
|
-
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
931
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
it("subscribe: an unresolved workspace is a 503 tenant_unresolved and writes no Subscriber", async () => {
|
|
935
|
-
mockNoWorkspace(prisma);
|
|
936
|
-
|
|
937
|
-
const res = await app.inject({
|
|
938
|
-
method: "POST",
|
|
939
|
-
url: "/v1/leads/subscribe",
|
|
940
|
-
payload: { siteId: "default", source: "footer", email: "nobody@example.com" },
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
expect(res.statusCode).toBe(503);
|
|
944
|
-
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
945
|
-
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
it("callback: an unresolved workspace is a 503 tenant_unresolved and writes no Callback", async () => {
|
|
949
|
-
mockNoWorkspace(prisma);
|
|
950
|
-
|
|
951
|
-
const res = await app.inject({
|
|
952
|
-
method: "POST",
|
|
953
|
-
url: "/v1/leads/callback",
|
|
954
|
-
payload: { siteId: "default", name: "Nobody", phone: "+15550000000" },
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
expect(res.statusCode).toBe(503);
|
|
958
|
-
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
959
|
-
expect(prisma.callback.create).not.toHaveBeenCalled();
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
it("submit: a body siteId naming another workspace never places the row there — the resolved workspace always wins", async () => {
|
|
963
|
-
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
964
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
965
|
-
id: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
966
|
-
});
|
|
967
|
-
|
|
968
|
-
const res = await app.inject({
|
|
969
|
-
method: "POST",
|
|
970
|
-
url: "/v1/leads/submit",
|
|
971
|
-
payload: {
|
|
972
|
-
// A DIFFERENT workspace id, which this test has deliberately made
|
|
973
|
-
// RESOLVABLE (`FOREIGN_WORKSPACE_ID` above) — so the row landing on
|
|
974
|
-
// the sentinel proves buildInput() never reads the body value at
|
|
975
|
-
// all, rather than proving the read merely failed closed.
|
|
976
|
-
siteId: FOREIGN_WORKSPACE_ID,
|
|
977
|
-
source: "contact-form",
|
|
978
|
-
email: "spoof@example.com",
|
|
979
|
-
payload: {},
|
|
980
|
-
},
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
expect(res.statusCode).toBe(201);
|
|
984
|
-
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
985
|
-
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
986
|
-
expect(data.workspaceId).toBe(RESOLVED_WORKSPACE_ID);
|
|
987
|
-
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
988
|
-
});
|
|
989
|
-
|
|
990
|
-
it("subscribe: a body siteId naming another workspace never places the row there", async () => {
|
|
991
|
-
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
992
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
993
|
-
id: "subscr_00000000-0000-7000-8000-0000000000f2",
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
const res = await app.inject({
|
|
997
|
-
method: "POST",
|
|
998
|
-
url: "/v1/leads/subscribe",
|
|
999
|
-
payload: {
|
|
1000
|
-
siteId: FOREIGN_WORKSPACE_ID,
|
|
1001
|
-
source: "footer",
|
|
1002
|
-
email: "spoof-sub@example.com",
|
|
1003
|
-
},
|
|
1004
|
-
});
|
|
1005
|
-
|
|
1006
|
-
expect(res.statusCode).toBe(201);
|
|
1007
|
-
const { data } = (prisma.subscriber.create as jest.Mock).mock.calls[0]![0];
|
|
1008
|
-
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
1009
|
-
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
it("callback: a body siteId naming another workspace never places the row there", async () => {
|
|
1013
|
-
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
1014
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1015
|
-
id: "callbk_00000000-0000-7000-8000-0000000000f3",
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
const res = await app.inject({
|
|
1019
|
-
method: "POST",
|
|
1020
|
-
url: "/v1/leads/callback",
|
|
1021
|
-
payload: {
|
|
1022
|
-
siteId: FOREIGN_WORKSPACE_ID,
|
|
1023
|
-
name: "Spoofer",
|
|
1024
|
-
phone: "+15559999999",
|
|
1025
|
-
},
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
expect(res.statusCode).toBe(201);
|
|
1029
|
-
const { data } = (prisma.callback.create as jest.Mock).mock.calls[0]![0];
|
|
1030
|
-
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
1031
|
-
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
1032
|
-
});
|
|
1033
|
-
});
|
|
1034
|
-
|
|
1035
|
-
// ── D5 — registration binds the client's literal path (issue #3964) ───────────
|
|
1036
|
-
//
|
|
1037
|
-
// The drift class this issue fixed: every test above hand-supplied the same
|
|
1038
|
-
// (wrong) URL the route was actually registered at, so the mismatch between
|
|
1039
|
-
// the registered path and the path every consumer calls shipped silently.
|
|
1040
|
-
// This suite registers the real exported plugin and asserts against the
|
|
1041
|
-
// EXACT literal packages/api-client/src/leads.ts uses (cross-referenced
|
|
1042
|
-
// below), so a future re-drift fails here instead of shipping.
|
|
1043
|
-
|
|
1044
|
-
describe("POST /v1/leads/submit — registration binds the client's literal path", () => {
|
|
1045
|
-
let app: FastifyInstance;
|
|
1046
|
-
|
|
1047
|
-
beforeEach(async () => {
|
|
1048
|
-
jest.clearAllMocks();
|
|
1049
|
-
stateSentinelWorkspaceExists();
|
|
1050
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1051
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1052
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
1053
|
-
|
|
1054
|
-
const fastify = require("fastify")();
|
|
1055
|
-
await fastify.register(leadSubmitRoute);
|
|
1056
|
-
app = fastify;
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
afterEach(async () => {
|
|
1060
|
-
await app.close();
|
|
1061
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1062
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1063
|
-
delete process.env.DATABASE_URL;
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
|
-
it("resolves POST /v1/leads/submit — the exact literal packages/api-client/src/leads.ts's leads.submit() posts to", async () => {
|
|
1067
|
-
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000d1";
|
|
1068
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1069
|
-
id: serverGeneratedId,
|
|
1070
|
-
});
|
|
1071
|
-
|
|
1072
|
-
// Cross-reference: packages/api-client/src/leads.ts —
|
|
1073
|
-
// submit: (payload) => post<LeadWriteResponse>("/v1/leads/submit", payload)
|
|
1074
|
-
const res = await app.inject({
|
|
1075
|
-
method: "POST",
|
|
1076
|
-
url: "/v1/leads/submit",
|
|
1077
|
-
payload: {
|
|
1078
|
-
siteId: "site-abc",
|
|
1079
|
-
source: "contact",
|
|
1080
|
-
email: "d5@example.com",
|
|
1081
|
-
payload: { message: "hello" },
|
|
1082
|
-
},
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
expect(res.statusCode).toBe(201);
|
|
1086
|
-
expect(res.json().id).toBe(serverGeneratedId);
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
it("POST /v1/leads/lead — no longer registered (404); the pre-fix path", async () => {
|
|
1090
|
-
const res = await app.inject({
|
|
1091
|
-
method: "POST",
|
|
1092
|
-
url: "/v1/leads/lead",
|
|
1093
|
-
payload: {
|
|
1094
|
-
siteId: "site-abc",
|
|
1095
|
-
source: "contact",
|
|
1096
|
-
email: "old-path@example.com",
|
|
1097
|
-
payload: { message: "hello" },
|
|
1098
|
-
},
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
expect(res.statusCode).toBe(404);
|
|
1102
|
-
});
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
// ── #5970 — the route wiring itself, at route grain ────────────────────────
|
|
1106
|
-
//
|
|
1107
|
-
// The #5970 acceptance review's one residual: the suppression half is measured
|
|
1108
|
-
// through the real `routeLead`, and the send half through the real
|
|
1109
|
-
// `sendRequestConfirmLink`, but the three lines in `routes/leads/index.ts`
|
|
1110
|
-
// that CONNECT them were asserted by construction only. Delete that call and
|
|
1111
|
-
// every other test in the branch stays green while the tenant receives zero
|
|
1112
|
-
// emails. These two cases drive the endpoint the acceptance criterion names,
|
|
1113
|
-
// so that deletion turns something red.
|
|
1114
|
-
//
|
|
1115
|
-
// NFR-1 is untouched by this block: every pre-existing case above runs with
|
|
1116
|
-
// the feature unset, exactly as before, and none of them is edited. What is
|
|
1117
|
-
// added here is the feature-ON path, which did not exist to be covered.
|
|
1118
|
-
describe("POST /v1/leads/callback — request-and-confirm wiring (#5970)", () => {
|
|
1119
|
-
// Registers its own hooks, so it must sit at describe level. It provisions
|
|
1120
|
-
// the file-fallback destination the second case needs; the first case never
|
|
1121
|
-
// reaches the fallback because its `callback.create` resolves.
|
|
1122
|
-
setupLeadFileEnv();
|
|
1123
|
-
|
|
1124
|
-
const CONFIRM_ENV = [
|
|
1125
|
-
"AILK_REQUEST_CONFIRM_ENABLED",
|
|
1126
|
-
"AILK_REQUEST_CONFIRM_TO",
|
|
1127
|
-
"AILK_REQUEST_CONFIRM_FROM",
|
|
1128
|
-
"AILK_PUBLIC_API_URL",
|
|
1129
|
-
] as const;
|
|
1130
|
-
|
|
1131
|
-
function enableConfirm() {
|
|
1132
|
-
process.env.AILK_REQUEST_CONFIRM_ENABLED = "true";
|
|
1133
|
-
process.env.AILK_REQUEST_CONFIRM_TO = "owner@example.com";
|
|
1134
|
-
process.env.AILK_REQUEST_CONFIRM_FROM = "hello@example.com";
|
|
1135
|
-
process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
|
|
1136
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1137
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1138
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
afterEach(() => {
|
|
1142
|
-
for (const key of CONFIRM_ENV) delete process.env[key];
|
|
1143
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1144
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1145
|
-
delete process.env.DATABASE_URL;
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
/** Awaitable equivalent of the route's fire-and-forget confirm send. */
|
|
1149
|
-
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
1150
|
-
|
|
1151
|
-
async function postCallback(fastify: FastifyInstance) {
|
|
1152
|
-
return fastify.inject({
|
|
1153
|
-
method: "POST",
|
|
1154
|
-
url: "/v1/leads/callback",
|
|
1155
|
-
payload: {
|
|
1156
|
-
siteId: "site-abc",
|
|
1157
|
-
name: "Ada Lovelace",
|
|
1158
|
-
phone: "+44 7700 900000",
|
|
1159
|
-
payload: {},
|
|
1160
|
-
},
|
|
1161
|
-
});
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
it("on the DB path the confirm email goes and the generic notification does not", async () => {
|
|
1165
|
-
jest.clearAllMocks();
|
|
1166
|
-
stateSentinelWorkspaceExists();
|
|
1167
|
-
enableConfirm();
|
|
1168
|
-
const email = require("@working-theory/email");
|
|
1169
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1170
|
-
id: "callbk_00000000-0000-7000-8000-00000000f001",
|
|
1171
|
-
});
|
|
1172
|
-
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
1173
|
-
|
|
1174
|
-
const fastify = require("fastify")({ logger: false }) as FastifyInstance;
|
|
1175
|
-
await fastify.register(leadCallbackRoute);
|
|
1176
|
-
await fastify.ready();
|
|
1177
|
-
try {
|
|
1178
|
-
const res = await postCallback(fastify);
|
|
1179
|
-
expect(res.statusCode).toBe(201);
|
|
1180
|
-
await flush();
|
|
1181
|
-
|
|
1182
|
-
// Exactly one email, and it is the one carrying the confirm link.
|
|
1183
|
-
expect(email.sendRequestConfirmEmail).toHaveBeenCalledTimes(1);
|
|
1184
|
-
expect(email.sendLeadNotification).not.toHaveBeenCalled();
|
|
1185
|
-
} finally {
|
|
1186
|
-
await fastify.close();
|
|
1187
|
-
}
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
it("on the FILE-FALLBACK path the generic notification goes and the confirm email does not", async () => {
|
|
1191
|
-
jest.clearAllMocks();
|
|
1192
|
-
stateSentinelWorkspaceExists();
|
|
1193
|
-
enableConfirm();
|
|
1194
|
-
const email = require("@working-theory/email");
|
|
1195
|
-
(prisma.callback.create as jest.Mock).mockRejectedValue(
|
|
1196
|
-
new Error("connection reset"),
|
|
1197
|
-
);
|
|
1198
|
-
|
|
1199
|
-
const fastify = require("fastify")({ logger: false }) as FastifyInstance;
|
|
1200
|
-
await fastify.register(leadCallbackRoute);
|
|
1201
|
-
await fastify.ready();
|
|
1202
|
-
try {
|
|
1203
|
-
const res = await postCallback(fastify);
|
|
1204
|
-
expect(res.statusCode).toBe(201);
|
|
1205
|
-
await flush();
|
|
1206
|
-
|
|
1207
|
-
// No row exists to carry a credential, so the confirm send must not run
|
|
1208
|
-
// — and the notification it would otherwise supersede must.
|
|
1209
|
-
expect(email.sendRequestConfirmEmail).not.toHaveBeenCalled();
|
|
1210
|
-
expect(email.sendLeadNotification).toHaveBeenCalledTimes(1);
|
|
1211
|
-
} finally {
|
|
1212
|
-
await fastify.close();
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
});
|
|
1216
|
-
|
|
1217
|
-
// ── #6335 — the fixture's own guard ───────────────────────────────────────
|
|
1218
|
-
//
|
|
1219
|
-
// Every other case in this file proves a ROUTE behaves. This one proves the
|
|
1220
|
-
// FIXTURE does: that the shared prisma mock ships `workspace.findUnique`
|
|
1221
|
-
// with no implementation at all, so a suite gets "no such workspace" until
|
|
1222
|
-
// it says otherwise. Re-adding a permissive default — the #6203 shape this
|
|
1223
|
-
// issue removed, or any catch-all under another name — fails here, and
|
|
1224
|
-
// fails for that reason rather than as a side effect three suites away.
|
|
1225
|
-
//
|
|
1226
|
-
// Read from a FRESH module instance so the assertion is about what the file
|
|
1227
|
-
// DECLARES, not about whatever the suites above have programmed onto the
|
|
1228
|
-
// double they share.
|
|
1229
|
-
describe("#6335 — the shared prisma mock declares no workspace default", () => {
|
|
1230
|
-
it("workspace.findUnique ships with no implementation, and answers nothing", async () => {
|
|
1231
|
-
await jest.isolateModulesAsync(async () => {
|
|
1232
|
-
const fresh = (
|
|
1233
|
-
require("../../../lib/__mocks__/prisma.js") as {
|
|
1234
|
-
prisma: { workspace: { findUnique: jest.Mock } };
|
|
1235
|
-
}
|
|
1236
|
-
).prisma;
|
|
1237
|
-
|
|
1238
|
-
expect(fresh.workspace.findUnique.getMockImplementation()).toBeUndefined();
|
|
1239
|
-
// A bare `jest.fn()` returns `undefined` rather than a promise, and
|
|
1240
|
-
// `await undefined` is `undefined` — which every resolver in
|
|
1241
|
-
// `lib/site.ts` reads as "no such workspace" and rejects. That is the
|
|
1242
|
-
// fail-closed default this fixture is supposed to have.
|
|
1243
|
-
const answer: unknown = fresh.workspace.findUnique({
|
|
1244
|
-
where: { id: "wksp_00000000-0000-7000-8000-0000000000ff" },
|
|
1245
|
-
});
|
|
1246
|
-
expect(answer).toBeUndefined();
|
|
1247
|
-
await expect(Promise.resolve(answer)).resolves.toBeUndefined();
|
|
1248
|
-
});
|
|
1249
|
-
});
|
|
1250
|
-
});
|
|
1251
|
-
|
|
1252
|
-
// ─── #6730 D2/D5 — email delivery and the production refusal, over HTTP ─────
|
|
1253
|
-
|
|
1254
|
-
describe("POST /v1/leads/submit — #6730 delivery modes", () => {
|
|
1255
|
-
let app: FastifyInstance;
|
|
1256
|
-
const ENV_KEYS = [
|
|
1257
|
-
"DATABASE_URL",
|
|
1258
|
-
"NODE_ENV",
|
|
1259
|
-
"RESEND_API_KEY",
|
|
1260
|
-
"AILK_LEAD_NOTIFICATION_TO",
|
|
1261
|
-
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1262
|
-
"LEAD_CAPTURE_FILE",
|
|
1263
|
-
"AILK_INBOX_FALLBACK_DIR",
|
|
1264
|
-
] as const;
|
|
1265
|
-
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1266
|
-
const sendLeadNotification = jest.requireMock("@working-theory/email")
|
|
1267
|
-
.sendLeadNotification as jest.Mock;
|
|
1268
|
-
const writeLeadFallbackSpy = jest.spyOn(
|
|
1269
|
-
jest.requireActual("../../../lib/file-fallback.js"),
|
|
1270
|
-
"writeLeadFallback",
|
|
1271
|
-
);
|
|
1272
|
-
|
|
1273
|
-
const submit = () =>
|
|
1274
|
-
app.inject({
|
|
1275
|
-
method: "POST",
|
|
1276
|
-
url: "/v1/leads/submit",
|
|
1277
|
-
payload: {
|
|
1278
|
-
kind: "submit",
|
|
1279
|
-
source: "contact",
|
|
1280
|
-
email: "visitor@example.com",
|
|
1281
|
-
payload: { message: "hello" },
|
|
1282
|
-
},
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
beforeEach(async () => {
|
|
1286
|
-
jest.clearAllMocks();
|
|
1287
|
-
saved = {};
|
|
1288
|
-
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1289
|
-
for (const k of ENV_KEYS) delete process.env[k];
|
|
1290
|
-
process.env.NODE_ENV = "production";
|
|
1291
|
-
const fastify = require("fastify")();
|
|
1292
|
-
await fastify.register(leadSubmitRoute);
|
|
1293
|
-
app = fastify;
|
|
1294
|
-
});
|
|
1295
|
-
|
|
1296
|
-
afterEach(async () => {
|
|
1297
|
-
await app.close();
|
|
1298
|
-
for (const k of ENV_KEYS) {
|
|
1299
|
-
if (saved[k] === undefined) delete process.env[k];
|
|
1300
|
-
else process.env[k] = saved[k];
|
|
1301
|
-
}
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
describe("email delivery (no DATABASE_URL, the three email vars)", () => {
|
|
1305
|
-
beforeEach(() => {
|
|
1306
|
-
process.env.RESEND_API_KEY = "re_test";
|
|
1307
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1308
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
it("an accepted email → 201 persisted:email; no Prisma write", async () => {
|
|
1312
|
-
sendLeadNotification.mockResolvedValueOnce({ ok: true, messageId: "msg-e" });
|
|
1313
|
-
|
|
1314
|
-
const res = await submit();
|
|
1315
|
-
|
|
1316
|
-
expect(res.statusCode).toBe(201);
|
|
1317
|
-
expect(res.json()).toMatchObject({ persisted: "email" });
|
|
1318
|
-
expect(res.json().id).toMatch(/^email-/);
|
|
1319
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1320
|
-
expect(sendLeadNotification).toHaveBeenCalledTimes(1);
|
|
1321
|
-
expect(sendLeadNotification.mock.calls[0][0].recipientEmail).toBe("owner@example.com");
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
it("a refused email → 503 delivery_failed, never 201", async () => {
|
|
1325
|
-
sendLeadNotification.mockResolvedValueOnce({
|
|
1326
|
-
ok: false,
|
|
1327
|
-
error: { kind: "provider_error", message: "rejected" },
|
|
1328
|
-
});
|
|
1329
|
-
|
|
1330
|
-
const res = await submit();
|
|
1331
|
-
|
|
1332
|
-
expect(res.statusCode).toBe(503);
|
|
1333
|
-
expect(res.json()).toEqual({
|
|
1334
|
-
error: "delivery_failed",
|
|
1335
|
-
message: "The message could not be delivered.",
|
|
1336
|
-
});
|
|
1337
|
-
});
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
it("production with neither → 503 lead_capture_not_configured; no Prisma, no file, no email", async () => {
|
|
1341
|
-
const res = await submit();
|
|
1342
|
-
|
|
1343
|
-
expect(res.statusCode).toBe(503);
|
|
1344
|
-
expect(res.json().error).toBe("lead_capture_not_configured");
|
|
1345
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1346
|
-
expect(writeLeadFallbackSpy).not.toHaveBeenCalled();
|
|
1347
|
-
expect(sendLeadNotification).not.toHaveBeenCalled();
|
|
1348
|
-
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1349
|
-
});
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
// ── #7869 — Idempotency-Key on submit (spec D1, D3–D7, D10 cases 1–8) ─────────
|
|
1353
|
-
//
|
|
1354
|
-
// `inbox` delivery (DATABASE_URL set), the sentinel workspace resolvable. The
|
|
1355
|
-
// app logs into `logLines` so case 8 can prove the key reaches no log line.
|
|
1356
|
-
describe("POST /v1/leads/submit — Idempotency-Key (#7869)", () => {
|
|
1357
|
-
let app: FastifyInstance;
|
|
1358
|
-
let logLines: string[];
|
|
1359
|
-
const KEY = "fixture-retry-0000-0000-0002";
|
|
1360
|
-
const STORED_ID = "lead_00000000-0000-7000-8000-00000000a7a7";
|
|
1361
|
-
const NEW_ID = "lead_00000000-0000-7000-8000-00000000b8b8";
|
|
1362
|
-
const sendLeadNotification = jest.requireMock("@working-theory/email")
|
|
1363
|
-
.sendLeadNotification as jest.Mock;
|
|
1364
|
-
const track = jest.requireMock("@working-theory/analytics/server")
|
|
1365
|
-
.track as jest.Mock;
|
|
1366
|
-
|
|
1367
|
-
const body = {
|
|
1368
|
-
kind: "submit",
|
|
1369
|
-
source: "contact",
|
|
1370
|
-
email: "visitor@example.com",
|
|
1371
|
-
payload: { message: "hello", name: "Jamie" },
|
|
1372
|
-
};
|
|
1373
|
-
const send = (
|
|
1374
|
-
headers: Record<string, string> = {},
|
|
1375
|
-
url = "/v1/leads/submit",
|
|
1376
|
-
payload: Record<string, unknown> = body,
|
|
1377
|
-
) => app.inject({ method: "POST", url, headers, payload });
|
|
1378
|
-
const keyLookups = () =>
|
|
1379
|
-
jest
|
|
1380
|
-
.mocked(prisma.lead.findUnique)
|
|
1381
|
-
.mock.calls.filter(
|
|
1382
|
-
(c) => "workspaceId_idempotencyKey" in (c[0]?.where ?? {}),
|
|
1383
|
-
);
|
|
1384
|
-
const keyIsNowhere = (resBody: string) => {
|
|
1385
|
-
expect(resBody).not.toContain(KEY);
|
|
1386
|
-
for (const line of logLines) expect(line).not.toContain(KEY);
|
|
1387
|
-
};
|
|
1388
|
-
|
|
1389
|
-
beforeEach(async () => {
|
|
1390
|
-
jest.clearAllMocks();
|
|
1391
|
-
stateSentinelWorkspaceExists();
|
|
1392
|
-
process.env.DATABASE_URL = "postgres://stub";
|
|
1393
|
-
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1394
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1395
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: NEW_ID });
|
|
1396
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1397
|
-
|
|
1398
|
-
logLines = [];
|
|
1399
|
-
const fastify = require("fastify")({
|
|
1400
|
-
logger: {
|
|
1401
|
-
level: "info",
|
|
1402
|
-
stream: { write: (msg: string) => logLines.push(msg) },
|
|
1403
|
-
},
|
|
1404
|
-
});
|
|
1405
|
-
await fastify.register(leadSubmitRoute);
|
|
1406
|
-
await fastify.register(leadSubscribeRoute);
|
|
1407
|
-
await fastify.register(leadCallbackRoute);
|
|
1408
|
-
app = fastify;
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
afterEach(async () => {
|
|
1412
|
-
await app.close();
|
|
1413
|
-
delete process.env.DATABASE_URL;
|
|
1414
|
-
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1415
|
-
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1416
|
-
});
|
|
1417
|
-
|
|
1418
|
-
it("case 1 — no header: one create with no idempotencyKey, no key lookup, 201", async () => {
|
|
1419
|
-
const res = await send();
|
|
1420
|
-
|
|
1421
|
-
expect(res.statusCode).toBe(201);
|
|
1422
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1423
|
-
expect(
|
|
1424
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1425
|
-
).not.toHaveProperty("idempotencyKey");
|
|
1426
|
-
expect(keyLookups()).toHaveLength(0);
|
|
1427
|
-
});
|
|
1428
|
-
|
|
1429
|
-
it("case 2 — a key with no stored row: scoped lookup, the create carries the key, 201", async () => {
|
|
1430
|
-
const res = await send({ "Idempotency-Key": KEY });
|
|
1431
|
-
|
|
1432
|
-
expect(res.statusCode).toBe(201);
|
|
1433
|
-
expect(res.json()).toEqual({
|
|
1434
|
-
id: NEW_ID,
|
|
1435
|
-
persisted: "db",
|
|
1436
|
-
email: { ok: true, messageId: "msg-test" },
|
|
1437
|
-
});
|
|
1438
|
-
expect(keyLookups()[0]?.[0]).toEqual({
|
|
1439
|
-
where: {
|
|
1440
|
-
workspaceId_idempotencyKey: {
|
|
1441
|
-
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
1442
|
-
idempotencyKey: KEY,
|
|
1443
|
-
},
|
|
1444
|
-
},
|
|
1445
|
-
select: { id: true, email: true, source: true, payload: true },
|
|
1446
|
-
});
|
|
1447
|
-
expect(
|
|
1448
|
-
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1449
|
-
).toMatchObject({
|
|
1450
|
-
idempotencyKey: KEY,
|
|
1451
|
-
});
|
|
1452
|
-
keyIsNowhere(res.body);
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
it("case 3 — a stored row with the same email, source and payload (keys reordered) replays: 201, the stored id, no create, no notification, no track, no capture line", async () => {
|
|
1456
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1457
|
-
id: STORED_ID,
|
|
1458
|
-
email: body.email,
|
|
1459
|
-
source: body.source,
|
|
1460
|
-
payload: { name: "Jamie", message: "hello" },
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
const res = await send({ "Idempotency-Key": KEY });
|
|
1464
|
-
|
|
1465
|
-
expect(res.statusCode).toBe(201);
|
|
1466
|
-
expect(res.json()).toEqual({
|
|
1467
|
-
id: STORED_ID,
|
|
1468
|
-
persisted: "db",
|
|
1469
|
-
email: { skipped: true, reason: "idempotent_replay" },
|
|
1470
|
-
});
|
|
1471
|
-
expect(Object.keys(res.json()).sort()).toEqual([
|
|
1472
|
-
"email",
|
|
1473
|
-
"id",
|
|
1474
|
-
"persisted",
|
|
1475
|
-
]);
|
|
1476
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1477
|
-
expect(sendLeadNotification).not.toHaveBeenCalled();
|
|
1478
|
-
expect(track).not.toHaveBeenCalled();
|
|
1479
|
-
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1480
|
-
const routed = logLines.find((l) => l.includes("lead routed"));
|
|
1481
|
-
expect(JSON.parse(routed!)).toMatchObject({ replayed: true });
|
|
1482
|
-
keyIsNowhere(res.body);
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
it.each([
|
|
1486
|
-
["email", { email: "other@example.com" }],
|
|
1487
|
-
["source", { source: "footer" }],
|
|
1488
|
-
["payload", { payload: { message: "a different message", name: "Jamie" } }],
|
|
1489
|
-
])(
|
|
1490
|
-
"case 4 — a stored row with a different %s is 422 idempotency_key_reused, no create, one warn log",
|
|
1491
|
-
async (_field, override) => {
|
|
1492
|
-
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1493
|
-
id: STORED_ID,
|
|
1494
|
-
email: body.email,
|
|
1495
|
-
source: body.source,
|
|
1496
|
-
payload: body.payload,
|
|
1497
|
-
...override,
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
const res = await send({ "Idempotency-Key": KEY });
|
|
1501
|
-
|
|
1502
|
-
expect(res.statusCode).toBe(422);
|
|
1503
|
-
expect(res.json()).toEqual({
|
|
1504
|
-
error: "idempotency_key_reused",
|
|
1505
|
-
message: "This Idempotency-Key was used for a different lead.",
|
|
1506
|
-
});
|
|
1507
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1508
|
-
const warns = logLines
|
|
1509
|
-
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
1510
|
-
.filter((l) => l["outcome"] === "idempotency_conflict");
|
|
1511
|
-
expect(warns).toHaveLength(1);
|
|
1512
|
-
expect(warns[0]).toMatchObject({
|
|
1513
|
-
level: 40,
|
|
1514
|
-
kind: "submit",
|
|
1515
|
-
siteId: RESOLVED_WORKSPACE_ID,
|
|
1516
|
-
});
|
|
1517
|
-
keyIsNowhere(res.body);
|
|
1518
|
-
},
|
|
1519
|
-
);
|
|
1520
|
-
|
|
1521
|
-
it.each([
|
|
1522
|
-
["empty", ""],
|
|
1523
|
-
["15 characters", "a".repeat(15)],
|
|
1524
|
-
["256 characters", "a".repeat(256)],
|
|
1525
|
-
["a space", "abcdefghij klmnopqrs"],
|
|
1526
|
-
["a non-ASCII character", "abcdefghijéklmnopqrs"],
|
|
1527
|
-
])(
|
|
1528
|
-
"case 5 — a malformed key (%s) is 400 invalid_input with no lead read or write",
|
|
1529
|
-
async (_label, key) => {
|
|
1530
|
-
const res = await app.inject({
|
|
1531
|
-
method: "POST",
|
|
1532
|
-
url: "/v1/leads/submit",
|
|
1533
|
-
headers: { "idempotency-key": key },
|
|
1534
|
-
payload: body,
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
expect(res.statusCode).toBe(400);
|
|
1538
|
-
expect(res.json()).toEqual({
|
|
1539
|
-
error: "invalid_input",
|
|
1540
|
-
message: "Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
1541
|
-
});
|
|
1542
|
-
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
1543
|
-
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1544
|
-
},
|
|
1545
|
-
);
|
|
1546
|
-
|
|
1547
|
-
it("case 6 — the same key stored only in another workspace is a miss here: create, 201 with the new id", async () => {
|
|
1548
|
-
jest.mocked(prisma.lead.findUnique).mockImplementation((async (args: {
|
|
1549
|
-
where: { workspaceId_idempotencyKey?: { workspaceId: string } };
|
|
1550
|
-
}) =>
|
|
1551
|
-
args.where.workspaceId_idempotencyKey?.workspaceId ===
|
|
1552
|
-
FOREIGN_WORKSPACE_ID
|
|
1553
|
-
? {
|
|
1554
|
-
id: STORED_ID,
|
|
1555
|
-
email: body.email,
|
|
1556
|
-
source: body.source,
|
|
1557
|
-
payload: body.payload,
|
|
1558
|
-
}
|
|
1559
|
-
: null) as never);
|
|
1560
|
-
|
|
1561
|
-
const res = await send({ "Idempotency-Key": KEY });
|
|
1562
|
-
|
|
1563
|
-
expect(res.statusCode).toBe(201);
|
|
1564
|
-
expect(res.json().id).toBe(NEW_ID);
|
|
1565
|
-
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1566
|
-
});
|
|
1567
|
-
|
|
1568
|
-
it("case 8 — the key reaches no response body and no log line on a fresh write, a replay or a conflict", async () => {
|
|
1569
|
-
const stored = {
|
|
1570
|
-
id: STORED_ID,
|
|
1571
|
-
email: body.email,
|
|
1572
|
-
source: body.source,
|
|
1573
|
-
payload: body.payload,
|
|
1574
|
-
};
|
|
1575
|
-
(prisma.lead.findUnique as jest.Mock)
|
|
1576
|
-
.mockResolvedValueOnce(null) // fresh write
|
|
1577
|
-
.mockResolvedValueOnce(stored) // replay
|
|
1578
|
-
.mockResolvedValueOnce({ ...stored, email: "other@example.com" }); // conflict
|
|
1579
|
-
|
|
1580
|
-
const statuses: number[] = [];
|
|
1581
|
-
for (let i = 0; i < 3; i++) {
|
|
1582
|
-
const res = await send({ "Idempotency-Key": KEY });
|
|
1583
|
-
statuses.push(res.statusCode);
|
|
1584
|
-
keyIsNowhere(res.body);
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
expect(statuses).toEqual([201, 201, 422]);
|
|
1588
|
-
expect(logLines.length).toBeGreaterThan(0);
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
it("case 7 — subscribe and callback ignore the header", async () => {
|
|
1592
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1593
|
-
id: "subscr_1",
|
|
1594
|
-
});
|
|
1595
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_1" });
|
|
1596
|
-
|
|
1597
|
-
const sub = await send(
|
|
1598
|
-
{ "Idempotency-Key": "bad" },
|
|
1599
|
-
"/v1/leads/subscribe",
|
|
1600
|
-
{
|
|
1601
|
-
kind: "subscribe",
|
|
1602
|
-
email: "sub@example.com",
|
|
1603
|
-
source: "footer",
|
|
1604
|
-
},
|
|
1605
|
-
);
|
|
1606
|
-
const cb = await send({ "Idempotency-Key": KEY }, "/v1/leads/callback", {
|
|
1607
|
-
kind: "callback",
|
|
1608
|
-
name: "Alice",
|
|
1609
|
-
phone: "+15551234567",
|
|
1610
|
-
payload: {},
|
|
1611
|
-
});
|
|
1612
|
-
|
|
1613
|
-
expect(sub.statusCode).toBe(201);
|
|
1614
|
-
expect(cb.statusCode).toBe(201);
|
|
1615
|
-
expect(keyLookups()).toHaveLength(0);
|
|
1616
|
-
expect(
|
|
1617
|
-
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data,
|
|
1618
|
-
).not.toHaveProperty("idempotencyKey");
|
|
1619
|
-
});
|
|
1620
|
-
});
|