create-ailk 0.1.1 → 0.3.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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,983 +0,0 @@
1
- /**
2
- * @file recommender-capture.test.ts
3
- * @description Unit tests for the recommender email-moment service (#4837).
4
- *
5
- * Mocks (external boundaries only — the repo's test rule; no internal module
6
- * is mocked):
7
- * - ../../lib/prisma (auto-mock via __mocks__/prisma.ts) — the DB
8
- * - @working-theory/email (sendLeadNotification + sendDeliverableEmail) —
9
- * reached transitively through the REAL routeLead / fulfillDeliverable,
10
- * exactly as flow-engine.test.ts drives them
11
- * - @working-theory/license (checkLicense) — the flow-engine.test.ts idiom
12
- * - @working-theory/observability logger — spied for the AC-6 PII walk
13
- *
14
- * So AC-1 asserts the Lead on `prisma.lead.create` (routeLead's own write),
15
- * AC-3 asserts the grant on `prisma.deliverableGrant.create`
16
- * (fulfillDeliverable's own mint), and AC-6 drives routeLead to `ok: false`
17
- * the way production can — the DB write rejects AND the file fallback
18
- * cannot write (`AILK_INBOX_FALLBACK_DIR` pointed at an unwritable path, the
19
- * lead-routing.test.ts idiom).
20
- *
21
- * The fixtures are a schema-valid recommending register + the compiled
22
- * FlowConfig `serveFlow` parses; `recommend()` / `price()` are the REAL pure
23
- * functions, so AC-1's `fits` and AC-4's `quote` are derived from the same
24
- * engine the browser runs (spec D3/D6).
25
- */
26
-
27
- // 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)
28
- jest.mock("../../lib/prisma.js");
29
-
30
- const mockSendDeliverableEmail = jest.fn();
31
- jest.mock("@working-theory/email", () => ({
32
- sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
33
- sendDeliverableEmail: (...args: unknown[]) => mockSendDeliverableEmail(...args),
34
- }));
35
-
36
- jest.mock("@working-theory/license", () => ({
37
- checkLicense: jest.fn().mockResolvedValue({
38
- tier: "free",
39
- plan: null,
40
- features: [],
41
- expiresAt: null,
42
- revoked: false,
43
- }),
44
- }));
45
-
46
- const mockLoggerInfo = jest.fn();
47
- const mockLoggerWarn = jest.fn();
48
- const mockLoggerError = jest.fn();
49
- jest.mock("@working-theory/observability", () => ({
50
- logger: {
51
- info: (...args: unknown[]) => mockLoggerInfo(...args),
52
- warn: (...args: unknown[]) => mockLoggerWarn(...args),
53
- error: (...args: unknown[]) => mockLoggerError(...args),
54
- debug: jest.fn(),
55
- },
56
- }));
57
-
58
- import { price, recommend } from "@working-theory/validation";
59
-
60
- import { prisma } from "../../lib/prisma.js";
61
- import { MAX_AGGREGATION_ROWS } from "../answer-distributions.js";
62
- import {
63
- attachStripeSession,
64
- captureRecommenderChoice,
65
- expireFlowCheckout,
66
- findRegisterForSlug,
67
- FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS,
68
- recordCheckout,
69
- } from "../recommender-capture.js";
70
-
71
- // ─── Fixtures ────────────────────────────────────────────────────────────────
72
-
73
- const SITE = "site_1";
74
- const SLUG = "ailk-reco";
75
- const EMAIL = "buyer@example.com";
76
- const en = (s: string) => ({ en: s });
77
-
78
- const SOURCE_META = {
79
- ip: "203.0.113.9",
80
- userAgent: "jest",
81
- receivedAt: "2026-09-05T20:00:00.000Z",
82
- };
83
-
84
- const OFFERS = [
85
- {
86
- key: "free_oss",
87
- copy: { name: en("Free (OSS)") },
88
- price: { kind: "label" as const, label: en("Free — self-hosted") },
89
- rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders get it free.") }],
90
- },
91
- {
92
- key: "pro",
93
- copy: { name: en("Pro") },
94
- price: { kind: "exact" as const, amount: 249, period: "annual" as const },
95
- rules: [
96
- { when: { answer: "q_team", eq: "small_team" }, reason: en("Teams need seats.") },
97
- ],
98
- },
99
- ];
100
-
101
- function rateTable(currency?: string) {
102
- return {
103
- seatRates: [{ offer: "pro", amount: 249, period: "annual" as const }],
104
- creditLadder: {
105
- denomination: "monthly" as const,
106
- tiers: [{ credits: 10, amount: 99 }],
107
- custom: en("Contact us for larger packages"),
108
- },
109
- ...(currency !== undefined ? { currency } : {}),
110
- };
111
- }
112
-
113
- const QUESTION_OPTIONS = [
114
- { value: "solo", label: en("Solo") },
115
- { value: "small_team", label: en("Small team") },
116
- ];
117
-
118
- function register(overrides: Record<string, unknown> = {}) {
119
- return {
120
- key: "reco-exp",
121
- mode: "recommending" as const,
122
- status: "live" as const,
123
- waitlists: [{ key: "main", flowSlug: SLUG }],
124
- questionSet: {
125
- version: 1 as const,
126
- questions: [
127
- {
128
- id: "q_team",
129
- prompt: en("Team?"),
130
- answerType: "single" as const,
131
- options: QUESTION_OPTIONS,
132
- },
133
- ],
134
- },
135
- tracking: {
136
- version: 1 as const,
137
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
138
- waitlists: { main: { campaign: "reco-campaign", channels: ["organic"] } },
139
- },
140
- stripeConfigRef: "stripe-main",
141
- offers: OFFERS,
142
- rateTable: rateTable("usd"),
143
- ...overrides,
144
- };
145
- }
146
-
147
- function flowConfig(overrides: Record<string, unknown> = {}) {
148
- return {
149
- slug: SLUG,
150
- defaultLocale: "en",
151
- mode: "recommending" as const,
152
- steps: [
153
- {
154
- key: "team",
155
- type: "segmentation" as const,
156
- copy: { title: en("Tell us about your team") },
157
- questions: [{ name: "q_team", options: QUESTION_OPTIONS }],
158
- },
159
- ],
160
- offers: OFFERS,
161
- rateTable: rateTable("usd"),
162
- recommendation: { key: "recommendation", copy: { title: en("Your recommendation") } },
163
- experiment: { key: "reco-exp", waitlist: "main" },
164
- ...overrides,
165
- };
166
- }
167
-
168
- const DELIVERABLES = {
169
- assets: [{ assetKey: "plan-pdf" }],
170
- copy: { subject: en("Your plan"), body: en("Here it is") },
171
- };
172
-
173
- const ANSWERS = { q_team: "small_team" };
174
-
175
- function arm(opts: { config?: Record<string, unknown>; register?: Record<string, unknown> } = {}) {
176
- (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
177
- id: "flow_1",
178
- siteId: SITE,
179
- slug: SLUG,
180
- config: flowConfig(opts.config),
181
- });
182
- (prisma.waitlistExperiment.findMany as jest.Mock).mockResolvedValue([
183
- { id: "wlexp_1", config: register(opts.register) },
184
- ]);
185
- (prisma.flowSession.create as jest.Mock).mockResolvedValue({ id: "flsess_new" });
186
- // Resume lookups see no walk session; recordCheckout's owner check sees the
187
- // freshly-created session with its lead stamped.
188
- (prisma.flowSession.findUnique as jest.Mock).mockImplementation(async (args: { where: { id: string } }) =>
189
- args.where.id === "flsess_new" ? { siteId: SITE, flowSlug: SLUG, leadId: "lead_1" } : null,
190
- );
191
- (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
192
- (prisma.flowCheckout.create as jest.Mock).mockResolvedValue({ id: "flchk_1" });
193
- // #4912 — the keyed idempotency lookup sees no prior attempt by default.
194
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(null);
195
- (prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
196
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_1" });
197
- (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
198
- id: "dasset_1",
199
- siteId: SITE,
200
- key: "plan-pdf",
201
- });
202
- (prisma.deliverableGrant.create as jest.Mock).mockResolvedValue({ id: "dgrant_1" });
203
- (prisma.deliverableGrant.update as jest.Mock).mockResolvedValue({});
204
- mockSendDeliverableEmail.mockResolvedValue({ ok: true, messageId: "msg-deliverable" });
205
- }
206
-
207
- function saveInput(extra: Record<string, unknown> = {}) {
208
- return {
209
- siteId: SITE,
210
- flowSlug: SLUG,
211
- answers: ANSWERS,
212
- email: EMAIL,
213
- locale: "en",
214
- choice: "save" as const,
215
- sourceMeta: SOURCE_META,
216
- ...extra,
217
- };
218
- }
219
-
220
- beforeEach(() => {
221
- jest.clearAllMocks();
222
- process.env.DATABASE_URL = "postgres://test";
223
- process.env.AILK_DELIVERABLE_EMAIL_FROM = "deliverable@example.com";
224
- process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
225
- delete process.env.AILK_LEAD_NOTIFICATION_TO;
226
- });
227
-
228
- // ─── AC-1 — cold-load save: one session + one lead with the full payload ────
229
-
230
- describe("AC-1 — cold-load save creates one session + one lead with the full payload", () => {
231
- it("creates exactly one completed session keyed by segmentation step, and one lead via routeLead", async () => {
232
- arm();
233
- const result = await captureRecommenderChoice(saveInput());
234
- expect(result.ok).toBe(true);
235
- if (!result.ok) return;
236
-
237
- expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
238
- const createArg = (prisma.flowSession.create as jest.Mock).mock.calls[0][0];
239
- expect(createArg.data.siteId).toBe(SITE);
240
- expect(createArg.data.flowSlug).toBe(SLUG);
241
- expect(createArg.data.email).toBe(EMAIL);
242
- expect(createArg.data.completedAt).toBeInstanceOf(Date);
243
- expect(createArg.data.state).toEqual({ team: { q_team: "small_team" } });
244
-
245
- // The REAL routeLead ran — its one DB write is the Lead assertion surface.
246
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
247
- const leadData = (prisma.lead.create as jest.Mock).mock.calls[0][0].data;
248
- expect(leadData.siteId).toBe(SITE);
249
- expect(leadData.source).toBe(`flow:${SLUG}`);
250
- expect(leadData.email).toBe(EMAIL);
251
- const expectedFits = recommend(register() as never, ANSWERS).fits.map((f) => f.offerKey);
252
- expect(expectedFits).toEqual(["pro"]);
253
- expect(leadData.payload).toMatchObject({
254
- q_team: "small_team",
255
- flowSlug: SLUG,
256
- locale: "en",
257
- experimentKey: "reco-exp",
258
- waitlistKey: "main",
259
- recommender: { choice: "save", fits: expectedFits },
260
- });
261
- expect(result.sessionId).toBe("flsess_new");
262
- expect(result.leadId).toBe("lead_1");
263
- // leadId is stamped on the session after routeLead succeeds.
264
- expect(prisma.flowSession.update).toHaveBeenCalledWith(
265
- expect.objectContaining({
266
- where: { id: "flsess_new" },
267
- data: expect.objectContaining({ leadId: "lead_1" }),
268
- }),
269
- );
270
- });
271
-
272
- it("findRegisterForSlug resolves the register via one siteId-scoped findMany, undefined on no match", async () => {
273
- arm();
274
- const found = await findRegisterForSlug(SITE, SLUG);
275
- expect(found?.stripeConfigRef).toBe("stripe-main");
276
- expect(found?.rateTable?.currency).toBe("usd");
277
- expect(prisma.waitlistExperiment.findMany).toHaveBeenCalledTimes(1);
278
- expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
279
- siteId: SITE,
280
- });
281
- expect(await findRegisterForSlug(SITE, "no-such-flow")).toBeUndefined();
282
- });
283
-
284
- it("findRegisterForSlug bounds its findMany with take: MAX_AGGREGATION_ROWS (#5147)", async () => {
285
- arm();
286
- await findRegisterForSlug(SITE, SLUG);
287
- expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].take).toBe(
288
- MAX_AGGREGATION_ROWS,
289
- );
290
- });
291
- });
292
-
293
- // ─── AC-2 — resume, never duplicate ─────────────────────────────────────────
294
-
295
- describe("AC-2 — a walk session is resumed, never duplicated", () => {
296
- it("updates the existing session in place and never creates", async () => {
297
- arm();
298
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
299
- id: "flsess_walk",
300
- siteId: SITE,
301
- flowSlug: SLUG,
302
- // #4912 — SESSION_RESUME_SELECT now returns these two; an unowned walk
303
- // session (no Lead stamped yet) is the case this test has always meant.
304
- leadId: null,
305
- email: null,
306
- });
307
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
308
- expect(result.ok).toBe(true);
309
- expect(prisma.flowSession.create).not.toHaveBeenCalled();
310
- const updates = (prisma.flowSession.update as jest.Mock).mock.calls;
311
- expect(updates.length).toBeGreaterThanOrEqual(1);
312
- expect(updates[0][0].where).toEqual({ id: "flsess_walk" });
313
- expect(updates[0][0].data).toMatchObject({
314
- email: EMAIL,
315
- state: { team: { q_team: "small_team" } },
316
- });
317
- expect(updates[0][0].data.completedAt).toBeInstanceOf(Date);
318
- if (result.ok) expect(result.sessionId).toBe("flsess_walk");
319
- });
320
-
321
- it.each([
322
- ["unknown id", null],
323
- ["foreign-site row", { id: "flsess_x", siteId: "site_other", flowSlug: SLUG, leadId: null, email: null }],
324
- ["other-slug row", { id: "flsess_x", siteId: SITE, flowSlug: "other-flow", leadId: null, email: null }],
325
- ])("falls back to create on an %s", async (_label, row) => {
326
- arm();
327
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(row);
328
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_x" }));
329
- expect(result.ok).toBe(true);
330
- expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
331
- if (result.ok) expect(result.sessionId).toBe("flsess_new");
332
- });
333
- });
334
-
335
- // ─── #4912 AC-2 — resume ownership, and the Lead reused rather than doubled ──
336
-
337
- describe("#4912 AC-2 — a session that carries a Lead is resumable only by its own email", () => {
338
- const OWNED = {
339
- id: "flsess_walk",
340
- siteId: SITE,
341
- flowSlug: SLUG,
342
- leadId: "lead_owner",
343
- email: EMAIL,
344
- };
345
-
346
- it("resumes for the same email and REUSES the Lead — no second routeLead write", async () => {
347
- arm();
348
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
349
-
350
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
351
-
352
- expect(result.ok).toBe(true);
353
- if (!result.ok) return;
354
- expect(result.sessionId).toBe("flsess_walk");
355
- expect(result.leadId).toBe("lead_owner");
356
- // The whole point of #4860: routeLead's own write does not happen twice.
357
- expect(prisma.lead.create).not.toHaveBeenCalled();
358
- expect(prisma.flowSession.create).not.toHaveBeenCalled();
359
- // The answers/locale/completion refresh still lands …
360
- const updates = (prisma.flowSession.update as jest.Mock).mock.calls;
361
- expect(updates).toHaveLength(1);
362
- expect(updates[0][0].data).toMatchObject({
363
- email: EMAIL,
364
- state: { team: { q_team: "small_team" } },
365
- });
366
- // … and the leadId is NOT re-stamped, because it never changed.
367
- expect(updates[0][0].data.leadId).toBeUndefined();
368
- });
369
-
370
- it.each([
371
- ["a second, different email", "attacker@example.com"],
372
- ["an email that merely looks similar", "buyer@example.com.evil.test"],
373
- ])("never overwrites the row for %s — it falls back to create", async (_label, email) => {
374
- arm();
375
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
376
-
377
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk", email }));
378
-
379
- expect(result.ok).toBe(true);
380
- if (!result.ok) return;
381
- // Nothing at all was written to the session somebody else owns.
382
- const touched = (prisma.flowSession.update as jest.Mock).mock.calls.filter(
383
- (call: [{ where: { id: string } }]) => call[0].where.id === "flsess_walk",
384
- );
385
- expect(touched).toHaveLength(0);
386
- expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
387
- expect(result.sessionId).toBe("flsess_new");
388
- expect(result.leadId).toBe("lead_1");
389
- });
390
-
391
- it("compares the owner email case-insensitively, after trimming", async () => {
392
- arm();
393
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
394
-
395
- const result = await captureRecommenderChoice(
396
- saveInput({ sessionId: "flsess_walk", email: " Buyer@Example.COM " }),
397
- );
398
-
399
- expect(result.ok).toBe(true);
400
- if (!result.ok) return;
401
- expect(result.sessionId).toBe("flsess_walk");
402
- expect(result.leadId).toBe("lead_owner");
403
- });
404
-
405
- it("falls back to create when the row carries a leadId but no email to own it", async () => {
406
- arm();
407
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({ ...OWNED, email: null });
408
-
409
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
410
-
411
- expect(result.ok).toBe(true);
412
- if (!result.ok) return;
413
- expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
414
- expect(result.sessionId).toBe("flsess_new");
415
- });
416
-
417
- it("still resumes an unowned walk session (no leadId yet) and stamps the new Lead on it", async () => {
418
- arm();
419
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
420
- ...OWNED,
421
- leadId: null,
422
- email: null,
423
- });
424
-
425
- const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
426
-
427
- expect(result.ok).toBe(true);
428
- if (!result.ok) return;
429
- expect(result.sessionId).toBe("flsess_walk");
430
- expect(result.leadId).toBe("lead_1");
431
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
432
- });
433
-
434
- it("does not re-mint the T+0 grant on an owned resume", async () => {
435
- arm({ config: { deliverables: DELIVERABLES } });
436
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
437
-
438
- await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
439
-
440
- expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
441
- expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
442
- });
443
- });
444
-
445
- // ─── AC-3 — the T+0 grant path: reused, opt-in, fail-soft ───────────────────
446
-
447
- describe("AC-3 — the T+0 grant path is reused, opt-in, and fail-soft", () => {
448
- it("mints one grant for the session + lead ids when the config carries deliverables (the real fulfillDeliverable)", async () => {
449
- arm({ config: { deliverables: DELIVERABLES } });
450
- const result = await captureRecommenderChoice(saveInput());
451
- expect(result.ok).toBe(true);
452
- // The asset lookup is keyed on the block's assetKey for THIS site …
453
- expect(prisma.deliverableAsset.findUnique).toHaveBeenCalledWith(
454
- expect.objectContaining({ where: { siteId_key: { siteId: SITE, key: "plan-pdf" } } }),
455
- );
456
- // … and the grant is minted once, against the new session + the lead.
457
- expect(prisma.deliverableGrant.create).toHaveBeenCalledTimes(1);
458
- expect((prisma.deliverableGrant.create as jest.Mock).mock.calls[0][0].data).toEqual({
459
- siteId: SITE,
460
- assetId: "dasset_1",
461
- leadId: "lead_1",
462
- sessionId: "flsess_new",
463
- });
464
- expect(mockSendDeliverableEmail).toHaveBeenCalledTimes(1);
465
- expect(mockSendDeliverableEmail.mock.calls[0][0].to).toBe(EMAIL);
466
- });
467
-
468
- it("never touches the grant path without a deliverables block", async () => {
469
- arm();
470
- await captureRecommenderChoice(saveInput());
471
- expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
472
- expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
473
- });
474
-
475
- it("a grant-path rejection (the asset read throwing) leaves the capture ok", async () => {
476
- arm({ config: { deliverables: DELIVERABLES } });
477
- (prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(new Error("db down"));
478
- const result = await captureRecommenderChoice(saveInput());
479
- expect(result.ok).toBe(true);
480
- expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
481
- });
482
- });
483
-
484
- // ─── AC-4 — recordCheckout: MINOR units, and every refusal ──────────────────
485
-
486
- describe("AC-4 — the checkout row carries a server-recomputed MINOR-unit amount", () => {
487
- function checkoutInput(extra: Record<string, unknown> = {}) {
488
- return {
489
- siteId: SITE,
490
- flowSlug: SLUG,
491
- experimentKey: "reco-exp",
492
- sessionId: "flsess_new",
493
- leadId: "lead_1",
494
- email: EMAIL,
495
- offerKey: "pro",
496
- seats: 1,
497
- period: "annual" as const,
498
- creditStop: { kind: "none" as const },
499
- permalink: "eyJ2ZXJzaW9uIjoxfQ",
500
- ...extra,
501
- };
502
- }
503
-
504
- it("writes one row with amountMinor 24900 (never 249), the currency, the quote and the permalink", async () => {
505
- arm();
506
- const result = await recordCheckout(checkoutInput());
507
- expect(result.ok).toBe(true);
508
- if (!result.ok) return;
509
- expect(prisma.flowCheckout.create).toHaveBeenCalledTimes(1);
510
- const data = (prisma.flowCheckout.create as jest.Mock).mock.calls[0][0].data;
511
- expect(data.amountMinor).toBe(24900);
512
- expect(data.amountMinor).not.toBe(249);
513
- expect(data.currency).toBe("usd");
514
- expect(data.status).toBe("created");
515
- expect(data.stripeSessionId).toBeNull();
516
- expect(data.quote).toEqual(price(register() as never, "pro", 1, "annual", { kind: "none" }));
517
- expect(data.permalink).toBe("eyJ2ZXJzaW9uIjoxfQ");
518
- expect(data).toMatchObject({
519
- siteId: SITE,
520
- flowSlug: SLUG,
521
- experimentKey: "reco-exp",
522
- sessionId: "flsess_new",
523
- leadId: "lead_1",
524
- offerKey: "pro",
525
- seats: 1,
526
- period: "annual",
527
- creditStop: { kind: "none" },
528
- });
529
- expect(result.flowCheckoutId).toBe("flchk_1");
530
- expect(result.amountMinor).toBe(24900);
531
- });
532
-
533
- it.each([
534
- ["unknown_offer", { offerKey: "nope" }, {}],
535
- ["custom_stop", { creditStop: { kind: "custom" as const } }, {}],
536
- ["invalid_seats", { seats: 1000 }, {}],
537
- ["invalid_seats", { seats: 0 }, {}],
538
- ["no_rate_table", {}, { register: { rateTable: undefined } }],
539
- ["no_currency", {}, { register: { rateTable: rateTable() } }],
540
- ["no_register", { flowSlug: "no-such-flow" }, {}],
541
- ["session_mismatch", { sessionId: "flsess_other" }, {}],
542
- ["session_mismatch", { leadId: "lead_other" }, {}],
543
- ])("returns %s and writes no row", async (kind, extra, armOpts) => {
544
- arm(armOpts as never);
545
- const result = await recordCheckout(checkoutInput(extra));
546
- expect(result.ok).toBe(false);
547
- if (result.ok) return;
548
- expect(result.error.kind).toBe(kind);
549
- expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
550
- });
551
-
552
- it("resolves the register from the DB itself — the caller supplies no register", async () => {
553
- arm();
554
- await recordCheckout(checkoutInput());
555
- expect(prisma.waitlistExperiment.findMany).toHaveBeenCalledTimes(1);
556
- expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].where).toEqual({ siteId: SITE });
557
- });
558
-
559
- it("attachStripeSession stamps the session id and the replay URL, scoped to the site", async () => {
560
- arm();
561
- const attached = await attachStripeSession(
562
- SITE,
563
- "flchk_1",
564
- "cs_test_abc",
565
- "https://checkout.stripe.com/c/pay/cs_test_abc",
566
- );
567
- expect(attached).toEqual({ count: 1 });
568
- expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
569
- // #4912 — `status: "created"` mirrors the #4838 webhook's own join, so a
570
- // row something else already finished or expired never takes the stamp.
571
- where: { id: "flchk_1", siteId: SITE, status: "created" },
572
- data: {
573
- stripeSessionId: "cs_test_abc",
574
- checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_abc",
575
- },
576
- });
577
- });
578
- });
579
-
580
- // ─── #4912 AC-3 / AC-4 — checkout idempotency, replay and reconcile ──────────
581
-
582
- describe("#4912 AC-3 — /checkout is keyed by a server-derived fingerprint", () => {
583
- function checkoutInput(extra: Record<string, unknown> = {}) {
584
- return {
585
- siteId: SITE,
586
- flowSlug: SLUG,
587
- experimentKey: "reco-exp",
588
- sessionId: "flsess_new",
589
- leadId: "lead_1",
590
- email: EMAIL,
591
- offerKey: "pro",
592
- seats: 1,
593
- period: "annual" as const,
594
- creditStop: { kind: "none" as const },
595
- permalink: "eyJ2ZXJzaW9uIjoxfQ",
596
- ...extra,
597
- };
598
- }
599
-
600
- /** A keyed row as the lookup sees it; `createdAt` defaults to "just now". */
601
- function keyedRow(over: Record<string, unknown> = {}) {
602
- return {
603
- id: "flchk_first",
604
- status: "created",
605
- stripeSessionId: "cs_test_first",
606
- checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_first",
607
- createdAt: new Date(Date.now() - 60_000),
608
- ...over,
609
- };
610
- }
611
-
612
- function lastKey(): string {
613
- const calls = (prisma.flowCheckout.findUnique as jest.Mock).mock.calls;
614
- return calls[calls.length - 1][0].where.siteId_idempotencyKey.idempotencyKey;
615
- }
616
-
617
- function createdKeys(): unknown[] {
618
- return (prisma.flowCheckout.create as jest.Mock).mock.calls.map(
619
- (call: [{ data: { idempotencyKey: unknown } }]) => call[0].data.idempotencyKey,
620
- );
621
- }
622
-
623
- it("looks the attempt up on a site-scoped sha256 digest and writes that key onto the new row", async () => {
624
- arm();
625
- const result = await recordCheckout(checkoutInput());
626
-
627
- expect(result.ok).toBe(true);
628
- if (!result.ok) return;
629
- const where = (prisma.flowCheckout.findUnique as jest.Mock).mock.calls[0][0].where;
630
- expect(where.siteId_idempotencyKey.siteId).toBe(SITE);
631
- expect(where.siteId_idempotencyKey.idempotencyKey).toMatch(/^[0-9a-f]{64}$/);
632
- expect(createdKeys()).toEqual([where.siteId_idempotencyKey.idempotencyKey]);
633
- expect(result.replayed).toBe(false);
634
- });
635
-
636
- it("gives two identical requests the same key, and any changed field a different one", async () => {
637
- arm();
638
- await recordCheckout(checkoutInput());
639
- const base = lastKey();
640
- await recordCheckout(checkoutInput());
641
- expect(lastKey()).toBe(base);
642
-
643
- for (const extra of [
644
- { seats: 2 },
645
- { offerKey: "free_oss" },
646
- { period: "monthly" as const },
647
- { creditStop: { kind: "tier" as const, index: 0 } },
648
- { permalink: "eyJ2ZXJzaW9uIjoyfQ" },
649
- { sessionId: "flsess_other_walk" },
650
- { email: "someone-else@example.com" },
651
- { flowSlug: SLUG.toUpperCase() },
652
- ]) {
653
- await recordCheckout(checkoutInput(extra));
654
- expect(lastKey()).not.toBe(base);
655
- }
656
- // The email is hashed into the key and never written to the row.
657
- for (const call of (prisma.flowCheckout.create as jest.Mock).mock.calls) {
658
- expect(JSON.stringify(call[0].data)).not.toContain(EMAIL);
659
- }
660
- });
661
-
662
- it("distinguishes two credit-stop TIERS, not merely the stop kind", async () => {
663
- // A two-rung ladder, so index 1 is a priceable stop rather than a refusal.
664
- arm({
665
- register: {
666
- rateTable: {
667
- ...rateTable("usd"),
668
- creditLadder: {
669
- denomination: "monthly" as const,
670
- tiers: [
671
- { credits: 10, amount: 99 },
672
- { credits: 25, amount: 199 },
673
- ],
674
- custom: en("Contact us for larger packages"),
675
- },
676
- },
677
- },
678
- });
679
- const first = await recordCheckout(checkoutInput({ creditStop: { kind: "tier" as const, index: 0 } }));
680
- expect(first.ok).toBe(true);
681
- const tier0 = lastKey();
682
- const second = await recordCheckout(checkoutInput({ creditStop: { kind: "tier" as const, index: 1 } }));
683
- expect(second.ok).toBe(true);
684
- expect(lastKey()).not.toBe(tier0);
685
- });
686
-
687
- it("distinguishes the same request against a DIFFERENT site", async () => {
688
- arm();
689
- await recordCheckout(checkoutInput());
690
- const here = lastKey();
691
- (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
692
- id: "flow_1",
693
- siteId: "site_other",
694
- slug: SLUG,
695
- config: flowConfig(),
696
- });
697
- (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
698
- siteId: "site_other",
699
- flowSlug: SLUG,
700
- leadId: "lead_1",
701
- });
702
- await recordCheckout(checkoutInput({ siteId: "site_other" }));
703
- expect(lastKey()).not.toBe(here);
704
- });
705
-
706
- it("changes the key when the SERVER's price moves, so a replay can never serve a stale amount", async () => {
707
- arm();
708
- await recordCheckout(checkoutInput());
709
- const at249 = lastKey();
710
- // Only the register's seat rate moves; the request body is identical.
711
- arm({ register: { rateTable: { ...rateTable("usd"), seatRates: [{ offer: "pro", amount: 300, period: "annual" as const }] } } });
712
- const result = await recordCheckout(checkoutInput());
713
- expect(result.ok).toBe(true);
714
- if (!result.ok) return;
715
- expect(result.amountMinor).toBe(30000);
716
- expect(lastKey()).not.toBe(at249);
717
- });
718
-
719
- it("replays a live keyed row inside the TTL — no second row, no second Stripe session", async () => {
720
- arm();
721
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(keyedRow());
722
-
723
- const result = await recordCheckout(checkoutInput());
724
-
725
- expect(result.ok).toBe(true);
726
- if (!result.ok) return;
727
- expect(result.replayed).toBe(true);
728
- if (!result.replayed) return;
729
- expect(result.flowCheckoutId).toBe("flchk_first");
730
- expect(result.stripeSessionId).toBe("cs_test_first");
731
- expect(result.checkoutUrl).toBe("https://checkout.stripe.com/c/pay/cs_test_first");
732
- // The price is still the server's own, recomputed on the replay.
733
- expect(result.amountMinor).toBe(24900);
734
- expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
735
- expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
736
- });
737
-
738
- it("replays exactly AT the TTL boundary, and no longer one millisecond past it", async () => {
739
- arm();
740
- const at = Date.now();
741
- jest.spyOn(Date, "now").mockReturnValue(at);
742
- try {
743
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
744
- keyedRow({ createdAt: new Date(at - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS) }),
745
- );
746
- const onBoundary = await recordCheckout(checkoutInput());
747
- expect(onBoundary.ok && onBoundary.replayed).toBe(true);
748
-
749
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
750
- keyedRow({ createdAt: new Date(at - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 1) }),
751
- );
752
- const pastIt = await recordCheckout(checkoutInput());
753
- expect(pastIt.ok && pastIt.replayed).toBe(false);
754
- } finally {
755
- (Date.now as jest.Mock).mockRestore();
756
- }
757
- });
758
-
759
- it("never replays a row the webhook already completed — the visitor gets a new session", async () => {
760
- arm();
761
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
762
- keyedRow({ status: "completed" }),
763
- );
764
-
765
- const result = await recordCheckout(checkoutInput());
766
-
767
- expect(result.ok).toBe(true);
768
- if (!result.ok) return;
769
- expect(result.replayed).toBe(false);
770
- // A completed row keeps its status and its Stripe ids; only the key is
771
- // released, so this new purchase can take a keyed row of its own.
772
- expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
773
- where: { id: "flchk_first", siteId: SITE },
774
- data: { idempotencyKey: null },
775
- });
776
- expect(createdKeys()).toHaveLength(1);
777
- expect(createdKeys()[0]).toMatch(/^[0-9a-f]{64}$/);
778
- });
779
-
780
- it("reconciles a STALE row that never took a Stripe session, then writes a fresh keyed one", async () => {
781
- arm();
782
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
783
- keyedRow({
784
- id: "flchk_stale",
785
- stripeSessionId: null,
786
- checkoutUrl: null,
787
- createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
788
- }),
789
- );
790
-
791
- const result = await recordCheckout(checkoutInput());
792
-
793
- expect(result.ok).toBe(true);
794
- if (!result.ok) return;
795
- expect(result.replayed).toBe(false);
796
- // Nothing can ever join it, so it is marked dead and its stored URL dropped.
797
- expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
798
- where: { id: "flchk_stale", siteId: SITE },
799
- data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
800
- });
801
- expect(result.flowCheckoutId).toBe("flchk_1");
802
- });
803
-
804
- it("leaves a STALE row that DID take a Stripe session joinable — key released, status untouched", async () => {
805
- arm();
806
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
807
- keyedRow({
808
- id: "flchk_stale",
809
- createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
810
- }),
811
- );
812
-
813
- await recordCheckout(checkoutInput());
814
-
815
- // Marking it `expired` would make the #4838 webhook's `status: "created"`
816
- // update miss, so a payment on that still-open session would go unrecorded.
817
- expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
818
- where: { id: "flchk_stale", siteId: SITE },
819
- data: { idempotencyKey: null },
820
- });
821
- });
822
-
823
- it("leaves an IN-FLIGHT row completely alone and takes an unkeyed row instead", async () => {
824
- arm();
825
- // A row created moments ago whose own request is still inside its Stripe
826
- // call: stealing its key would strand the session about to be stamped on it.
827
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
828
- keyedRow({ id: "flchk_inflight", stripeSessionId: null, checkoutUrl: null }),
829
- );
830
-
831
- const result = await recordCheckout(checkoutInput());
832
-
833
- expect(result.ok).toBe(true);
834
- if (!result.ok) return;
835
- expect(result.replayed).toBe(false);
836
- expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
837
- expect(mockLoggerWarn).not.toHaveBeenCalledWith(
838
- expect.objectContaining({ outcome: "checkout_reconciled" }),
839
- expect.anything(),
840
- );
841
- expect(createdKeys()).toEqual([null]);
842
- });
843
-
844
- it("logs the reconcile with the row-id prefix and an outcome, and nothing else", async () => {
845
- arm();
846
- (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
847
- keyedRow({
848
- id: "flchk_stale_row_id",
849
- stripeSessionId: null,
850
- checkoutUrl: null,
851
- createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
852
- }),
853
- );
854
-
855
- await recordCheckout(checkoutInput());
856
-
857
- const line = mockLoggerWarn.mock.calls.find(
858
- (call: [Record<string, unknown>]) => call[0]?.["outcome"] === "checkout_reconciled",
859
- );
860
- expect(line).toBeDefined();
861
- expect(line?.[0]).toEqual({
862
- flowCheckoutIdPrefix: "flchk_st",
863
- outcome: "checkout_reconciled",
864
- });
865
- expect((line?.[0] as { flowCheckoutIdPrefix: string }).flowCheckoutIdPrefix).toHaveLength(8);
866
- });
867
-
868
- it("survives a lost create race: the unique violation becomes an unkeyed row, never a throw", async () => {
869
- arm();
870
- const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
871
- (prisma.flowCheckout.create as jest.Mock)
872
- .mockRejectedValueOnce(conflict)
873
- .mockResolvedValue({ id: "flchk_2" });
874
- // The winner has not stamped its Stripe session yet, so there is nothing
875
- // to replay.
876
- (prisma.flowCheckout.findUnique as jest.Mock)
877
- .mockResolvedValueOnce(null)
878
- .mockResolvedValue(keyedRow({ stripeSessionId: null, checkoutUrl: null }));
879
-
880
- const result = await recordCheckout(checkoutInput());
881
-
882
- expect(result.ok).toBe(true);
883
- if (!result.ok) return;
884
- expect(result.replayed).toBe(false);
885
- expect(result.flowCheckoutId).toBe("flchk_2");
886
- expect(createdKeys()[1]).toBeNull();
887
- });
888
-
889
- it("replays instead, when the create race is lost to a winner that already has its session", async () => {
890
- arm();
891
- const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
892
- (prisma.flowCheckout.create as jest.Mock).mockRejectedValueOnce(conflict);
893
- (prisma.flowCheckout.findUnique as jest.Mock)
894
- .mockResolvedValueOnce(null)
895
- .mockResolvedValue(keyedRow());
896
-
897
- const result = await recordCheckout(checkoutInput());
898
-
899
- expect(result.ok).toBe(true);
900
- if (!result.ok) return;
901
- expect(result.replayed).toBe(true);
902
- if (!result.replayed) return;
903
- expect(result.stripeSessionId).toBe("cs_test_first");
904
- });
905
-
906
- it("rethrows any create failure that is NOT a unique violation", async () => {
907
- arm();
908
- (prisma.flowCheckout.create as jest.Mock).mockRejectedValue(new Error("db down"));
909
- await expect(recordCheckout(checkoutInput())).rejects.toThrow("db down");
910
- });
911
-
912
- it("expireFlowCheckout kills only a still-created row, and releases its key and URL", async () => {
913
- arm();
914
- const result = await expireFlowCheckout(SITE, "flchk_1");
915
- expect(result).toEqual({ count: 1 });
916
- expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
917
- where: { id: "flchk_1", siteId: SITE, status: "created" },
918
- data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
919
- });
920
- });
921
- });
922
-
923
- // ─── AC-6 — failure asymmetry + PII boundary ────────────────────────────────
924
-
925
- describe("AC-6 — failure asymmetry + PII boundary", () => {
926
- it("a failed routeLead is a failed capture: completedAt stamped, no checkout row, no grant", async () => {
927
- arm({ config: { deliverables: DELIVERABLES } });
928
- // The way production fails: the DB write rejects AND the file fallback
929
- // cannot write (an unwritable dir — the lead-routing.test.ts idiom), so the
930
- // REAL routeLead returns persist_failed.
931
- process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
932
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
933
- const result = await captureRecommenderChoice(saveInput());
934
- expect(result.ok).toBe(false);
935
- if (result.ok) return;
936
- expect(result.error.kind).toBe("lead_failed");
937
- expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
938
- expect((prisma.flowSession.update as jest.Mock).mock.calls[0][0].data.completedAt).toBeInstanceOf(
939
- Date,
940
- );
941
- expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
942
- expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
943
- });
944
-
945
- it("a grant rejection whose message embeds the recipient address never reaches a log line", async () => {
946
- arm({ config: { deliverables: DELIVERABLES } });
947
- mockSendDeliverableEmail.mockRejectedValue(new Error(`Resend refused delivery to ${EMAIL}`));
948
- const result = await captureRecommenderChoice(saveInput());
949
- expect(result.ok).toBe(true);
950
- const lines = [...mockLoggerInfo.mock.calls, ...mockLoggerWarn.mock.calls, ...mockLoggerError.mock.calls]
951
- .map((call) => JSON.stringify(call))
952
- .filter((line) => line.includes("[recommender-capture]"));
953
- expect(lines.length).toBeGreaterThan(0);
954
- for (const line of lines) expect(line).not.toContain(EMAIL);
955
- const grantLine = mockLoggerWarn.mock.calls.find((c) => c[1] === "[recommender-capture] grant_failed");
956
- expect(grantLine?.[0].err).toBe("Error");
957
- });
958
-
959
- it("every logger call this module emits carries only the allow-listed keys", async () => {
960
- const ALLOWED = new Set(["siteId", "flowSlug", "sessionId", "leadId", "choice", "outcome", "err"]);
961
- arm({ config: { deliverables: DELIVERABLES } });
962
- (prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(new Error("db down"));
963
- await captureRecommenderChoice(saveInput());
964
- process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
965
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
966
- await captureRecommenderChoice(saveInput());
967
- // Only THIS module's lines — routeLead / fulfillDeliverable log through the
968
- // same logger with their own (already-tested) boundaries.
969
- const calls = [
970
- ...mockLoggerInfo.mock.calls,
971
- ...mockLoggerWarn.mock.calls,
972
- ...mockLoggerError.mock.calls,
973
- ].filter((call) => typeof call[1] === "string" && call[1].startsWith("[recommender-capture]"));
974
- expect(calls.length).toBeGreaterThan(0);
975
- for (const call of calls) {
976
- const ctx = call[0] as Record<string, unknown>;
977
- for (const key of Object.keys(ctx)) expect(ALLOWED.has(key)).toBe(true);
978
- const serialized = JSON.stringify(ctx);
979
- expect(serialized).not.toContain(EMAIL);
980
- expect(serialized).not.toContain("small_team");
981
- }
982
- });
983
- });