create-ailk 0.2.0 → 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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -1,684 +0,0 @@
1
- /**
2
- * @file listing-promotion.test.ts
3
- * @description Unit tests for the listing-promotion service (#5295; ADR 0009
4
- * D3 service-layer pattern), mirroring `lead-promotion.test.ts`.
5
- *
6
- * External dependency boundary mocked:
7
- * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
8
- * manual mock (`jest.mock("../../lib/prisma.js")`).
9
- *
10
- * `prisma.projectListing.findMany` is given a REALISTIC filtering
11
- * implementation over an in-memory fixture "table" rather than a static
12
- * `mockResolvedValue`, so the two security properties this service exists for
13
- * actually get exercised against what the service passes to the DB. A bug that
14
- * widened the `siteId` match set, or that loosened the email comparison, would
15
- * surface the foreign row here rather than only in an argument-shape assertion.
16
- *
17
- * `tenant-context.ts`'s `listTenancy` runs REAL — it is a pure read over the
18
- * mocked `prisma.membership.findMany` — so the "resolve via the sole
19
- * tenant-context assembler" contract is covered rather than re-stubbed.
20
- */
21
-
22
- // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
23
- jest.mock("../../lib/prisma.js");
24
-
25
- import { prisma } from "../../lib/prisma.js";
26
- import { promoteListingsForVerifiedUser } from "../listing-promotion";
27
- import type { PromoteListingsResult } from "../listing-promotion";
28
-
29
- // ─── Fixtures ─────────────────────────────────────────────────────────────────
30
-
31
- const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
32
- const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
33
- const OWN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
34
- // OTHER_WORKSPACE_ID belongs to a different organization than OWN_ORG_ID —
35
- // the tenant-safety fixture (never a workspace of OWN_ORG_ID).
36
- const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
37
- // A SECOND workspace of the user's OWN organization — the per-site grain of the
38
- // ambiguity refusal needs two of the caller's own sites to be observable.
39
- const SECOND_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c3";
40
- const USER_EMAIL = "founder@example.com";
41
-
42
- type FakeListingRow = {
43
- id: string;
44
- founderEmail: string;
45
- siteId: string;
46
- ownerUserId: string | null;
47
- /** Defaults to `requested` — a submitted, claimable row. */
48
- status?: string;
49
- };
50
-
51
- /** Shapes prisma.membership.findMany's mocked return — what listTenancy reads. */
52
- function ownMembership() {
53
- return [
54
- {
55
- role: "owner",
56
- organization: {
57
- id: OWN_ORG_ID,
58
- name: "Acme",
59
- slug: "acme",
60
- workspaces: [{ id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" }],
61
- },
62
- },
63
- ];
64
- }
65
-
66
- function verifiedUser(overrides?: Partial<{ emailVerified: boolean; email: string }>) {
67
- return {
68
- id: USER_ID,
69
- email: overrides?.email ?? USER_EMAIL,
70
- emailVerified: overrides?.emailVerified ?? true,
71
- };
72
- }
73
-
74
- /**
75
- * Wires projectListing.findMany / updateMany to a realistic in-memory "table"
76
- * filtered by the `where` the service passes.
77
- *
78
- * `founderEmail` is compared with `===` here deliberately: the service passes a
79
- * plain string (EXACT equality), not `{ equals, mode: "insensitive" }`. Prisma
80
- * compiles an insensitive `equals` to `ILIKE`, which would make `_` and `%` in
81
- * the caller's own address LIKE wildcards — so if the service ever switched to
82
- * that form, this fixture would no longer model the DB and the
83
- * wildcard-capture test below would stop being meaningful. The fixture asserts
84
- * the form it models.
85
- */
86
- function seedListingTable(rows: FakeListingRow[]) {
87
- const table = rows.map((r) => ({ ...r }));
88
-
89
- (prisma.projectListing.findMany as jest.Mock).mockImplementation(
90
- async (args: {
91
- where: {
92
- founderEmail: unknown;
93
- siteId: { in: string[] };
94
- ownerUserId: null;
95
- status?: { in?: string[] };
96
- };
97
- select: { id: true; siteId: true };
98
- }) => {
99
- const { founderEmail, siteId, status } = args.where;
100
- if (typeof founderEmail !== "string") {
101
- throw new Error(
102
- "listing-promotion must compare founderEmail by exact string equality; " +
103
- "a filter object (e.g. mode: 'insensitive') compiles to ILIKE and makes " +
104
- "`_`/`%` in the caller's own address wildcards",
105
- );
106
- }
107
- // A candidate read with no status term would make an anonymous,
108
- // quota-free draft claimable; model the filter so its absence fails.
109
- if (!Array.isArray(status?.in)) {
110
- throw new Error(
111
- "the candidate read must constrain status; a draft is created anonymously with no quota, so a claimable draft is a planted-row vector",
112
- );
113
- }
114
- const claimable = status.in;
115
- return table
116
- .filter(
117
- (row) =>
118
- row.founderEmail === founderEmail &&
119
- siteId.in.includes(row.siteId) &&
120
- row.ownerUserId === null &&
121
- claimable.includes(row.status ?? "requested"),
122
- )
123
- .map((row) => ({ id: row.id, siteId: row.siteId }));
124
- },
125
- );
126
-
127
- /**
128
- * Models the WRITE's own `ownerUserId: null` predicate, not just its id list.
129
- * Without that the mock would happily overwrite a row already linked to
130
- * somebody else, hiding the compare-and-swap the service relies on to survive
131
- * two concurrent reconciliation runs.
132
- */
133
- (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
134
- async (args: {
135
- where: { id: { in: string[] }; ownerUserId: null };
136
- data: { ownerUserId: string; ownerLinkedAt: Date };
137
- }) => {
138
- if (args.where.ownerUserId !== null) {
139
- throw new Error(
140
- "the owner-link write must repeat `ownerUserId: null`; without it a concurrent run transfers a row away from the user who already claimed it",
141
- );
142
- }
143
- let count = 0;
144
- for (const row of table) {
145
- if (args.where.id.in.includes(row.id) && row.ownerUserId === null) {
146
- row.ownerUserId = args.data.ownerUserId;
147
- count += 1;
148
- }
149
- }
150
- return { count };
151
- },
152
- );
153
-
154
- return table;
155
- }
156
-
157
- describe("promoteListingsForVerifiedUser", () => {
158
- beforeEach(() => {
159
- jest.clearAllMocks();
160
- });
161
-
162
- // ── The happy path: all three conditions hold ────────────────────────────
163
- it("links an un-linked listing whose founderEmail is the verified user's own, in the user's own workspace", async () => {
164
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
165
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
166
- const table = seedListingTable([
167
- {
168
- id: "prjl_00000000-0000-7000-8000-0000000000d1",
169
- founderEmail: USER_EMAIL,
170
- siteId: OWN_WORKSPACE_ID,
171
- ownerUserId: null,
172
- },
173
- ]);
174
-
175
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
176
-
177
- expect(result).toEqual<PromoteListingsResult>({
178
- ok: true,
179
- count: 1,
180
- promoted: [
181
- {
182
- listingId: "prjl_00000000-0000-7000-8000-0000000000d1",
183
- siteId: OWN_WORKSPACE_ID,
184
- },
185
- ],
186
- });
187
- expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
188
- where: {
189
- founderEmail: USER_EMAIL,
190
- siteId: { in: [OWN_WORKSPACE_ID] },
191
- ownerUserId: null,
192
- status: { in: ["requested", "rejected", "active"] },
193
- },
194
- select: { id: true, siteId: true },
195
- });
196
- expect(prisma.projectListing.updateMany).toHaveBeenCalledWith({
197
- where: {
198
- id: { in: ["prjl_00000000-0000-7000-8000-0000000000d1"] },
199
- // Repeated on the write, so the update is a compare-and-swap.
200
- ownerUserId: null,
201
- },
202
- data: { ownerUserId: USER_ID, ownerLinkedAt: expect.any(Date) },
203
- });
204
- expect(table[0]?.ownerUserId).toBe(USER_ID);
205
- });
206
-
207
- // ── SECURITY PROPERTY 1 ──────────────────────────────────────────────────
208
- it("an UNVERIFIED email never produces a link, even when it MATCHES founderEmail exactly", async () => {
209
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(
210
- verifiedUser({ emailVerified: false }),
211
- );
212
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
213
- // The listing's founderEmail is byte-identical to the caller's address, and
214
- // it sits in a workspace the caller owns. Only verification is missing.
215
- const table = seedListingTable([
216
- {
217
- id: "prjl_unverified_match",
218
- founderEmail: USER_EMAIL,
219
- siteId: OWN_WORKSPACE_ID,
220
- ownerUserId: null,
221
- },
222
- ]);
223
-
224
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
225
-
226
- expect(result).toEqual<PromoteListingsResult>({
227
- ok: true,
228
- count: 0,
229
- promoted: [],
230
- });
231
- // The gate is evaluated BEFORE any tenancy or listing read happens at all.
232
- expect(prisma.membership.findMany).not.toHaveBeenCalled();
233
- expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
234
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
235
- expect(table[0]?.ownerUserId).toBeNull();
236
- });
237
-
238
- // ── SECURITY PROPERTY 2 ──────────────────────────────────────────────────
239
- it("a matching, VERIFIED email in a DIFFERENT tenant never produces a link", async () => {
240
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
241
- // The user belongs ONLY to OWN_ORG_ID / OWN_WORKSPACE_ID.
242
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
243
- const table = seedListingTable([
244
- {
245
- id: "prjl_cross_tenant",
246
- founderEmail: USER_EMAIL, // the caller's own verified address
247
- siteId: OTHER_WORKSPACE_ID, // a DIFFERENT organization's workspace
248
- ownerUserId: null,
249
- },
250
- ]);
251
-
252
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
253
-
254
- expect(result).toEqual<PromoteListingsResult>({
255
- ok: true,
256
- count: 0,
257
- promoted: [],
258
- });
259
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
260
- expect(table.find((r) => r.id === "prjl_cross_tenant")?.ownerUserId).toBeNull();
261
- // The query itself never widens siteId.in to the foreign workspace.
262
- expect(prisma.projectListing.findMany).toHaveBeenCalledWith({
263
- where: {
264
- founderEmail: USER_EMAIL,
265
- siteId: { in: [OWN_WORKSPACE_ID] },
266
- ownerUserId: null,
267
- status: { in: ["requested", "rejected", "active"] },
268
- },
269
- select: { id: true, siteId: true },
270
- });
271
- });
272
-
273
- /**
274
- * The email comparison is EXACT, so a LIKE metacharacter in the caller's own
275
- * verified address cannot capture a different founder's row.
276
- *
277
- * Verified against a live Postgres while building this: with
278
- * `{ equals, mode: "insensitive" }` Prisma emits `founder_email ILIKE $1`,
279
- * and a caller whose real address is `a_c@example.com` matched BOTH its own
280
- * row and a stranger's `abc@example.com` in the same workspace — two rows in,
281
- * two rows out. This test is the regression lock for that.
282
- */
283
- it("a LIKE metacharacter in the caller's own verified address captures no other founder's listing", async () => {
284
- const WILDCARD_EMAIL = "a_c@example.com";
285
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(
286
- verifiedUser({ email: WILDCARD_EMAIL }),
287
- );
288
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
289
- const table = seedListingTable([
290
- {
291
- id: "prjl_mine",
292
- founderEmail: WILDCARD_EMAIL,
293
- siteId: OWN_WORKSPACE_ID,
294
- ownerUserId: null,
295
- },
296
- {
297
- // A DIFFERENT person, in the same workspace, whose address differs from
298
- // the caller's only where the `_` sits.
299
- id: "prjl_neighbour",
300
- founderEmail: "abc@example.com",
301
- siteId: OWN_WORKSPACE_ID,
302
- ownerUserId: null,
303
- },
304
- ]);
305
-
306
- const result = (await promoteListingsForVerifiedUser({
307
- userId: USER_ID,
308
- })) as Extract<PromoteListingsResult, { ok: true }>;
309
-
310
- expect(result.count).toBe(1);
311
- expect(result.promoted).toEqual([
312
- { listingId: "prjl_mine", siteId: OWN_WORKSPACE_ID },
313
- ]);
314
- expect(table.find((r) => r.id === "prjl_neighbour")?.ownerUserId).toBeNull();
315
- });
316
-
317
- // ── Two people sharing an address: the link follows the VERIFIED one ──────
318
- it("two listings sharing a founderEmail are both linked ONLY to the user who verified that address", async () => {
319
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
320
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
321
- const table = seedListingTable([
322
- {
323
- id: "prjl_shared_1",
324
- founderEmail: USER_EMAIL,
325
- siteId: OWN_WORKSPACE_ID,
326
- ownerUserId: null,
327
- },
328
- {
329
- // Already linked to SOMEBODY ELSE — excluded by `ownerUserId: null`, so
330
- // a later verifier of the same address can never steal it.
331
- id: "prjl_shared_2",
332
- founderEmail: USER_EMAIL,
333
- siteId: OWN_WORKSPACE_ID,
334
- ownerUserId: "user_00000000-0000-7000-8000-0000000000a2",
335
- },
336
- ]);
337
-
338
- const result = (await promoteListingsForVerifiedUser({
339
- userId: USER_ID,
340
- })) as Extract<PromoteListingsResult, { ok: true }>;
341
-
342
- expect(result.count).toBe(1);
343
- expect(result.promoted).toEqual([
344
- { listingId: "prjl_shared_1", siteId: OWN_WORKSPACE_ID },
345
- ]);
346
- expect(table.find((r) => r.id === "prjl_shared_2")?.ownerUserId).toBe(
347
- "user_00000000-0000-7000-8000-0000000000a2",
348
- );
349
- });
350
-
351
- /**
352
- * THE COUNTEREXAMPLE this service is built to refuse.
353
- *
354
- * Two strangers both typed the same address into the anonymous form, so there
355
- * are TWO un-linked rows carrying it at one site and nothing that says which
356
- * is whose. Linking both to whoever verifies the address would hand one
357
- * person the other's whole application — the disclosure the owner link exists
358
- * to remove, arriving through the promotion path instead of the read path.
359
- *
360
- * Found by an independent verifier who reproduced it end to end against a
361
- * live Postgres before the refusal existed: `count: 2`, and the verifying
362
- * founder read the stranger's row through `GET /v1/project-listings/me`.
363
- */
364
- it("REFUSES to link when TWO un-linked listings share the address at one site", async () => {
365
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
366
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
367
- const table = seedListingTable([
368
- {
369
- id: "prjl_typed_by_founder_a",
370
- founderEmail: USER_EMAIL,
371
- siteId: OWN_WORKSPACE_ID,
372
- ownerUserId: null,
373
- },
374
- {
375
- // A DIFFERENT person who typed the same address. Indistinguishable.
376
- id: "prjl_typed_by_founder_b",
377
- founderEmail: USER_EMAIL,
378
- siteId: OWN_WORKSPACE_ID,
379
- ownerUserId: null,
380
- },
381
- ]);
382
-
383
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
384
-
385
- expect(result).toEqual<PromoteListingsResult>({
386
- ok: true,
387
- count: 0,
388
- promoted: [],
389
- });
390
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
391
- // BOTH rows stay null. Neither founder can read the other's application.
392
- expect(table.find((r) => r.id === "prjl_typed_by_founder_a")?.ownerUserId).toBeNull();
393
- expect(table.find((r) => r.id === "prjl_typed_by_founder_b")?.ownerUserId).toBeNull();
394
- });
395
-
396
- it("an ambiguous group at one site does not block an unambiguous row at another", async () => {
397
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
398
- (prisma.membership.findMany as jest.Mock).mockResolvedValue([
399
- {
400
- role: "owner",
401
- organization: {
402
- id: OWN_ORG_ID,
403
- name: "Acme",
404
- slug: "acme",
405
- workspaces: [
406
- { id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" },
407
- { id: SECOND_WORKSPACE_ID, name: "Acme Two", slug: "acme-two" },
408
- ],
409
- },
410
- },
411
- ]);
412
- const table = seedListingTable([
413
- // Ambiguous pair at the first site — both refused.
414
- {
415
- id: "prjl_ambiguous_a",
416
- founderEmail: USER_EMAIL,
417
- siteId: OWN_WORKSPACE_ID,
418
- ownerUserId: null,
419
- },
420
- {
421
- id: "prjl_ambiguous_b",
422
- founderEmail: USER_EMAIL,
423
- siteId: OWN_WORKSPACE_ID,
424
- ownerUserId: null,
425
- },
426
- // The only row carrying the address at the SECOND site — attributable.
427
- {
428
- id: "prjl_unambiguous",
429
- founderEmail: USER_EMAIL,
430
- siteId: SECOND_WORKSPACE_ID,
431
- ownerUserId: null,
432
- },
433
- ]);
434
-
435
- const result = (await promoteListingsForVerifiedUser({
436
- userId: USER_ID,
437
- })) as Extract<PromoteListingsResult, { ok: true }>;
438
-
439
- expect(result.count).toBe(1);
440
- expect(result.promoted).toEqual([
441
- { listingId: "prjl_unambiguous", siteId: SECOND_WORKSPACE_ID },
442
- ]);
443
- expect(table.find((r) => r.id === "prjl_ambiguous_a")?.ownerUserId).toBeNull();
444
- expect(table.find((r) => r.id === "prjl_ambiguous_b")?.ownerUserId).toBeNull();
445
- expect(table.find((r) => r.id === "prjl_unambiguous")?.ownerUserId).toBe(USER_ID);
446
- });
447
-
448
- /**
449
- * The write repeats `ownerUserId: null`, so a run that loses a race matches 0
450
- * rows instead of transferring a row away from the user who already claimed
451
- * it. The candidate read alone cannot establish this: both runs read the row
452
- * as un-linked before either wrote.
453
- */
454
- it("a row claimed between the candidate read and the write is not transferred away", async () => {
455
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
456
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
457
- const table = seedListingTable([
458
- {
459
- id: "prjl_raced",
460
- founderEmail: USER_EMAIL,
461
- siteId: OWN_WORKSPACE_ID,
462
- ownerUserId: null,
463
- },
464
- ]);
465
-
466
- // Simulate the interleaving: another verifier claims the row after this
467
- // call's candidate read returns it and before its update runs.
468
- const findMany = prisma.projectListing.findMany as jest.Mock;
469
- const realFindMany = findMany.getMockImplementation();
470
- findMany.mockImplementation(async (args: Parameters<typeof realFindMany>[0]) => {
471
- const rows = await realFindMany!(args);
472
- const raced = table.find((r) => r.id === "prjl_raced");
473
- if (raced) raced.ownerUserId = "user_00000000-0000-7000-8000-0000000000a9";
474
- return rows;
475
- });
476
-
477
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
478
-
479
- expect(result).toEqual<PromoteListingsResult>({
480
- ok: true,
481
- count: 0,
482
- promoted: [],
483
- });
484
- // The first claimant keeps the row.
485
- expect(table.find((r) => r.id === "prjl_raced")?.ownerUserId).toBe(
486
- "user_00000000-0000-7000-8000-0000000000a9",
487
- );
488
- });
489
-
490
- /**
491
- * The denial-of-link the ambiguity refusal would otherwise open, found by the
492
- * security reviewer after the refusal landed.
493
- *
494
- * `POST /v1/project-listings` is anonymous and the concurrent-listing quota is
495
- * enforced at SUBMIT, not at start, so anyone with a site's public key — which
496
- * ships in browser JavaScript by design — can create unbounded `draft` rows
497
- * carrying any address. One planted draft would be enough to make every
498
- * genuine row at that site ambiguous, permanently blocking the real founder
499
- * from ever claiming their own listing. Excluding drafts from candidacy is
500
- * what stops one anonymous request from doing that.
501
- */
502
- it("a planted anonymous DRAFT neither links nor blocks the founder's own submitted row", async () => {
503
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
504
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
505
- const table = seedListingTable([
506
- {
507
- id: "prjl_planted_draft",
508
- founderEmail: USER_EMAIL,
509
- siteId: OWN_WORKSPACE_ID,
510
- ownerUserId: null,
511
- status: "draft",
512
- },
513
- {
514
- id: "prjl_genuine_submitted",
515
- founderEmail: USER_EMAIL,
516
- siteId: OWN_WORKSPACE_ID,
517
- ownerUserId: null,
518
- status: "requested",
519
- },
520
- ]);
521
-
522
- const result = (await promoteListingsForVerifiedUser({
523
- userId: USER_ID,
524
- })) as Extract<PromoteListingsResult, { ok: true }>;
525
-
526
- // The draft is not a candidate, so the group is NOT ambiguous and the
527
- // founder's own submitted row still links.
528
- expect(result.count).toBe(1);
529
- expect(result.promoted).toEqual([
530
- { listingId: "prjl_genuine_submitted", siteId: OWN_WORKSPACE_ID },
531
- ]);
532
- expect(table.find((r) => r.id === "prjl_planted_draft")?.ownerUserId).toBeNull();
533
- });
534
-
535
- it("a rejected listing is still the founder's own and still links", async () => {
536
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
537
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
538
- seedListingTable([
539
- {
540
- id: "prjl_rejected",
541
- founderEmail: USER_EMAIL,
542
- siteId: OWN_WORKSPACE_ID,
543
- ownerUserId: null,
544
- status: "rejected",
545
- },
546
- ]);
547
-
548
- const result = (await promoteListingsForVerifiedUser({
549
- userId: USER_ID,
550
- })) as Extract<PromoteListingsResult, { ok: true }>;
551
-
552
- expect(result.count).toBe(1);
553
- });
554
-
555
- // ── Case + whitespace: the user side is normalized the way writes are ────
556
- it("normalizes the user's address the way startProjectListingDraft normalizes founderEmail", async () => {
557
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(
558
- verifiedUser({ email: " Founder@Example.com " }),
559
- );
560
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
561
- seedListingTable([
562
- {
563
- id: "prjl_normalized",
564
- founderEmail: USER_EMAIL,
565
- siteId: OWN_WORKSPACE_ID,
566
- ownerUserId: null,
567
- },
568
- ]);
569
-
570
- const result = (await promoteListingsForVerifiedUser({
571
- userId: USER_ID,
572
- })) as Extract<PromoteListingsResult, { ok: true }>;
573
-
574
- expect(result.count).toBe(1);
575
- expect(prisma.projectListing.findMany).toHaveBeenCalledWith(
576
- expect.objectContaining({
577
- where: expect.objectContaining({ founderEmail: USER_EMAIL }),
578
- }),
579
- );
580
- });
581
-
582
- // ── Idempotent ───────────────────────────────────────────────────────────
583
- it("a listing linked on the first run is excluded on the second run", async () => {
584
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
585
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
586
- seedListingTable([
587
- {
588
- id: "prjl_first_run",
589
- founderEmail: USER_EMAIL,
590
- siteId: OWN_WORKSPACE_ID,
591
- ownerUserId: null,
592
- },
593
- ]);
594
-
595
- const first = await promoteListingsForVerifiedUser({ userId: USER_ID });
596
- expect(first).toEqual<PromoteListingsResult>({
597
- ok: true,
598
- count: 1,
599
- promoted: [{ listingId: "prjl_first_run", siteId: OWN_WORKSPACE_ID }],
600
- });
601
-
602
- const second = await promoteListingsForVerifiedUser({ userId: USER_ID });
603
- expect(second).toEqual<PromoteListingsResult>({
604
- ok: true,
605
- count: 0,
606
- promoted: [],
607
- });
608
- });
609
-
610
- // ── No match → count:0 ───────────────────────────────────────────────────
611
- it("no matching listing → count:0 with no update", async () => {
612
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
613
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
614
- seedListingTable([]);
615
-
616
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
617
-
618
- expect(result).toEqual<PromoteListingsResult>({
619
- ok: true,
620
- count: 0,
621
- promoted: [],
622
- });
623
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
624
- });
625
-
626
- // ── No workspaces → no-op, never queries the listing table ───────────────
627
- it("a user with no workspaces is a no-op — never queries inbox.ProjectListing", async () => {
628
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
629
- (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
630
-
631
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
632
-
633
- expect(result).toEqual<PromoteListingsResult>({
634
- ok: true,
635
- count: 0,
636
- promoted: [],
637
- });
638
- expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
639
- });
640
-
641
- // ── Unknown user → ok:false, user_not_found ──────────────────────────────
642
- it("an unknown userId returns ok:false with kind user_not_found", async () => {
643
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
644
-
645
- const result = (await promoteListingsForVerifiedUser({
646
- userId: "user_does-not-exist",
647
- })) as Extract<PromoteListingsResult, { ok: false }>;
648
-
649
- expect(result.ok).toBe(false);
650
- expect(result.error.kind).toBe("user_not_found");
651
- });
652
-
653
- // ── A DB failure is a result, never a throw ──────────────────────────────
654
- it("a DB failure returns ok:false internal_error rather than throwing", async () => {
655
- (prisma.user.findUnique as jest.Mock).mockRejectedValue(
656
- new Error("connection terminated"),
657
- );
658
-
659
- const result = (await promoteListingsForVerifiedUser({
660
- userId: USER_ID,
661
- })) as Extract<PromoteListingsResult, { ok: false }>;
662
-
663
- expect(result.ok).toBe(false);
664
- expect(result.error.kind).toBe("internal_error");
665
- });
666
-
667
- // ── PII non-leak — the result never carries the user's raw email ─────────
668
- it("PII non-leak — the result never carries the founder's raw email", async () => {
669
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
670
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(ownMembership());
671
- seedListingTable([
672
- {
673
- id: "prjl_pii",
674
- founderEmail: USER_EMAIL,
675
- siteId: OWN_WORKSPACE_ID,
676
- ownerUserId: null,
677
- },
678
- ]);
679
-
680
- const result = await promoteListingsForVerifiedUser({ userId: USER_ID });
681
-
682
- expect(JSON.stringify(result)).not.toContain(USER_EMAIL);
683
- });
684
- });