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,1314 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file waitlist-dashboard.test.ts
4
- *
5
- * The B0 service layer (#4665 D2/D3/D4/D5, #4671). Mocked Prisma (a
6
- * where-clause-honoring in-memory matcher, mirroring
7
- * `routes/workspaces/__tests__/tenant-isolation.test.ts`'s discipline): the
8
- * platform-operator predicate both ways (sentinel-org owner -> "all";
9
- * sentinel-org admin, non-sentinel owner -> "site"); every query carries the
10
- * scope; a foreign lead is `not_found`; D4 arithmetic on a fixture (raw /
11
- * qualified / null cases / "(direct)" / "(other)" / multi counted per
12
- * value); no write verb reachable.
13
- */
14
- import { waitlistDashboardListSchema } from "@working-theory/validation";
15
-
16
- import { MAX_AGGREGATION_ROWS } from "../answer-distributions";
17
-
18
- import {
19
- getExperimentComparison,
20
- getSignup,
21
- isPlatformOperator,
22
- listExperiments,
23
- listSignups,
24
- resolveWaitlistReadScope,
25
- WAITLIST_DASHBOARD_ROLES,
26
- } from "../waitlist-dashboard";
27
-
28
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
29
- jest.mock("../../lib/prisma.js", () => ({
30
- prisma: {
31
- waitlistExperiment: { findMany: jest.fn(), findFirst: jest.fn() },
32
- lead: { findMany: jest.fn(), findFirst: jest.fn() },
33
- waitlistScore: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
34
- flowSession: { findMany: jest.fn() },
35
- flowCheckout: { findMany: jest.fn() },
36
- },
37
- }));
38
-
39
- // #4861 — the register engine is wrapped, not replaced: the REAL `recommend`
40
- // runs, through a `jest.fn` so the per-call count is observable (spec §5.1
41
- // AC-1). Every existing test's behaviour is byte-identical.
42
- jest.mock("@working-theory/validation", () => {
43
- const actual = jest.requireActual("@working-theory/validation");
44
- return { ...actual, recommend: jest.fn(actual.recommend) };
45
- });
46
-
47
- import { logger } from "@working-theory/observability";
48
-
49
- import { prisma } from "../../lib/prisma.js";
50
- import type { TenantContext } from "../tenant-context";
51
-
52
- // ── Fixture ids ──────────────────────────────────────────────────────────────
53
-
54
- const ORG_SENTINEL = "org_00000000-0000-7000-8000-000000000001";
55
- const WS_A = "wksp_00000000-0000-7000-8000-000000000001"; // sentinel org's own site
56
- // A genuinely non-sentinel org id — a real (non-zero-timestamp) v7 uuid, so
57
- // `isSentinel` correctly reads false for it (unlike the all-zero-timestamp
58
- // sentinel range `00000000-0000-7000-8000-…`).
59
- const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
60
- const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
61
-
62
- const EXP_A = "wlexp_00000000-0000-7000-8000-0000000000aa"; // qualifying, site A
63
- const EXP_B = "wlexp_00000000-0000-7000-8000-0000000000bb"; // lead-capture, site B
64
-
65
- // ── Config fixtures (waitlistExperimentConfigSchema-shaped) ──────────────────
66
-
67
- const en = (s: string) => ({ en: s });
68
-
69
- const CONFIG_A = {
70
- key: "founder-diagnostic",
71
- mode: "qualifying" as const,
72
- status: "live" as const,
73
- waitlists: [
74
- { key: "early_bird", flowSlug: "founder-diagnostic-early" },
75
- { key: "general", flowSlug: "founder-diagnostic-general", entryPageKey: "gen-page" },
76
- ],
77
- questionSet: {
78
- version: 1 as const,
79
- questions: [
80
- {
81
- id: "role",
82
- prompt: en("Role?"),
83
- answerType: "single" as const,
84
- options: [
85
- { value: "founder", label: en("Founder") },
86
- { value: "other", label: en("Other") },
87
- ],
88
- },
89
- { id: "notes", prompt: en("Notes?"), answerType: "text" as const, minLength: 1 },
90
- ],
91
- },
92
- scoring: {
93
- version: 1 as const,
94
- points: [
95
- {
96
- name: "role_pt",
97
- question: "role",
98
- kind: "optionWeights" as const,
99
- weights: { founder: 5, other: 0 },
100
- },
101
- ],
102
- scores: { total: { sum: ["role_pt"] } },
103
- tiers: [
104
- { key: "hot", when: { score: "total", gte: 5 } },
105
- { key: "cold", when: { score: "total", gte: 0 } },
106
- ],
107
- default: "cold",
108
- serviceableExcludes: ["cold"],
109
- },
110
- tracking: {
111
- version: 1 as const,
112
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
113
- waitlists: {
114
- early_bird: { campaign: "eb-campaign", channels: ["organic"] },
115
- general: { campaign: "gen-campaign", channels: ["organic"] },
116
- },
117
- },
118
- stripeConfigRef: null,
119
- };
120
-
121
- const CONFIG_B = {
122
- key: "lead-capture-exp",
123
- mode: "lead-capture" as const,
124
- status: "draft" as const,
125
- waitlists: [{ key: "general", flowSlug: "lead-capture-general" }],
126
- questionSet: {
127
- version: 1 as const,
128
- questions: [{ id: "role", prompt: en("Role?"), answerType: "text" as const, minLength: 1 }],
129
- },
130
- tracking: {
131
- version: 1 as const,
132
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
133
- waitlists: { general: { campaign: "lc-campaign", channels: ["organic"] } },
134
- },
135
- stripeConfigRef: null,
136
- };
137
-
138
- const EXPERIMENTS = [
139
- {
140
- id: EXP_A,
141
- siteId: WS_A,
142
- key: CONFIG_A.key,
143
- mode: CONFIG_A.mode,
144
- status: CONFIG_A.status,
145
- config: CONFIG_A,
146
- createdAt: new Date("2026-08-01T00:00:00.000Z"),
147
- updatedAt: new Date("2026-08-01T00:00:00.000Z"),
148
- },
149
- {
150
- id: EXP_B,
151
- siteId: WS_B,
152
- key: CONFIG_B.key,
153
- mode: CONFIG_B.mode,
154
- status: CONFIG_B.status,
155
- config: CONFIG_B,
156
- createdAt: new Date("2026-08-02T00:00:00.000Z"),
157
- updatedAt: new Date("2026-08-02T00:00:00.000Z"),
158
- },
159
- ];
160
-
161
- // ── Lead fixtures for EXP_A (four signups across two waitlists) ─────────────
162
-
163
- const LEAD_1 = "lead_00000000-0000-7000-8000-000000000001"; // early_bird, founder, twitter -> hot
164
- const LEAD_2 = "lead_00000000-0000-7000-8000-000000000002"; // early_bird, other, direct -> cold
165
- const LEAD_3 = "lead_00000000-0000-7000-8000-000000000003"; // general, founder, direct, unscored
166
- const LEAD_4 = "lead_00000000-0000-7000-8000-000000000004"; // general, weird-value (-> other), twitter -> cold
167
- const LEAD_5 = "lead_00000000-0000-7000-8000-000000000005"; // EXP_B's one signup
168
-
169
- const LEADS = [
170
- {
171
- id: LEAD_1,
172
- siteId: WS_A,
173
- email: "one@example.com",
174
- source: "flow:founder-diagnostic-early",
175
- payload: {
176
- experimentKey: "founder-diagnostic",
177
- waitlistKey: "early_bird",
178
- role: "founder",
179
- notes: "hi",
180
- utm_source: "twitter",
181
- },
182
- createdAt: new Date("2026-08-10T00:00:00.000Z"),
183
- },
184
- {
185
- id: LEAD_2,
186
- siteId: WS_A,
187
- email: "two@example.com",
188
- source: "flow:founder-diagnostic-early",
189
- payload: {
190
- experimentKey: "founder-diagnostic",
191
- waitlistKey: "early_bird",
192
- role: "other",
193
- },
194
- createdAt: new Date("2026-08-11T00:00:00.000Z"),
195
- },
196
- {
197
- id: LEAD_3,
198
- siteId: WS_A,
199
- email: "three@example.com",
200
- source: "flow:founder-diagnostic-general",
201
- payload: {
202
- experimentKey: "founder-diagnostic",
203
- waitlistKey: "general",
204
- role: "founder",
205
- },
206
- createdAt: new Date("2026-08-12T00:00:00.000Z"),
207
- },
208
- {
209
- id: LEAD_4,
210
- siteId: WS_A,
211
- email: "four@example.com",
212
- source: "flow:founder-diagnostic-general",
213
- payload: {
214
- experimentKey: "founder-diagnostic",
215
- waitlistKey: "general",
216
- role: "weird-value",
217
- utm_source: "twitter",
218
- },
219
- createdAt: new Date("2026-08-13T00:00:00.000Z"),
220
- },
221
- {
222
- id: LEAD_5,
223
- siteId: WS_B,
224
- email: "five@example.com",
225
- source: "flow:lead-capture-general",
226
- payload: { experimentKey: "lead-capture-exp", waitlistKey: "general", role: "solo" },
227
- createdAt: new Date("2026-08-14T00:00:00.000Z"),
228
- },
229
- ];
230
-
231
- const SCORES = [
232
- {
233
- leadId: LEAD_1,
234
- siteId: WS_A,
235
- experimentKey: "founder-diagnostic",
236
- tier: "hot",
237
- score: 5,
238
- categoryScores: { total: 5 },
239
- ruleTrace: { fired: ["role_pt"] },
240
- updatedAt: new Date("2026-08-10T01:00:00.000Z"),
241
- },
242
- {
243
- leadId: LEAD_2,
244
- siteId: WS_A,
245
- experimentKey: "founder-diagnostic",
246
- tier: "cold",
247
- score: 0,
248
- categoryScores: { total: 0 },
249
- ruleTrace: { fired: [] },
250
- updatedAt: new Date("2026-08-11T01:00:00.000Z"),
251
- },
252
- {
253
- leadId: LEAD_4,
254
- siteId: WS_A,
255
- experimentKey: "founder-diagnostic",
256
- tier: "cold",
257
- score: 0,
258
- categoryScores: { total: 0 },
259
- ruleTrace: { fired: [] },
260
- updatedAt: new Date("2026-08-13T01:00:00.000Z"),
261
- },
262
- // LEAD_3 is intentionally unscored — A2 has not run for it yet.
263
- ];
264
-
265
- // ── A generic, where-clause-honoring in-memory matcher ───────────────────────
266
-
267
- type Row = Record<string, unknown>;
268
- type Where = Record<string, unknown>;
269
-
270
- function valuesEqual(a: unknown, b: unknown): boolean {
271
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
272
- return a === b;
273
- }
274
- function lessThan(a: unknown, b: unknown): boolean {
275
- if (a instanceof Date && b instanceof Date) return a.getTime() < b.getTime();
276
- return (a as never) < (b as never);
277
- }
278
-
279
- function matchesWhere(row: Row, where: Where): boolean {
280
- for (const [key, cond] of Object.entries(where)) {
281
- if (key === "OR") {
282
- if (!(cond as Where[]).some((w) => matchesWhere(row, w))) return false;
283
- continue;
284
- }
285
- if (key === "AND") {
286
- if (!(cond as Where[]).every((w) => matchesWhere(row, w))) return false;
287
- continue;
288
- }
289
- if (key === "payload") {
290
- const { path, equals } = cond as { path: string[]; equals: unknown };
291
- const payload = row.payload as Record<string, unknown> | undefined;
292
- const value = payload ? payload[path[0] as string] : undefined;
293
- if (!valuesEqual(value, equals)) return false;
294
- continue;
295
- }
296
- const rowValue = row[key];
297
- if (cond !== null && typeof cond === "object" && !Array.isArray(cond) && !(cond instanceof Date)) {
298
- const condObj = cond as Record<string, unknown>;
299
- if ("in" in condObj) {
300
- if (!(condObj.in as unknown[]).some((v) => valuesEqual(v, rowValue))) return false;
301
- continue;
302
- }
303
- if ("lt" in condObj) {
304
- if (!lessThan(rowValue, condObj.lt)) return false;
305
- continue;
306
- }
307
- }
308
- if (!valuesEqual(rowValue, cond)) return false;
309
- }
310
- return true;
311
- }
312
-
313
- function sortRows(rows: Row[], orderBy: unknown): Row[] {
314
- if (!orderBy) return rows;
315
- const orders = Array.isArray(orderBy) ? orderBy : [orderBy];
316
- return [...rows].sort((a, b) => {
317
- for (const ord of orders as Array<Record<string, "asc" | "desc">>) {
318
- const [field, dir] = Object.entries(ord)[0] as [string, "asc" | "desc"];
319
- const av = a[field];
320
- const bv = b[field];
321
- let cmp: number;
322
- if (av instanceof Date && bv instanceof Date) cmp = av.getTime() - bv.getTime();
323
- else if ((av as never) < (bv as never)) cmp = -1;
324
- else if ((av as never) > (bv as never)) cmp = 1;
325
- else cmp = 0;
326
- if (dir === "desc") cmp = -cmp;
327
- if (cmp !== 0) return cmp;
328
- }
329
- return 0;
330
- });
331
- }
332
-
333
- function installFixtureMocks(): void {
334
- (prisma.waitlistExperiment.findMany as jest.Mock).mockImplementation(
335
- ({ where }: { where?: Where }) =>
336
- Promise.resolve(sortRows(EXPERIMENTS.filter((e) => matchesWhere(e, where ?? {})), { createdAt: "desc" })),
337
- );
338
- (prisma.waitlistExperiment.findFirst as jest.Mock).mockImplementation(
339
- ({ where }: { where?: Where }) =>
340
- Promise.resolve(EXPERIMENTS.find((e) => matchesWhere(e, where ?? {})) ?? null),
341
- );
342
- (prisma.lead.findMany as jest.Mock).mockImplementation(
343
- ({ where, orderBy, take }: { where?: Where; orderBy?: unknown; take?: number }) => {
344
- let rows = LEADS.filter((l) => matchesWhere(l, where ?? {}));
345
- rows = sortRows(rows, orderBy);
346
- if (typeof take === "number") rows = rows.slice(0, take);
347
- return Promise.resolve(rows);
348
- },
349
- );
350
- (prisma.lead.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
351
- Promise.resolve(LEADS.find((l) => matchesWhere(l, where ?? {})) ?? null),
352
- );
353
- (prisma.waitlistScore.findMany as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
354
- Promise.resolve(SCORES.filter((s) => matchesWhere(s, where ?? {}))),
355
- );
356
- (prisma.waitlistScore.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
357
- Promise.resolve(SCORES.find((s) => matchesWhere(s, where ?? {})) ?? null),
358
- );
359
- (prisma.waitlistScore.findUnique as jest.Mock).mockImplementation(
360
- ({ where }: { where: { leadId: string } }) =>
361
- Promise.resolve(SCORES.find((s) => s.leadId === where.leadId) ?? null),
362
- );
363
- }
364
-
365
- function tenant(overrides: Partial<TenantContext>): TenantContext {
366
- return {
367
- userId: "user_00000000-0000-7000-8000-000000000001" as TenantContext["userId"],
368
- organizationId: ORG_SENTINEL as TenantContext["organizationId"],
369
- workspaceId: WS_A as TenantContext["workspaceId"],
370
- role: "owner",
371
- ...overrides,
372
- };
373
- }
374
-
375
- beforeEach(() => {
376
- jest.clearAllMocks();
377
- installFixtureMocks();
378
- });
379
-
380
- // ── AC5 — the platform-operator predicate, fail-closed on AILK_OPERATOR_EMAIL ──
381
- //
382
- // Sam-disposed 2026-09-02 (PR #4687 security review, High finding): the
383
- // sentinel-owner predicate alone is no longer sufficient — `kind:"all"`
384
- // requires an explicit deployment pin. `bindOperatorMembership`
385
- // (`tenant-context.ts`) TOFU-binds this Membership to whichever caller wins
386
- // a first-login race when the pin is unset; this surface turns that
387
- // membership into cross-tenant signup-PII read access, so a deployment must
388
- // opt in explicitly.
389
-
390
- const ORIGINAL_ENV = process.env;
391
-
392
- describe("isPlatformOperator / resolveWaitlistReadScope", () => {
393
- beforeEach(() => {
394
- process.env = { ...ORIGINAL_ENV };
395
- delete process.env.AILK_OPERATOR_EMAIL;
396
- });
397
- afterAll(() => {
398
- process.env = ORIGINAL_ENV;
399
- });
400
-
401
- it("AILK_OPERATOR_EMAIL set + sentinel-org owner -> true, scope 'all'", () => {
402
- process.env.AILK_OPERATOR_EMAIL = "op@example.com";
403
- const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
404
- expect(isPlatformOperator(t)).toBe(true);
405
- expect(resolveWaitlistReadScope(t)).toEqual({ kind: "all" });
406
- });
407
-
408
- it("AILK_OPERATOR_EMAIL UNSET + sentinel-org owner -> false, scope 'site' (fail closed)", () => {
409
- // AILK_OPERATOR_EMAIL deliberately left unset by beforeEach.
410
- const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
411
- expect(isPlatformOperator(t)).toBe(false);
412
- expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_A });
413
- });
414
-
415
- it("a blank/whitespace-only AILK_OPERATOR_EMAIL is treated as unset (mirrors tenant-context.ts's own trim discipline)", () => {
416
- process.env.AILK_OPERATOR_EMAIL = " ";
417
- const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "owner" });
418
- expect(isPlatformOperator(t)).toBe(false);
419
- });
420
-
421
- it("AILK_OPERATOR_EMAIL set + sentinel-org admin -> false, scope 'site'", () => {
422
- process.env.AILK_OPERATOR_EMAIL = "op@example.com";
423
- const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "admin" });
424
- expect(isPlatformOperator(t)).toBe(false);
425
- expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_A });
426
- });
427
-
428
- it("AILK_OPERATOR_EMAIL set + non-sentinel owner -> false, scope 'site'", () => {
429
- process.env.AILK_OPERATOR_EMAIL = "op@example.com";
430
- const t = tenant({ organizationId: ORG_B as TenantContext["organizationId"], workspaceId: WS_B as TenantContext["workspaceId"], role: "owner" });
431
- expect(isPlatformOperator(t)).toBe(false);
432
- expect(resolveWaitlistReadScope(t)).toEqual({ kind: "site", siteId: WS_B });
433
- });
434
-
435
- it("false for a member regardless of pin or org (never a platform operator)", () => {
436
- process.env.AILK_OPERATOR_EMAIL = "op@example.com";
437
- const t = tenant({ organizationId: ORG_SENTINEL as TenantContext["organizationId"], role: "member" });
438
- expect(isPlatformOperator(t)).toBe(false);
439
- });
440
-
441
- it("the role floor allow-list is exactly owner|admin", () => {
442
- expect(WAITLIST_DASHBOARD_ROLES.has("owner")).toBe(true);
443
- expect(WAITLIST_DASHBOARD_ROLES.has("admin")).toBe(true);
444
- expect(WAITLIST_DASHBOARD_ROLES.has("member")).toBe(false);
445
- });
446
- });
447
-
448
- // ── listExperiments — scope + rollup ─────────────────────────────────────────
449
-
450
- describe("listExperiments", () => {
451
- it("site scope sees only its own site's experiment", async () => {
452
- const result = await listExperiments({ kind: "site", siteId: WS_A });
453
- expect(result.scope).toBe("site");
454
- expect(result.experiments.map((e) => e.id)).toEqual([EXP_A]);
455
- expect(result.rollup.sites).toBe(1);
456
- expect(result.rollup.experiments).toEqual({ total: 1, live: 1, closed: 0, draft: 0 });
457
- expect(result.rollup.raw).toBe(4);
458
- expect(result.rollup.qualified).toBe(1);
459
- expect(result.rollup.qualificationRate).toBeCloseTo(0.25);
460
- });
461
-
462
- it("the other site's scope sees only its own experiment, unscored (null rollup)", async () => {
463
- const result = await listExperiments({ kind: "site", siteId: WS_B });
464
- expect(result.experiments.map((e) => e.id)).toEqual([EXP_B]);
465
- expect(result.rollup.experiments).toEqual({ total: 1, live: 0, closed: 0, draft: 1 });
466
- expect(result.rollup.raw).toBe(1);
467
- expect(result.rollup.qualified).toBeNull();
468
- expect(result.rollup.qualificationRate).toBeNull();
469
- });
470
-
471
- it("platform-operator scope 'all' sees both sites' experiments; rollup.sites = 2", async () => {
472
- const result = await listExperiments({ kind: "all" });
473
- expect(result.scope).toBe("all");
474
- expect(result.experiments.map((e) => e.id).sort()).toEqual([EXP_A, EXP_B].sort());
475
- expect(result.rollup.sites).toBe(2);
476
- expect(result.rollup.experiments).toEqual({ total: 2, live: 1, closed: 0, draft: 1 });
477
- // Only EXP_A's qualified count is known — EXP_B is unscored (mode
478
- // lead-capture) and contributes null, not zero.
479
- expect(result.rollup.raw).toBe(5);
480
- expect(result.rollup.qualified).toBe(1);
481
- expect(result.rollup.qualificationRate).toBeCloseTo(0.2);
482
- });
483
-
484
- it("returns an empty, zeroed rollup when scope admits no experiments", async () => {
485
- const result = await listExperiments({ kind: "site", siteId: "wksp_00000000-0000-7000-8000-0000000000zz" });
486
- expect(result.experiments).toEqual([]);
487
- expect(result.rollup).toEqual({
488
- experiments: { total: 0, live: 0, closed: 0, draft: 0 },
489
- raw: 0,
490
- qualified: null,
491
- qualificationRate: null,
492
- sites: 0,
493
- });
494
- });
495
-
496
- // Medium finding, PR #4687 security review: a corrupted config on ONE
497
- // experiment must not abort the platform operator's whole `all`-scope
498
- // list (a cross-tenant availability coupling) — it is skipped, and every
499
- // rollup count (total/status/sites/raw/qualified) excludes it.
500
- it("skips an experiment with a corrupted config rather than throwing, under kind:'all'", async () => {
501
- const corrupt = {
502
- id: "wlexp_00000000-0000-7000-8000-0000000000cc",
503
- siteId: WS_B,
504
- key: "corrupt-exp",
505
- mode: "qualifying",
506
- status: "live",
507
- config: { not: "a valid WaitlistExperimentConfig" },
508
- createdAt: new Date("2026-08-03T00:00:00.000Z"),
509
- updatedAt: new Date("2026-08-03T00:00:00.000Z"),
510
- };
511
- EXPERIMENTS.push(corrupt as never);
512
- try {
513
- const result = await listExperiments({ kind: "all" });
514
- expect(result.experiments.map((e) => e.id).sort()).toEqual([EXP_A, EXP_B].sort());
515
- // The corrupt row contributes to NEITHER count — same rollup as
516
- // without it (the "both sites" test above).
517
- expect(result.rollup.experiments).toEqual({ total: 2, live: 1, closed: 0, draft: 1 });
518
- expect(result.rollup.sites).toBe(2);
519
- } finally {
520
- EXPERIMENTS.pop();
521
- }
522
- });
523
-
524
- it("bounds its Lead/WaitlistScore aggregation reads with a `take` cap (Medium finding, PR #4687)", async () => {
525
- await listExperiments({ kind: "all" });
526
- const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
527
- const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
528
- expect(typeof leadCall.take).toBe("number");
529
- expect(leadCall.take).toBeGreaterThan(0);
530
- expect(typeof scoreCall.take).toBe("number");
531
- expect(scoreCall.take).toBeGreaterThan(0);
532
- });
533
-
534
- // #5107 — without an explicit orderBy, Postgres does not guarantee which
535
- // rows survive the `take: MAX_AGGREGATION_ROWS` cap above. Mirrors the
536
- // sibling flowSession/flowCheckout reads in this same function (below),
537
- // which already carry their own explicit orderBy alongside their take cap.
538
- it("orders its Lead/WaitlistScore aggregation reads deterministically (#5107)", async () => {
539
- await listExperiments({ kind: "all" });
540
- const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
541
- const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
542
- expect(leadCall.orderBy).toEqual({ createdAt: "asc" });
543
- expect(scoreCall.orderBy).toEqual({ createdAt: "asc" });
544
- });
545
- });
546
-
547
- // ── getExperimentComparison — D4 arithmetic ──────────────────────────────────
548
-
549
- describe("getExperimentComparison", () => {
550
- it("computes per-waitlist and total metrics per D4, including (direct)/(other) and unscored handling", async () => {
551
- const result = await getExperimentComparison({ kind: "all" }, EXP_A);
552
- expect(result.ok).toBe(true);
553
- if (!result.ok) return;
554
-
555
- expect(result.value.experiment.id).toBe(EXP_A);
556
- expect(result.value.serviceableExcludes).toEqual(["cold"]);
557
-
558
- const earlyBird = result.value.waitlists.find((w) => w.key === "early_bird");
559
- expect(earlyBird).toMatchObject({ raw: 2, qualified: 1, qualificationRate: 0.5, campaign: "eb-campaign" });
560
- expect(earlyBird?.byTier.sort((a, b) => a.tier.localeCompare(b.tier))).toEqual([
561
- { tier: "cold", count: 1 },
562
- { tier: "hot", count: 1 },
563
- ]);
564
- const ebSources = new Map(earlyBird?.bySource.map((s) => [s.source, s]));
565
- expect(ebSources.get("(direct)")).toEqual({ source: "(direct)", raw: 1, qualified: 0, qualificationRate: 0 });
566
- expect(ebSources.get("twitter")).toEqual({ source: "twitter", raw: 1, qualified: 1, qualificationRate: 1 });
567
-
568
- const general = result.value.waitlists.find((w) => w.key === "general");
569
- // LEAD_3 is unscored -> byTier has no entry for it; qualified counts only
570
- // scored, non-excluded leads (zero here — the only score is "cold").
571
- expect(general).toMatchObject({ raw: 2, qualified: 0, qualificationRate: 0 });
572
-
573
- const roleDist = general?.distributions.find((d) => d.questionId === "role");
574
- const roleOptions = new Map(roleDist?.options.map((o) => [o.value, o]));
575
- expect(roleDist?.answered).toBe(2);
576
- expect(roleOptions.get("founder")).toEqual({ value: "founder", count: 1, share: 0.5 });
577
- // LEAD_4's "weird-value" is not a declared option -> the "(other)" bucket.
578
- expect(roleOptions.get("(other)")).toEqual({ value: "(other)", count: 1, share: 0.5 });
579
-
580
- // Total row aggregates over ALL of the experiment's waitlists (D4).
581
- expect(result.value.total).toMatchObject({ key: "total", raw: 4, qualified: 1, qualificationRate: 0.25 });
582
- const totalRoleDist = result.value.total.distributions.find((d) => d.questionId === "role");
583
- expect(totalRoleDist?.answered).toBe(4);
584
- const totalRoleOptions = new Map(totalRoleDist?.options.map((o) => [o.value, o]));
585
- expect(totalRoleOptions.get("founder")?.count).toBe(2);
586
- expect(totalRoleOptions.get("other")?.count).toBe(1);
587
- expect(totalRoleOptions.get("(other)")?.count).toBe(1);
588
- });
589
-
590
- it("qualified: null and serviceableExcludes: [] for a lead-capture (no scoring) experiment", async () => {
591
- const result = await getExperimentComparison({ kind: "all" }, EXP_B);
592
- expect(result.ok).toBe(true);
593
- if (!result.ok) return;
594
- expect(result.value.serviceableExcludes).toEqual([]);
595
- expect(result.value.total.qualified).toBeNull();
596
- expect(result.value.total.qualificationRate).toBeNull();
597
- });
598
-
599
- it("a foreign experiment id under a site scope is not_found", async () => {
600
- const result = await getExperimentComparison({ kind: "site", siteId: WS_B }, EXP_A);
601
- expect(result.ok).toBe(false);
602
- if (result.ok) return;
603
- expect(result.error.kind).toBe("not_found");
604
- });
605
-
606
- it("a missing experiment id under scope 'all' is not_found", async () => {
607
- const result = await getExperimentComparison({ kind: "all" }, "wlexp_00000000-0000-7000-8000-00000000dead");
608
- expect(result.ok).toBe(false);
609
- });
610
-
611
- it("bounds its Lead/WaitlistScore aggregation reads with a `take` cap (Medium finding, PR #4687)", async () => {
612
- await getExperimentComparison({ kind: "all" }, EXP_A);
613
- const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
614
- const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
615
- expect(typeof leadCall.take).toBe("number");
616
- expect(leadCall.take).toBeGreaterThan(0);
617
- expect(typeof scoreCall.take).toBe("number");
618
- expect(scoreCall.take).toBeGreaterThan(0);
619
- });
620
-
621
- // #5107 — same fix as listExperiments's pair above: an explicit orderBy
622
- // makes which rows survive the take cap deterministic.
623
- it("orders its Lead/WaitlistScore aggregation reads deterministically (#5107)", async () => {
624
- await getExperimentComparison({ kind: "all" }, EXP_A);
625
- const leadCall = (prisma.lead.findMany as jest.Mock).mock.calls[0]?.[0];
626
- const scoreCall = (prisma.waitlistScore.findMany as jest.Mock).mock.calls[0]?.[0];
627
- expect(leadCall.orderBy).toEqual({ createdAt: "asc" });
628
- expect(scoreCall.orderBy).toEqual({ createdAt: "asc" });
629
- });
630
- });
631
-
632
- // ── getSignup — drill-down ────────────────────────────────────────────────────
633
-
634
- describe("getSignup", () => {
635
- it("returns the scored signup's answers, tier, score, categoryScores, ruleTrace", async () => {
636
- const result = await getSignup({ kind: "all" }, EXP_A, LEAD_1);
637
- expect(result.ok).toBe(true);
638
- if (!result.ok) return;
639
- expect(result.value.waitlistKey).toBe("early_bird");
640
- expect(result.value.answers.find((a) => a.questionId === "role")?.value).toBe("founder");
641
- expect(result.value.score).toEqual({
642
- tier: "hot",
643
- score: 5,
644
- categoryScores: { total: 5 },
645
- ruleTrace: { fired: ["role_pt"] },
646
- updatedAt: "2026-08-10T01:00:00.000Z",
647
- });
648
- });
649
-
650
- it("returns score: null for an unscored signup", async () => {
651
- const result = await getSignup({ kind: "all" }, EXP_A, LEAD_3);
652
- expect(result.ok).toBe(true);
653
- if (!result.ok) return;
654
- expect(result.value.score).toBeNull();
655
- });
656
-
657
- it("a foreign lead under a site scope is not_found — no enumeration signal", async () => {
658
- const result = await getSignup({ kind: "site", siteId: WS_B }, EXP_B, LEAD_1);
659
- expect(result.ok).toBe(false);
660
- if (result.ok) return;
661
- expect(result.error.kind).toBe("not_found");
662
- });
663
-
664
- it("a lead belonging to a DIFFERENT experiment on the same site is not_found (loaded through the experiment)", async () => {
665
- // LEAD_1 exists on site A but under "founder-diagnostic", not under a
666
- // second (hypothetical) experiment id on that same site.
667
- const result = await getSignup({ kind: "all" }, EXP_B, LEAD_1);
668
- expect(result.ok).toBe(false);
669
- });
670
-
671
- it("scopes the WaitlistScore read by siteId + experimentKey in the WHERE clause, not a post-filter (Low finding, PR #4687)", async () => {
672
- await getSignup({ kind: "all" }, EXP_A, LEAD_1);
673
- const call = (prisma.waitlistScore.findFirst as jest.Mock).mock.calls[0]?.[0];
674
- expect(call.where).toMatchObject({ leadId: LEAD_1, siteId: WS_A, experimentKey: "founder-diagnostic" });
675
- });
676
-
677
- // A lead missing `waitlistKey` is corrupt/anomalous data (server-owned,
678
- // written alongside `experimentKey` at completion) — treated as not_found
679
- // rather than emitting an out-of-contract empty string (Low finding, PR
680
- // #4687 security review).
681
- it("treats a signup missing waitlistKey as not_found rather than emitting waitlistKey: ''", async () => {
682
- const corruptLead = {
683
- id: "lead_00000000-0000-7000-8000-0000000000c1",
684
- siteId: WS_A,
685
- email: "corrupt@example.com",
686
- source: "flow:founder-diagnostic-early",
687
- payload: { experimentKey: "founder-diagnostic", role: "founder" }, // no waitlistKey
688
- createdAt: new Date("2026-08-15T00:00:00.000Z"),
689
- };
690
- LEADS.push(corruptLead as never);
691
- try {
692
- const result = await getSignup({ kind: "all" }, EXP_A, corruptLead.id);
693
- expect(result.ok).toBe(false);
694
- if (result.ok) return;
695
- expect(result.error.kind).toBe("not_found");
696
- } finally {
697
- LEADS.pop();
698
- }
699
- });
700
- });
701
-
702
- // ── listSignups — pagination ──────────────────────────────────────────────────
703
-
704
- describe("listSignups", () => {
705
- it("returns the first page newest-first with a nextCursor when more remain", async () => {
706
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 2 });
707
- expect(result.ok).toBe(true);
708
- if (!result.ok) return;
709
- // createdAt desc: LEAD_4 (13th), LEAD_3 (12th) are the newest two.
710
- expect(result.value.items.map((i) => i.leadId)).toEqual([LEAD_4, LEAD_3]);
711
- expect(result.value.nextCursor).toBe(LEAD_3);
712
- });
713
-
714
- it("the second page (via cursor) returns the remaining rows with nextCursor: null", async () => {
715
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 2, cursor: LEAD_3 });
716
- expect(result.ok).toBe(true);
717
- if (!result.ok) return;
718
- expect(result.value.items.map((i) => i.leadId)).toEqual([LEAD_2, LEAD_1]);
719
- expect(result.value.nextCursor).toBeNull();
720
- });
721
-
722
- it("filters by ?waitlist=", async () => {
723
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50, waitlist: "general" });
724
- expect(result.ok).toBe(true);
725
- if (!result.ok) return;
726
- expect(result.value.items.map((i) => i.leadId).sort()).toEqual([LEAD_3, LEAD_4].sort());
727
- });
728
-
729
- it("carries tier/score from the joined WaitlistScore row, null when absent", async () => {
730
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50 });
731
- expect(result.ok).toBe(true);
732
- if (!result.ok) return;
733
- const byId = new Map(result.value.items.map((i) => [i.leadId, i]));
734
- expect(byId.get(LEAD_1)).toMatchObject({ tier: "hot", score: 5 });
735
- expect(byId.get(LEAD_3)).toMatchObject({ tier: null, score: null });
736
- });
737
-
738
- it("a foreign experiment id under a site scope is not_found", async () => {
739
- const result = await listSignups({ kind: "site", siteId: WS_B }, EXP_A, { limit: 50 });
740
- expect(result.ok).toBe(false);
741
- });
742
-
743
- // Low finding, PR #4687 security review: a supplied-but-unresolvable
744
- // cursor must 400 (invalid_cursor), not silently degrade to page 1.
745
- it("a cursor that does not exist at all is invalid_cursor", async () => {
746
- const result = await listSignups({ kind: "all" }, EXP_A, {
747
- limit: 50,
748
- cursor: "lead_00000000-0000-7000-8000-00000000dead",
749
- });
750
- expect(result.ok).toBe(false);
751
- if (result.ok) return;
752
- expect(result.error.kind).toBe("invalid_cursor");
753
- });
754
-
755
- it("a cursor belonging to a DIFFERENT experiment on the same site is invalid_cursor", async () => {
756
- // LEAD_5 exists on site B under "lead-capture-exp" — not under EXP_A's
757
- // "founder-diagnostic", even though both experiments' scope is 'all'.
758
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50, cursor: LEAD_5 });
759
- expect(result.ok).toBe(false);
760
- if (result.ok) return;
761
- expect(result.error.kind).toBe("invalid_cursor");
762
- });
763
-
764
- it("skips a signup missing waitlistKey rather than emitting waitlistKey: '' (Low finding, PR #4687)", async () => {
765
- const corruptLead = {
766
- id: "lead_00000000-0000-7000-8000-0000000000c2",
767
- siteId: WS_A,
768
- email: "corrupt2@example.com",
769
- source: "flow:founder-diagnostic-early",
770
- payload: { experimentKey: "founder-diagnostic", role: "founder" }, // no waitlistKey
771
- createdAt: new Date("2026-08-16T00:00:00.000Z"),
772
- };
773
- LEADS.push(corruptLead as never);
774
- try {
775
- const result = await listSignups({ kind: "all" }, EXP_A, { limit: 50 });
776
- expect(result.ok).toBe(true);
777
- if (!result.ok) return;
778
- expect(result.value.items.some((i) => i.leadId === corruptLead.id)).toBe(false);
779
- expect(result.value.items.every((i) => i.waitlistKey !== "")).toBe(true);
780
- } finally {
781
- LEADS.pop();
782
- }
783
- });
784
- });
785
-
786
- // ── Read-only — no write verb is ever reachable (spec §5.2 AC4) ─────────────
787
-
788
- describe("read-only discipline", () => {
789
- it("the prisma mock declares no create/update/upsert/delete verb — an accidental call throws, not silently succeeds", () => {
790
- for (const model of ["waitlistExperiment", "lead", "waitlistScore"] as const) {
791
- for (const verb of ["create", "update", "upsert", "delete", "deleteMany", "updateMany", "createMany"] as const) {
792
- expect((prisma[model] as Record<string, unknown>)[verb]).toBeUndefined();
793
- }
794
- }
795
- });
796
- });
797
-
798
- // ── #4839 — per-offer funnel counts for a recommending experiment ───────────
799
-
800
- const EXP_R = "wlexp_00000000-0000-7000-8000-0000000000dd"; // recommending, site A
801
-
802
- const CONFIG_R = {
803
- key: "ailk-reco",
804
- mode: "recommending" as const,
805
- status: "live" as const,
806
- waitlists: [{ key: "main", flowSlug: "ailk-reco" }],
807
- questionSet: {
808
- version: 1 as const,
809
- questions: [
810
- {
811
- id: "q_team",
812
- prompt: en("Team?"),
813
- answerType: "single" as const,
814
- options: [
815
- { value: "solo", label: en("Solo") },
816
- { value: "small_team", label: en("Small team") },
817
- ],
818
- },
819
- ],
820
- },
821
- tracking: {
822
- version: 1 as const,
823
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
824
- waitlists: { main: { campaign: "reco-campaign", channels: ["organic"] } },
825
- },
826
- stripeConfigRef: "stripe-main",
827
- offers: [
828
- {
829
- key: "pro",
830
- copy: { name: en("Pro") },
831
- price: { kind: "exact" as const, amount: 249, period: "annual" as const },
832
- rules: [{ when: { answer: "q_team", eq: "small_team" }, reason: en("Teams need seats.") }],
833
- },
834
- {
835
- key: "starter",
836
- copy: { name: en("Starter") },
837
- price: { kind: "label" as const, label: en("Free") },
838
- rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders start free.") }],
839
- },
840
- ],
841
- };
842
-
843
- const EXPERIMENT_R = {
844
- id: EXP_R,
845
- siteId: WS_A,
846
- key: CONFIG_R.key,
847
- mode: CONFIG_R.mode,
848
- status: CONFIG_R.status,
849
- config: CONFIG_R,
850
- createdAt: new Date("2026-09-01T00:00:00.000Z"),
851
- updatedAt: new Date("2026-09-01T00:00:00.000Z"),
852
- };
853
-
854
- const SESSIONS_R = [
855
- { siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T00:00:00.000Z"), state: { team: { q_team: "small_team" } } },
856
- { siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T01:00:00.000Z"), state: { team: { q_team: "small_team" } } },
857
- { siteId: WS_A, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T02:00:00.000Z"), state: { team: { q_team: "solo" } } },
858
- // An incomplete walk and a foreign-site session — never counted.
859
- { siteId: WS_A, flowSlug: "ailk-reco", completedAt: null, state: { team: { q_team: "small_team" } } },
860
- { siteId: WS_B, flowSlug: "ailk-reco", completedAt: new Date("2026-09-02T03:00:00.000Z"), state: { team: { q_team: "small_team" } } },
861
- ];
862
-
863
- const LEAD_R = {
864
- id: "lead_00000000-0000-7000-8000-0000000000dd",
865
- siteId: WS_A,
866
- email: "buyer@example.com",
867
- source: "flow:ailk-reco",
868
- payload: {
869
- experimentKey: "ailk-reco",
870
- waitlistKey: "main",
871
- q_team: "small_team",
872
- recommender: { choice: "save", fits: ["pro"] },
873
- },
874
- createdAt: new Date("2026-09-02T00:30:00.000Z"),
875
- };
876
-
877
- const CHECKOUTS_R = [
878
- { siteId: WS_A, experimentKey: "ailk-reco", offerKey: "pro", status: "completed" },
879
- { siteId: WS_A, experimentKey: "ailk-reco", offerKey: "pro", status: "completed" },
880
- { siteId: WS_A, experimentKey: "ailk-reco", offerKey: "starter", status: "created" },
881
- ];
882
-
883
- function installRecommendingFixtures(): void {
884
- EXPERIMENTS.push(EXPERIMENT_R as never);
885
- LEADS.push(LEAD_R as never);
886
- (prisma.flowSession.findMany as jest.Mock).mockImplementation(
887
- ({ where, take }: { where?: Where; take?: number }) => {
888
- let rows = SESSIONS_R.filter((r) => {
889
- const { completedAt, ...rest } = (where ?? {}) as Where & { completedAt?: { not: null } };
890
- if (completedAt && r.completedAt === null) return false;
891
- return matchesWhere(r as Row, rest);
892
- });
893
- if (typeof take === "number") rows = rows.slice(0, take);
894
- return Promise.resolve(rows);
895
- },
896
- );
897
- (prisma.flowCheckout.findMany as jest.Mock).mockImplementation(
898
- ({ where, take }: { where?: Where; take?: number }) => {
899
- let rows = CHECKOUTS_R.filter((r) => matchesWhere(r as Row, where ?? {}));
900
- if (typeof take === "number") rows = rows.slice(0, take);
901
- return Promise.resolve(rows);
902
- },
903
- );
904
- }
905
-
906
- function removeRecommendingFixtures(): void {
907
- EXPERIMENTS.pop();
908
- LEADS.pop();
909
- }
910
-
911
- describe("listExperiments — per-offer funnel counts (#4839)", () => {
912
- beforeEach(() => installRecommendingFixtures());
913
- afterEach(() => removeRecommendingFixtures());
914
-
915
- it("AC-1: the three counts per offer in register order; a created checkout is not counted; the response parses", async () => {
916
- const result = await listExperiments({ kind: "site", siteId: WS_A });
917
- const reco = result.experiments.find((e) => e.id === EXP_R);
918
- expect(reco?.counts.offers).toEqual([
919
- { offerKey: "pro", recommended: 2, saved: 1, checkedOut: 2 },
920
- { offerKey: "starter", recommended: 1, saved: 0, checkedOut: 0 },
921
- ]);
922
- expect(() => waitlistDashboardListSchema.parse(result)).not.toThrow();
923
- const qualifying = result.experiments.find((e) => e.id === EXP_A);
924
- expect(qualifying?.counts).not.toHaveProperty("offers");
925
- });
926
-
927
- it("AC-1: a lead-capture experiment in the same list carries no offers key", async () => {
928
- const result = await listExperiments({ kind: "all" });
929
- const leadCapture = result.experiments.find((e) => e.id === EXP_B);
930
- expect(leadCapture?.counts).not.toHaveProperty("offers");
931
- expect(result.experiments.find((e) => e.id === EXP_R)?.counts.offers).toHaveLength(2);
932
- });
933
-
934
- it("AC-2: exactly one FlowSession read + one FlowCheckout read, scoped under site, capped", async () => {
935
- await listExperiments({ kind: "site", siteId: WS_A });
936
- expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
937
- expect(prisma.flowCheckout.findMany).toHaveBeenCalledTimes(1);
938
- const sessionCall = (prisma.flowSession.findMany as jest.Mock).mock.calls[0][0];
939
- const checkoutCall = (prisma.flowCheckout.findMany as jest.Mock).mock.calls[0][0];
940
- expect(sessionCall.where.siteId).toBe(WS_A);
941
- expect(checkoutCall.where.siteId).toBe(WS_A);
942
- expect(sessionCall.take).toBe(MAX_AGGREGATION_ROWS);
943
- expect(checkoutCall.take).toBe(MAX_AGGREGATION_ROWS);
944
- expect(checkoutCall.orderBy).toEqual({ createdAt: "asc" });
945
- expect(checkoutCall.where.status).toBe("completed");
946
- });
947
-
948
- it("AC-2: the same two reads once under kind:'all', without siteId, even with two recommending experiments", async () => {
949
- const second = { ...EXPERIMENT_R, id: "wlexp_00000000-0000-7000-8000-0000000000ee", siteId: WS_B };
950
- EXPERIMENTS.push(second as never);
951
- try {
952
- await listExperiments({ kind: "all" });
953
- expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
954
- expect(prisma.flowCheckout.findMany).toHaveBeenCalledTimes(1);
955
- const sessionCall = (prisma.flowSession.findMany as jest.Mock).mock.calls[0][0];
956
- const checkoutCall = (prisma.flowCheckout.findMany as jest.Mock).mock.calls[0][0];
957
- expect(sessionCall.where).not.toHaveProperty("siteId");
958
- expect(checkoutCall.where).not.toHaveProperty("siteId");
959
- } finally {
960
- EXPERIMENTS.pop();
961
- }
962
- });
963
-
964
- it("AC-2: neither read runs when no recommending experiment is in scope", async () => {
965
- await listExperiments({ kind: "site", siteId: WS_B });
966
- expect(prisma.flowSession.findMany).not.toHaveBeenCalled();
967
- expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
968
- });
969
-
970
- it("AC-4: the rollup and the existing per-experiment counts are unchanged by the funnel", async () => {
971
- const result = await listExperiments({ kind: "site", siteId: WS_A });
972
- // The pre-#4839 fixture's rollup for site A was { raw: 4, qualified: 1,
973
- // rate: 0.25 } over EXP_A alone; the recommending experiment adds its one
974
- // saved lead to raw (it IS a signup) and nothing else.
975
- expect(result.rollup.experiments).toEqual({ total: 2, live: 2, closed: 0, draft: 0 });
976
- expect(result.rollup.raw).toBe(5);
977
- expect(result.rollup.qualified).toBe(1);
978
- expect(result.rollup.qualificationRate).toBeCloseTo(0.2);
979
- const qualifying = result.experiments.find((e) => e.id === EXP_A);
980
- expect(qualifying?.counts).toEqual({ raw: 4, qualified: 1, qualificationRate: 0.25 });
981
- });
982
- });
983
-
984
- // ── #4861 — the read-time recommended count costs the DISTINCT answer sets ───
985
- //
986
- // `computeOfferCounts` recomputes the pure `recommend()` for every completed
987
- // session in scope, up to `MAX_AGGREGATION_ROWS` rows × the register's offers ×
988
- // their rules, synchronously, on every list read (spec §1's measurement: 232 ms
989
- // at 4 offers / 459 ms at 8 / 308 ms with scoring, on a 20k-row fixture). Spec
990
- // D1 memoizes it per call on the session's answers PROJECTED onto the
991
- // register's declared question ids — sound because
992
- // `waitlistExperimentConfigSchema` rejects any predicate leaf or scoring point
993
- // naming an undeclared question (`packages/validation/src/schemas/waitlist.ts:449–452`,
994
- // `:759–765`), so `recommend()` cannot read a key outside that projection.
995
-
996
- const EXP_C = "wlexp_00000000-0000-7000-8000-0000000000cc"; // recommending, site A, cost fixture
997
-
998
- const CONFIG_C = {
999
- key: "reco-cost",
1000
- mode: "recommending" as const,
1001
- status: "live" as const,
1002
- waitlists: [{ key: "main", flowSlug: "reco-cost" }],
1003
- questionSet: {
1004
- version: 1 as const,
1005
- questions: [
1006
- // A declared FREE-TEXT question, FIRST in declared order: its answer is
1007
- // raw visitor input under no charset constraint, so a memo key that
1008
- // joined `id=value` pairs with a separator could be forged from this box
1009
- // to impersonate a later question's answer (the third test below).
1010
- { id: "q_company", prompt: en("Company?"), answerType: "text" as const, minLength: 1 },
1011
- {
1012
- id: "q_team",
1013
- prompt: en("Team?"),
1014
- answerType: "single" as const,
1015
- options: [
1016
- { value: "solo", label: en("Solo") },
1017
- { value: "small_team", label: en("Small team") },
1018
- { value: "agency", label: en("Agency") },
1019
- ],
1020
- },
1021
- {
1022
- id: "q_needs",
1023
- prompt: en("Needs?"),
1024
- answerType: "multi" as const,
1025
- options: [
1026
- { value: "seo", label: en("SEO") },
1027
- { value: "leads", label: en("Leads") },
1028
- { value: "content", label: en("Content") },
1029
- ],
1030
- },
1031
- {
1032
- id: "q_support",
1033
- prompt: en("Support?"),
1034
- answerType: "single" as const,
1035
- options: [
1036
- { value: "diy", label: en("DIY") },
1037
- { value: "guided", label: en("Guided") },
1038
- ],
1039
- },
1040
- ],
1041
- },
1042
- tracking: {
1043
- version: 1 as const,
1044
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
1045
- waitlists: { main: { campaign: "reco-cost-campaign", channels: ["organic"] } },
1046
- },
1047
- stripeConfigRef: "stripe-cost",
1048
- offers: [
1049
- {
1050
- key: "free_oss",
1051
- copy: { name: en("Free") },
1052
- price: { kind: "label" as const, label: en("Free") },
1053
- rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders start free.") }],
1054
- },
1055
- {
1056
- key: "pro",
1057
- copy: { name: en("Pro") },
1058
- price: { kind: "exact" as const, amount: 249, period: "annual" as const },
1059
- rules: [
1060
- { when: { answer: "q_team", in: ["small_team", "agency"] }, reason: en("Teams need seats.") },
1061
- ],
1062
- },
1063
- {
1064
- key: "pro_plus",
1065
- copy: { name: en("Pro Plus") },
1066
- price: { kind: "exact" as const, amount: 449, period: "monthly" as const },
1067
- rules: [
1068
- {
1069
- when: {
1070
- allOf: [
1071
- { answer: "q_team", eq: "agency" },
1072
- { answer: "q_support", eq: "guided" },
1073
- ],
1074
- },
1075
- reason: en("Agencies want a person to call."),
1076
- },
1077
- ],
1078
- },
1079
- {
1080
- key: "launch_service",
1081
- copy: { name: en("Launch service") },
1082
- price: { kind: "label" as const, label: en("From $5k") },
1083
- rules: [
1084
- {
1085
- when: {
1086
- allOf: [
1087
- { answer: "q_support", eq: "guided" },
1088
- { answer: "q_needs", includesAny: ["content"] },
1089
- ],
1090
- },
1091
- reason: en("Content plus hand-holding is a service engagement."),
1092
- },
1093
- ],
1094
- },
1095
- ],
1096
- };
1097
-
1098
- const EXPERIMENT_C = {
1099
- id: EXP_C,
1100
- siteId: WS_A,
1101
- key: CONFIG_C.key,
1102
- mode: CONFIG_C.mode,
1103
- status: CONFIG_C.status,
1104
- config: CONFIG_C,
1105
- createdAt: new Date("2026-09-03T00:00:00.000Z"),
1106
- updatedAt: new Date("2026-09-03T00:00:00.000Z"),
1107
- };
1108
-
1109
- const C_TEAMS = ["solo", "small_team", "agency"];
1110
- const C_NEEDS = [["seo"], ["leads"], ["seo", "content"], ["content"]];
1111
- const C_SUPPORT = ["diy", "guided"];
1112
-
1113
- /**
1114
- * `count` completed sessions cycling through the register's option space — 12
1115
- * distinct answer sets (lcm(3, 4, 2)) — each carrying a per-session-unique
1116
- * value under an UNDECLARED key (`email`, as a recommender walk's field step
1117
- * leaves in `state`). Keying the memo on the raw flattened map would make every
1118
- * row unique; the D1 projection drops the undeclared key.
1119
- */
1120
- function costSessions(count: number): Array<{
1121
- siteId: string;
1122
- flowSlug: string;
1123
- completedAt: Date;
1124
- state: Record<string, Record<string, string | string[]>>;
1125
- }> {
1126
- const rows = [];
1127
- for (let i = 0; i < count; i += 1) {
1128
- rows.push({
1129
- siteId: WS_A,
1130
- flowSlug: "reco-cost",
1131
- completedAt: new Date(1_760_000_000_000 + i),
1132
- state: {
1133
- segmentation: {
1134
- q_team: C_TEAMS[i % 3] as string,
1135
- q_needs: C_NEEDS[i % 4] as string[],
1136
- q_support: C_SUPPORT[i % 2] as string,
1137
- },
1138
- contact: { email: `visitor-${i}@example.com` },
1139
- },
1140
- });
1141
- }
1142
- return rows;
1143
- }
1144
-
1145
- /**
1146
- * The expected per-offer `recommended` counts, re-derived from the fixture's
1147
- * own answers by reading CONFIG_C's four fit rules directly — never by calling
1148
- * `recommend()`, so the assertion is independent of the code under test.
1149
- */
1150
- function expectedRecommended(
1151
- rows: ReturnType<typeof costSessions>,
1152
- ): Record<string, number> {
1153
- const expected: Record<string, number> = {
1154
- free_oss: 0,
1155
- pro: 0,
1156
- pro_plus: 0,
1157
- launch_service: 0,
1158
- };
1159
- for (const row of rows) {
1160
- const a = row.state.segmentation as { q_team: string; q_needs: string[]; q_support: string };
1161
- // CONFIG_C's four fit rules, read straight off the register in its own
1162
- // order, then capped the way the engine's contract caps them
1163
- // (`packages/validation/src/schemas/waitlist-recommend.ts:266` — at most
1164
- // the first three fits are returned).
1165
- const matched: string[] = [];
1166
- if (a.q_team === "solo") matched.push("free_oss");
1167
- if (a.q_team === "small_team" || a.q_team === "agency") matched.push("pro");
1168
- if (a.q_team === "agency" && a.q_support === "guided") matched.push("pro_plus");
1169
- if (a.q_support === "guided" && a.q_needs.includes("content")) matched.push("launch_service");
1170
- for (const key of matched.slice(0, 3)) expected[key] = (expected[key] ?? 0) + 1;
1171
- }
1172
- return expected;
1173
- }
1174
-
1175
- describe("listExperiments — recommended-count cost (#4861)", () => {
1176
- const recommendMock = (
1177
- jest.requireMock("@working-theory/validation") as { recommend: jest.Mock }
1178
- ).recommend;
1179
- const realRecommend = (
1180
- jest.requireActual("@working-theory/validation") as {
1181
- recommend: (...args: never[]) => unknown;
1182
- }
1183
- ).recommend;
1184
-
1185
- beforeEach(() => {
1186
- EXPERIMENTS.push(EXPERIMENT_C as never);
1187
- recommendMock.mockImplementation(realRecommend);
1188
- (prisma.flowCheckout.findMany as jest.Mock).mockResolvedValue([]);
1189
- });
1190
- afterEach(() => {
1191
- EXPERIMENTS.pop();
1192
- });
1193
-
1194
- it("AC-1: at the 20,000-row cap, recommend() runs once per DISTINCT answer set — and every count is what the rows say", async () => {
1195
- const rows = costSessions(MAX_AGGREGATION_ROWS);
1196
- (prisma.flowSession.findMany as jest.Mock).mockResolvedValue(rows);
1197
-
1198
- const startedAt = Date.now();
1199
- const result = await listExperiments({ kind: "site", siteId: WS_A });
1200
- const elapsedMs = Date.now() - startedAt;
1201
-
1202
- // 12 distinct answer sets in 20,000 rows (RED at HEAD: 20,000 calls). The
1203
- // lower bound keeps the assertion self-proving — a mock that stopped
1204
- // intercepting would report 0 and pass the ceiling alone.
1205
- expect(recommendMock.mock.calls.length).toBeGreaterThan(0);
1206
- expect(recommendMock.mock.calls.length).toBeLessThanOrEqual(32);
1207
- const expected = expectedRecommended(rows);
1208
- expect(result.experiments.find((e) => e.id === EXP_C)?.counts.offers).toEqual([
1209
- { offerKey: "free_oss", recommended: expected.free_oss, saved: 0, checkedOut: 0 },
1210
- { offerKey: "pro", recommended: expected.pro, saved: 0, checkedOut: 0 },
1211
- { offerKey: "pro_plus", recommended: expected.pro_plus, saved: 0, checkedOut: 0 },
1212
- { offerKey: "launch_service", recommended: expected.launch_service, saved: 0, checkedOut: 0 },
1213
- ]);
1214
- // A catastrophe ceiling, NOT the budget: the decided budget (≤ 500 ms at
1215
- // the cap, spec D7) is recorded on #4861 with its measurement, because
1216
- // wall-clock in CI is not a reliable gate.
1217
- expect(elapsedMs).toBeLessThan(2_000);
1218
- }, 60_000);
1219
-
1220
- it("AC-1: two sessions differing only in an UNDECLARED answer key share one recommend() and both count", async () => {
1221
- const base = costSessions(1)[0]!;
1222
- const twin = {
1223
- ...base,
1224
- completedAt: new Date(base.completedAt.getTime() + 1),
1225
- state: {
1226
- segmentation: { ...base.state.segmentation },
1227
- contact: { email: "someone-else@example.com" },
1228
- },
1229
- };
1230
- (prisma.flowSession.findMany as jest.Mock).mockResolvedValue([base, twin]);
1231
-
1232
- const result = await listExperiments({ kind: "site", siteId: WS_A });
1233
-
1234
- expect(recommendMock.mock.calls.length).toBe(1);
1235
- const offers = result.experiments.find((e) => e.id === EXP_C)?.counts.offers;
1236
- expect(offers?.find((o) => o.offerKey === "free_oss")?.recommended).toBe(2);
1237
- });
1238
-
1239
- it("AC-1: a free-text answer cannot forge another session's answer set into one memo entry", async () => {
1240
- const at = new Date("2026-09-04T00:00:00.000Z");
1241
- // Under a delimiter-joined key these two collide: the first session's
1242
- // company name carries the separator plus a literal `q_team=agency`.
1243
- const forger = {
1244
- siteId: WS_A,
1245
- flowSlug: "reco-cost",
1246
- completedAt: at,
1247
- state: { segmentation: { q_company: "Acme\u001eq_team=agency" } },
1248
- };
1249
- const genuine = {
1250
- siteId: WS_A,
1251
- flowSlug: "reco-cost",
1252
- completedAt: new Date(at.getTime() + 1),
1253
- state: { segmentation: { q_company: "Acme", q_team: "agency" } },
1254
- };
1255
- (prisma.flowSession.findMany as jest.Mock).mockResolvedValue([forger, genuine]);
1256
-
1257
- const result = await listExperiments({ kind: "site", siteId: WS_A });
1258
-
1259
- expect(recommendMock.mock.calls.length).toBe(2);
1260
- const offers = result.experiments.find((e) => e.id === EXP_C)?.counts.offers;
1261
- // Only the genuine `agency` answer reaches `pro`; the forger answered no
1262
- // segmentation question at all and fits nothing.
1263
- expect(offers?.find((o) => o.offerKey === "pro")?.recommended).toBe(1);
1264
- expect(offers?.find((o) => o.offerKey === "free_oss")?.recommended).toBe(0);
1265
- });
1266
-
1267
- it("AC-3: a flow no register can score costs ONE aggregated warn, not one per row", async () => {
1268
- const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
1269
- recommendMock.mockImplementation(() => {
1270
- throw Object.assign(new Error("register drifted"), { name: "WaitlistEvaluationError" });
1271
- });
1272
- const rows = costSessions(500);
1273
- (prisma.flowSession.findMany as jest.Mock).mockResolvedValue(rows);
1274
-
1275
- try {
1276
- const result = await listExperiments({ kind: "site", siteId: WS_A });
1277
-
1278
- const skipWarns = warnSpy.mock.calls.filter(
1279
- (call) => typeof call[1] === "string" && call[1].includes("[waitlist-dashboard] skipped"),
1280
- );
1281
- expect(skipWarns).toHaveLength(1);
1282
- expect(skipWarns[0]?.[0]).toEqual({
1283
- siteId: WS_A,
1284
- experimentKey: "reco-cost",
1285
- skipped: 500,
1286
- errName: "WaitlistEvaluationError",
1287
- });
1288
- // The failure is memoized too — once per distinct answer set, not per row.
1289
- expect(recommendMock.mock.calls.length).toBeLessThanOrEqual(32);
1290
- // A session nothing can score counts nowhere.
1291
- expect(
1292
- result.experiments.find((e) => e.id === EXP_C)?.counts.offers?.every((o) => o.recommended === 0),
1293
- ).toBe(true);
1294
- } finally {
1295
- warnSpy.mockRestore();
1296
- }
1297
- });
1298
-
1299
- it("AC-3: a fully scoreable flow emits no skipped-session warn at all", async () => {
1300
- const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
1301
- (prisma.flowSession.findMany as jest.Mock).mockResolvedValue(costSessions(60));
1302
-
1303
- try {
1304
- await listExperiments({ kind: "site", siteId: WS_A });
1305
- expect(
1306
- warnSpy.mock.calls.filter(
1307
- (call) => typeof call[1] === "string" && call[1].includes("[waitlist-dashboard] skipped"),
1308
- ),
1309
- ).toHaveLength(0);
1310
- } finally {
1311
- warnSpy.mockRestore();
1312
- }
1313
- });
1314
- });