create-ailk 0.18.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.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. 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
- });