create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,731 @@
1
+ /**
2
+ * @file offerings.test.ts
3
+ * GET /v1/content/offerings — the HTTP twin of the `get_offerings` MCP tool
4
+ * (#7177).
5
+ *
6
+ * Uses the real content adapter backed by a per-test temp dir (Issue #507).
7
+ * The resolver is spied on only for the 404 and 500 arms.
8
+ *
9
+ * The `provider:` and `categories:` fixtures are written here rather than
10
+ * through `writeFixturePage`, whose frontmatter parameter is
11
+ * `Record<string, string>` and so cannot express YAML nesting.
12
+ */
13
+
14
+ import * as fs from "fs/promises";
15
+ import * as path from "path";
16
+ import { Writable } from "node:stream";
17
+
18
+ import cors from "@fastify/cors";
19
+ import {
20
+ currentShownSet,
21
+ listOfferings,
22
+ listOfferingsWithShownSet,
23
+ type ContentAdapter,
24
+ } from "@working-theory/content-adapters";
25
+ import { logger } from "@working-theory/observability";
26
+ import { shownOfferingsAnswerSchema } from "@working-theory/validation";
27
+ import Fastify from "fastify";
28
+ import type { FastifyInstance } from "fastify";
29
+
30
+ import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
31
+ import { getContentAdapter } from "../../../lib/content-adapter";
32
+ import * as publicSiteResolver from "../../../services/public-site-resolver.js";
33
+ import { contentRoutes } from "../index";
34
+ import * as commerceClaim from "../../../lib/require-commerce-claim.js";
35
+ import { prisma } from "../../../lib/prisma.js";
36
+ import * as offeringsServices from "../../../services/offerings/index.js";
37
+ import {
38
+ FIXTURE_NOW,
39
+ FIXTURE_ORG,
40
+ FIXTURE_ORG_2_WS,
41
+ FIXTURE_WS,
42
+ FIXTURE_WS_2,
43
+ OFFERINGS_FIXTURE_CATALOG,
44
+ seedOfferings as seedOfferingsRows,
45
+ } from "../../../services/offerings/__tests__/fixtures.js";
46
+ import { offeringsRoute } from "../offerings";
47
+ import { pricingRoute } from "../pricing";
48
+ import * as shownOfferings from "../shown-offerings";
49
+ import type { ShownOfferingsRouteOptions } from "../shown-offerings";
50
+
51
+ // #8933 — the Offerings services read and write the in-memory double, so a
52
+ // seeded connected workspace answers its shown set. The content cases above
53
+ // never resolve a workspace (no spoke key, the MDX adapter), so they never
54
+ // reach it.
55
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
56
+ jest.mock("../../../lib/prisma.js", () =>
57
+ jest
58
+ .requireActual<
59
+ typeof import("../../../__tests__/factories/in-memory-prisma")
60
+ >("../../../__tests__/factories/in-memory-prisma")
61
+ .createInMemoryPrisma(),
62
+ );
63
+
64
+ /** While set, the shown-set mapping throws (AC-3) — a plain switch, not a mock. */
65
+ const mockMappingThrows = { on: false };
66
+
67
+ // The real package, with the shown-set mapping behind a switch a case can
68
+ // throw through. Not a stand-in: every other export is the real one.
69
+ jest.mock("@working-theory/content-adapters", () => {
70
+ const actual = jest.requireActual<
71
+ typeof import("@working-theory/content-adapters")
72
+ >("@working-theory/content-adapters");
73
+ return {
74
+ __esModule: true,
75
+ ...actual,
76
+ listOfferingsWithShownSet: (
77
+ ...args: Parameters<typeof actual.listOfferingsWithShownSet>
78
+ ) => {
79
+ if (mockMappingThrows.on) {
80
+ throw new Error("mapping refused Growth: For a growing practice.");
81
+ }
82
+ return actual.listOfferingsWithShownSet(...args);
83
+ },
84
+ };
85
+ });
86
+
87
+ const fixture = setupContentFixture();
88
+
89
+ /** Writes an MDX page into the default-locale path. */
90
+ async function writePage(
91
+ type: "Service" | "Product",
92
+ slug: string,
93
+ lines: readonly string[],
94
+ ): Promise<void> {
95
+ const dir = path.join(fixture.getContentRoot(), "en", type);
96
+ await fs.mkdir(dir, { recursive: true });
97
+ const frontmatter = [`type: "${type}"`, `slug: "${slug}"`, ...lines].join(
98
+ "\n",
99
+ );
100
+ await fs.writeFile(
101
+ path.join(dir, `${slug}.mdx`),
102
+ `---\n${frontmatter}\n---\n\nBody copy.\n`,
103
+ "utf8",
104
+ );
105
+ }
106
+
107
+ async function seedOfferings(): Promise<void> {
108
+ await writePage("Service", "launch", [
109
+ `title: "Launch Service"`,
110
+ `description: "A productized two-week deploy."`,
111
+ "provider:",
112
+ " name: SalesSmyth",
113
+ "price: 2495",
114
+ "priceCurrency: USD",
115
+ "categories:",
116
+ " - technology",
117
+ ]);
118
+ await writePage("Product", "standard", [
119
+ `title: "Standard"`,
120
+ `description: "The flagship marketing template."`,
121
+ "provider:",
122
+ " name: SalesSmyth",
123
+ " logo: /logomark.svg",
124
+ ]);
125
+ }
126
+
127
+ function names(body: string): string[] {
128
+ return (
129
+ JSON.parse(body) as { data: { offerings: { name: string }[] } }
130
+ ).data.offerings
131
+ .map((o) => o.name)
132
+ .sort();
133
+ }
134
+
135
+ describe("GET /v1/content/offerings", () => {
136
+ let app: FastifyInstance;
137
+
138
+ beforeEach(async () => {
139
+ app = require("fastify")();
140
+ await app.register(contentRoutes);
141
+ });
142
+
143
+ afterEach(async () => {
144
+ jest.restoreAllMocks();
145
+ await app.close();
146
+ });
147
+
148
+ it("answers 200 with every offering in the API envelope", async () => {
149
+ await seedOfferings();
150
+
151
+ const res = await app.inject({
152
+ method: "GET",
153
+ url: "/v1/content/offerings",
154
+ });
155
+
156
+ expect(res.statusCode).toBe(200);
157
+ expect(JSON.parse(res.body).ok).toBe(true);
158
+ expect(names(res.body)).toEqual(
159
+ expect.arrayContaining(["Launch Service", "Standard"]),
160
+ );
161
+ });
162
+
163
+ it("answers Service pages only for ?type=service", async () => {
164
+ await seedOfferings();
165
+
166
+ const res = await app.inject({
167
+ method: "GET",
168
+ url: "/v1/content/offerings?type=service",
169
+ });
170
+
171
+ expect(res.statusCode).toBe(200);
172
+ expect(names(res.body)).toEqual(["Launch Service"]);
173
+ });
174
+
175
+ it("answers Product pages only for ?type=product", async () => {
176
+ await seedOfferings();
177
+
178
+ const res = await app.inject({
179
+ method: "GET",
180
+ url: "/v1/content/offerings?type=product",
181
+ });
182
+
183
+ expect(res.statusCode).toBe(200);
184
+ // The seeded page and the fixture's own `test-product`, and no Service.
185
+ expect(names(res.body)).toEqual(["Product test-product title", "Standard"]);
186
+ });
187
+
188
+ it("refuses a type the tool refuses, echoing no issues", async () => {
189
+ const res = await app.inject({
190
+ method: "GET",
191
+ url: "/v1/content/offerings?type=offer",
192
+ });
193
+
194
+ expect(res.statusCode).toBe(400);
195
+ const body = JSON.parse(res.body);
196
+ expect(body.ok).toBe(false);
197
+ expect(body.error.code).toBe("validation_failed");
198
+ expect(body).not.toHaveProperty("details");
199
+ expect(body).not.toHaveProperty("issues");
200
+ expect(body.error).not.toHaveProperty("details");
201
+ expect(body.error).not.toHaveProperty("issues");
202
+ });
203
+
204
+ it("refuses a malformed locale, and falls back for an unknown well-formed one", async () => {
205
+ await seedOfferings();
206
+
207
+ const malformed = await app.inject({
208
+ method: "GET",
209
+ url: "/v1/content/offerings?locale=english",
210
+ });
211
+ expect(malformed.statusCode).toBe(400);
212
+ expect(JSON.parse(malformed.body).error.code).toBe("validation_failed");
213
+
214
+ const unknown = await app.inject({
215
+ method: "GET",
216
+ url: "/v1/content/offerings?locale=zz",
217
+ });
218
+ expect(unknown.statusCode).toBe(200);
219
+ expect(names(unknown.body)).toEqual(
220
+ expect.arrayContaining(["Launch Service", "Standard"]),
221
+ );
222
+ });
223
+
224
+ it("answers 404 not_found when no public site resolves", async () => {
225
+ jest
226
+ .spyOn(publicSiteResolver, "resolvePublicContentAdapter")
227
+ .mockResolvedValue(null);
228
+
229
+ const res = await app.inject({
230
+ method: "GET",
231
+ url: "/v1/content/offerings",
232
+ });
233
+
234
+ expect(res.statusCode).toBe(404);
235
+ const body = JSON.parse(res.body);
236
+ expect(body.ok).toBe(false);
237
+ expect(body.error.code).toBe("not_found");
238
+ // The 404 depends on the spoke key too, so a URL-keyed cache must not
239
+ // serve one bad key's 404 to another site.
240
+ expect(String(res.headers["vary"]).toLowerCase()).toContain(
241
+ "x-ailk-spoke-key",
242
+ );
243
+ });
244
+
245
+ it("answers 500 internal_error and leaks nothing when the read throws", async () => {
246
+ const failing: Pick<ContentAdapter, "listPages"> = {
247
+ listPages: async () => {
248
+ throw new Error("secret-path");
249
+ },
250
+ };
251
+ jest
252
+ .spyOn(publicSiteResolver, "resolvePublicContentAdapter")
253
+ .mockResolvedValue(failing as ContentAdapter);
254
+
255
+ const res = await app.inject({
256
+ method: "GET",
257
+ url: "/v1/content/offerings",
258
+ });
259
+
260
+ expect(res.statusCode).toBe(500);
261
+ expect(JSON.parse(res.body).error.code).toBe("internal_error");
262
+ expect(res.body).not.toContain("secret-path");
263
+ expect(res.body).not.toContain("stack");
264
+ });
265
+
266
+ /**
267
+ * D6 — API ↔ MCP parity evidence for #7177.
268
+ *
269
+ * The route must return EXACTLY what `listOfferings` returns for the
270
+ * options the `get_offerings` tool builds from the same arguments.
271
+ * `apps/api` cannot import `apps/mcp`, so the shared seam is asserted:
272
+ * `apps/mcp/__tests__/get_offerings.test.ts` pins the other half — tool
273
+ * answer ≡ `listOfferings` — and the two compose into the 1:1 claim.
274
+ */
275
+ describe("parity with the seam the MCP tool calls (#7177 D5)", () => {
276
+ it("deep-equals listOfferings for ?type=service&locale=en", async () => {
277
+ await seedOfferings();
278
+
279
+ const res = await app.inject({
280
+ method: "GET",
281
+ url: "/v1/content/offerings?type=service&locale=en",
282
+ });
283
+ expect(res.statusCode).toBe(200);
284
+
285
+ const expected = await listOfferings(getContentAdapter(), {
286
+ type: "service",
287
+ locale: "en",
288
+ });
289
+ expect(JSON.parse(res.body).data).toEqual(
290
+ JSON.parse(JSON.stringify(expected)),
291
+ );
292
+ });
293
+
294
+ it("deep-equals listOfferings with no query at all", async () => {
295
+ await seedOfferings();
296
+
297
+ const res = await app.inject({
298
+ method: "GET",
299
+ url: "/v1/content/offerings",
300
+ });
301
+ expect(res.statusCode).toBe(200);
302
+
303
+ const expected = await listOfferings(getContentAdapter(), {
304
+ locale: "en",
305
+ });
306
+ expect(JSON.parse(res.body).data).toEqual(
307
+ JSON.parse(JSON.stringify(expected)),
308
+ );
309
+ });
310
+ });
311
+ });
312
+
313
+ describe("GET /v1/content/offerings — Vary beside CORS", () => {
314
+ let app: FastifyInstance;
315
+
316
+ beforeEach(async () => {
317
+ app = require("fastify")();
318
+ // Registered as `server.ts` registers it, so CORS's `Vary: Origin` is
319
+ // written before the route adds its own value.
320
+ await app.register(cors, {
321
+ origin: ["https://a.example"],
322
+ credentials: true,
323
+ });
324
+ await app.register(contentRoutes);
325
+ });
326
+
327
+ afterEach(async () => {
328
+ await app.close();
329
+ });
330
+
331
+ it("appends x-ailk-spoke-key to Vary and keeps Origin", async () => {
332
+ const res = await app.inject({
333
+ method: "GET",
334
+ url: "/v1/content/offerings",
335
+ headers: { origin: "https://a.example" },
336
+ });
337
+
338
+ expect(res.statusCode).toBe(200);
339
+ const vary = String(res.headers["vary"])
340
+ .split(",")
341
+ .map((v) => v.trim().toLowerCase());
342
+ expect(vary).toEqual(
343
+ expect.arrayContaining(["origin", "x-ailk-spoke-key"]),
344
+ );
345
+ expect(res.headers["access-control-allow-origin"]).toBe(
346
+ "https://a.example",
347
+ );
348
+ });
349
+ });
350
+
351
+ /**
352
+ * #8933 (spec docs/specs/8933-get-pricing-and-get-offerings-spec.md) — on a
353
+ * connected workspace every read but `type=service` answers the Service
354
+ * pages followed by the workspace's Offerings shown set, read through
355
+ * #8924's `readShownOfferings` over #8876's real services, the in-memory
356
+ * database and the seed at `FIXTURE_NOW`. The resolver's workspace read and
357
+ * the commerce claim are spied; the content adapter is the real fixture one,
358
+ * so every fallback is compared with today's `listOfferings` answer over it.
359
+ */
360
+ describe("GET /v1/content/offerings — the Offerings shown set (#8933)", () => {
361
+ type Row = Record<string, unknown>;
362
+ // The in-memory double exposes its store, which the PrismaClient type the
363
+ // import carries does not name, so it is named here once.
364
+ // eslint-disable-next-line no-restricted-syntax -- justified above
365
+ const db = prisma as unknown as { __db: Record<string, Row[]> };
366
+
367
+ const SECOND = 1000;
368
+ const HOUR = 60 * 60 * SECOND;
369
+ const at = (offsetMs: number) => new Date(FIXTURE_NOW.getTime() + offsetMs);
370
+
371
+ /** The seed's shown Products as an agent reads them (AC-1). */
372
+ const EXPECTED_SHOWN = [
373
+ {
374
+ name: "Basic",
375
+ description: "One site, set up once.",
376
+ provider: null,
377
+ categories: [],
378
+ price: 99,
379
+ priceCurrency: "USD",
380
+ prices: [
381
+ { price: 99, priceCurrency: "USD", interval: null, lookupKey: "basic_once" },
382
+ ],
383
+ },
384
+ {
385
+ name: "Growth",
386
+ description: "For a growing practice.\nEverything in Basic.",
387
+ provider: null,
388
+ categories: [],
389
+ price: 49,
390
+ priceCurrency: "USD",
391
+ prices: [
392
+ { price: 49, priceCurrency: "USD", interval: "month", lookupKey: "growth_monthly" },
393
+ { price: 490, priceCurrency: "USD", interval: "year", lookupKey: "growth_yearly" },
394
+ ],
395
+ },
396
+ {
397
+ name: "SEO audit",
398
+ description: "",
399
+ provider: null,
400
+ categories: [],
401
+ price: 15,
402
+ priceCurrency: "USD",
403
+ prices: [
404
+ { price: 15, priceCurrency: "USD", interval: "month", lookupKey: "seo-audit_monthly" },
405
+ ],
406
+ },
407
+ ];
408
+
409
+ /** Every product name, description, feature string, lookup key and id in the catalog. */
410
+ const FIXTURE_TEXT: string[] = [
411
+ ...OFFERINGS_FIXTURE_CATALOG.products.flatMap((product) => [
412
+ product.name,
413
+ ...(product.description ? [product.description] : []),
414
+ ...(product.marketing_features ?? []).map((feature) => feature.name),
415
+ product.id,
416
+ ]),
417
+ ...OFFERINGS_FIXTURE_CATALOG.prices.flatMap((price) => [
418
+ price.id,
419
+ ...(price.lookup_key ? [price.lookup_key] : []),
420
+ ]),
421
+ "prod_",
422
+ "price_",
423
+ ];
424
+
425
+ let logLines: string[] = [];
426
+ let loggerCalls: unknown[][] = [];
427
+ let apps: FastifyInstance[] = [];
428
+ let resolveWorkspace: jest.SpyInstance;
429
+ let resolveClaim: jest.SpyInstance;
430
+
431
+ /** A Fastify with a log stream and the three routes that read the shown set. */
432
+ async function buildApp(
433
+ options: ShownOfferingsRouteOptions = {},
434
+ ): Promise<FastifyInstance> {
435
+ const stream = new Writable({
436
+ write(chunk: Buffer, _encoding, callback) {
437
+ logLines.push(chunk.toString());
438
+ callback();
439
+ },
440
+ });
441
+ const app = Fastify({ logger: { level: "trace", stream } });
442
+ await app.register(offeringsRoute, options);
443
+ await app.register(pricingRoute, options);
444
+ await app.register(shownOfferings.shownOfferingsRoute, options);
445
+ apps.push(app);
446
+ return app;
447
+ }
448
+
449
+ async function get(app: FastifyInstance, url = "/v1/content/offerings") {
450
+ const response = await app.inject({ method: "GET", url });
451
+ return { response, body: JSON.parse(response.body) as Row };
452
+ }
453
+
454
+ function resolvedTo(workspaceId: string): void {
455
+ resolveWorkspace.mockResolvedValue({ kind: "resolved", workspaceId });
456
+ }
457
+
458
+ /** Today's content answer — what every fallback must equal. */
459
+ async function contentAnswer(type?: "service" | "product") {
460
+ return JSON.parse(
461
+ JSON.stringify(
462
+ await listOfferings(getContentAdapter(), { type, locale: "en" }),
463
+ ),
464
+ ) as unknown;
465
+ }
466
+
467
+ /** The request log's lines whose `outcome` is `outcome`. */
468
+ function linesWithOutcome(outcome: string): Row[] {
469
+ return logLines
470
+ .map((line) => JSON.parse(line) as Row)
471
+ .filter((line) => line.outcome === outcome);
472
+ }
473
+
474
+ function capturedLogText(): string {
475
+ return [
476
+ ...logLines,
477
+ ...loggerCalls.map((call) => JSON.stringify(call)),
478
+ ].join("\n");
479
+ }
480
+
481
+ /** A `sourceFor` that answers the fixture catalog and counts each source call. */
482
+ function countingSourceFor(
483
+ answer: () => Promise<typeof OFFERINGS_FIXTURE_CATALOG> = async () =>
484
+ structuredClone(OFFERINGS_FIXTURE_CATALOG),
485
+ ) {
486
+ const calls: string[] = [];
487
+ const sourceFor: offeringsServices.OfferingsSourceFor = (
488
+ organizationId,
489
+ ) => ({
490
+ listActiveCatalog: () => {
491
+ calls.push(organizationId);
492
+ return answer();
493
+ },
494
+ });
495
+ return { sourceFor, calls };
496
+ }
497
+
498
+ /** Waits, a macrotask at a time, until `done()` holds (at most a second). */
499
+ async function until(done: () => boolean): Promise<void> {
500
+ for (let i = 0; i < 200 && !done(); i += 1) {
501
+ await new Promise((resolve) => setTimeout(resolve, 5));
502
+ }
503
+ }
504
+
505
+ beforeEach(async () => {
506
+ for (const key of Object.keys(db.__db)) delete db.__db[key];
507
+ offeringsServices.resetOfferingsRefreshStateForTests();
508
+ await seedOfferingsRows(prisma, { now: FIXTURE_NOW });
509
+ await seedOfferings();
510
+ logLines = [];
511
+ loggerCalls = [];
512
+ mockMappingThrows.on = false;
513
+ for (const level of ["error", "warn", "info", "debug"] as const) {
514
+ jest.spyOn(logger, level).mockImplementation(((...args: unknown[]) => {
515
+ loggerCalls.push([level, ...args]);
516
+ }) as never);
517
+ }
518
+ resolveWorkspace = jest.spyOn(publicSiteResolver, "resolvePublicWorkspace");
519
+ resolvedTo(FIXTURE_WS);
520
+ resolveClaim = jest
521
+ .spyOn(commerceClaim, "resolveCommerceClaim")
522
+ .mockResolvedValue("claimed");
523
+ });
524
+
525
+ afterEach(async () => {
526
+ // AC-5 (T6): across every case, no log line carries Stripe text.
527
+ const text = capturedLogText();
528
+ for (const fragment of FIXTURE_TEXT) expect(text).not.toContain(fragment);
529
+ mockMappingThrows.on = false;
530
+ await Promise.all(apps.map((app) => app.close()));
531
+ apps = [];
532
+ jest.restoreAllMocks();
533
+ });
534
+
535
+ describe("a connected workspace answers its shown set (AC-1)", () => {
536
+ it("with no query: the Service pages, then the shown Products in place of the Product pages", async () => {
537
+ const app = await buildApp({ now: () => FIXTURE_NOW });
538
+ const { response, body } = await get(app);
539
+ expect(response.statusCode).toBe(200);
540
+ const services = (
541
+ (await contentAnswer("service")) as { offerings: Row[] }
542
+ ).offerings;
543
+ expect(services.map((o) => o.name)).toContain("Launch Service");
544
+ expect(body).toEqual({
545
+ ok: true,
546
+ data: { offerings: [...services, ...EXPECTED_SHOWN] },
547
+ });
548
+ expect(JSON.stringify(body)).not.toContain("The flagship marketing template.");
549
+ });
550
+
551
+ it("?type=product answers the shown Products alone", async () => {
552
+ const app = await buildApp({ now: () => FIXTURE_NOW });
553
+ const { body } = await get(app, "/v1/content/offerings?type=product");
554
+ expect(body).toEqual({ ok: true, data: { offerings: EXPECTED_SHOWN } });
555
+ for (const offering of EXPECTED_SHOWN) {
556
+ expect(offering).not.toHaveProperty("category");
557
+ expect(offering).not.toHaveProperty("image");
558
+ }
559
+ });
560
+
561
+ it("deep-equals listOfferingsWithShownSet over the shared read's answer", async () => {
562
+ const app = await buildApp({ now: () => FIXTURE_NOW });
563
+ const read = await get(app, "/v1/content/shown-offerings");
564
+ const shownSet = currentShownSet(
565
+ shownOfferingsAnswerSchema.parse(read.body.data),
566
+ );
567
+ expect(shownSet).not.toBeNull();
568
+
569
+ const { body } = await get(app, "/v1/content/offerings?locale=en");
570
+ const expected = await listOfferingsWithShownSet(
571
+ getContentAdapter(),
572
+ { locale: "en" },
573
+ shownSet!,
574
+ );
575
+ expect(body.data).toEqual(JSON.parse(JSON.stringify(expected)));
576
+ });
577
+
578
+ it("?type=service answers today's listOfferings, reading no shown set", async () => {
579
+ const read = jest.spyOn(shownOfferings, "readShownOfferings");
580
+ const app = await buildApp({ now: () => FIXTURE_NOW });
581
+ const { body } = await get(app, "/v1/content/offerings?type=service");
582
+ expect(body.data).toEqual(await contentAnswer("service"));
583
+ expect(read).not.toHaveBeenCalled();
584
+ });
585
+
586
+ it("reads the snapshot through readShownOfferings, with the route's options", async () => {
587
+ const read = jest.spyOn(shownOfferings, "readShownOfferings");
588
+ const options = { now: () => FIXTURE_NOW };
589
+ const app = await buildApp(options);
590
+ await get(app);
591
+ expect(read).toHaveBeenCalledTimes(1);
592
+ expect(read.mock.calls[0]![1]).toBe(options);
593
+ });
594
+ });
595
+
596
+ describe("every other case answers page content exactly as before (AC-3)", () => {
597
+ it.each([
598
+ ["the Organization's second workspace (no selection)", FIXTURE_WS_2],
599
+ ["the second Organization's workspace (no connection)", FIXTURE_ORG_2_WS],
600
+ ])("%s", async (_name, workspaceId) => {
601
+ resolvedTo(workspaceId);
602
+ const app = await buildApp({ now: () => FIXTURE_NOW });
603
+ const { response, body } = await get(app);
604
+ expect(response.statusCode).toBe(200);
605
+ expect(body.data).toEqual(await contentAnswer());
606
+ });
607
+
608
+ it("an expired snapshot (25 hours old)", async () => {
609
+ const app = await buildApp({ now: () => at(25 * HOUR) });
610
+ const { response, body } = await get(app, "/v1/content/offerings?type=product");
611
+ expect(response.statusCode).toBe(200);
612
+ expect(body.data).toEqual(await contentAnswer("product"));
613
+ });
614
+
615
+ it.each(["unclaimed", "undetermined"] as const)(
616
+ "a %s licence",
617
+ async (claim) => {
618
+ resolveClaim.mockResolvedValue(claim);
619
+ const app = await buildApp({ now: () => FIXTURE_NOW });
620
+ const { response, body } = await get(app);
621
+ expect(response.statusCode).toBe(200);
622
+ expect(body.data).toEqual(await contentAnswer());
623
+ },
624
+ );
625
+
626
+ it("a site with no workspace to resolve (not-required)", async () => {
627
+ resolveWorkspace.mockRestore();
628
+ const read = jest.spyOn(shownOfferings, "readShownOfferings");
629
+ const app = await buildApp({ now: () => FIXTURE_NOW });
630
+ const { response, body } = await get(app);
631
+ expect(response.statusCode).toBe(200);
632
+ expect(body.data).toEqual(await contentAnswer());
633
+ // The content answer came from the read's own `unconnected` arm, not
634
+ // from a failure that would also have landed on page content.
635
+ await expect(read.mock.results[0]!.value).resolves.toEqual({
636
+ status: "unconnected",
637
+ });
638
+ expect(linesWithOutcome("shown_set_failed")).toEqual([]);
639
+ });
640
+
641
+ it("a shown-set read that rejects logs one fixed warn and answers content, never a 500", async () => {
642
+ jest
643
+ .spyOn(shownOfferings, "readShownOfferings")
644
+ .mockRejectedValue(new Error("read refused One site, set up once."));
645
+ const app = await buildApp({ now: () => FIXTURE_NOW });
646
+ const { response, body } = await get(app);
647
+ expect(response.statusCode).toBe(200);
648
+ expect(body.data).toEqual(await contentAnswer());
649
+ expect(linesWithOutcome("shown_set_failed")).toEqual([
650
+ expect.objectContaining({ level: 40, errName: "Error" }),
651
+ ]);
652
+ expect(capturedLogText()).not.toContain("read refused");
653
+ });
654
+
655
+ it("a mapping that throws answers content, never a 500", async () => {
656
+ mockMappingThrows.on = true;
657
+ const app = await buildApp({ now: () => FIXTURE_NOW });
658
+ const { response, body } = await get(app);
659
+ expect(response.statusCode).toBe(200);
660
+ expect(body.data).toEqual(await contentAnswer());
661
+ expect(linesWithOutcome("shown_set_failed")).toHaveLength(1);
662
+ expect(capturedLogText()).not.toContain("mapping refused");
663
+ });
664
+ });
665
+
666
+ describe("no request calls Stripe itself (AC-4, T7)", () => {
667
+ it("a snapshot 30 seconds old answers five sequential requests with no source call", async () => {
668
+ const refresh = jest.spyOn(offeringsServices, "refreshWorkspaceSnapshot");
669
+ const { sourceFor, calls } = countingSourceFor();
670
+ const app = await buildApp({ sourceFor, now: () => at(30 * SECOND) });
671
+ for (let i = 0; i < 5; i += 1) {
672
+ const { body } = await get(app, "/v1/content/offerings?type=product");
673
+ expect(body.data).toEqual({ offerings: EXPECTED_SHOWN });
674
+ }
675
+ expect(calls).toEqual([]);
676
+ expect(refresh).not.toHaveBeenCalled();
677
+ });
678
+
679
+ it("five concurrent stale requests across the three reads make one source call between them", async () => {
680
+ const refresh = jest.spyOn(offeringsServices, "refreshWorkspaceSnapshot");
681
+ let release!: () => void;
682
+ const held = new Promise<void>((resolve) => (release = resolve));
683
+ const { sourceFor, calls } = countingSourceFor(async () => {
684
+ await held;
685
+ return structuredClone(OFFERINGS_FIXTURE_CATALOG);
686
+ });
687
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
688
+
689
+ const pending = Promise.all(
690
+ [
691
+ "/v1/content/offerings",
692
+ "/v1/content/pricing",
693
+ "/v1/content/shown-offerings",
694
+ "/v1/content/offerings",
695
+ "/v1/content/pricing",
696
+ ].map((url) => get(app, url)),
697
+ );
698
+ await until(() => refresh.mock.calls.length === 5);
699
+ release();
700
+ const answers = await pending;
701
+
702
+ expect(calls).toEqual([FIXTURE_ORG]);
703
+ for (const { response } of answers) expect(response.statusCode).toBe(200);
704
+ expect(refresh).toHaveBeenCalledTimes(5);
705
+ for (const call of refresh.mock.calls) {
706
+ expect(call[2]).not.toHaveProperty("force");
707
+ }
708
+ });
709
+
710
+ it("registered with no options, a stale snapshot is served and never refreshed", async () => {
711
+ const refresh = jest.spyOn(offeringsServices, "refreshWorkspaceSnapshot");
712
+ const app = await buildApp({ now: () => at(61 * SECOND) });
713
+ const { body } = await get(app, "/v1/content/offerings?type=product");
714
+ expect(body.data).toEqual({ offerings: EXPECTED_SHOWN });
715
+ expect(refresh).not.toHaveBeenCalled();
716
+ });
717
+
718
+ it("a refresh that rejects with Stripe text logs none of it", async () => {
719
+ const keyBody = "y".repeat(24);
720
+ const { sourceFor } = countingSourceFor(async () => {
721
+ throw new Error(`Growth refused for rk_test_${keyBody}`);
722
+ });
723
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
724
+ const { response } = await get(app);
725
+ expect(response.statusCode).toBe(200);
726
+ const text = capturedLogText();
727
+ expect(text).not.toContain("rk_test_");
728
+ expect(text).not.toContain(keyBody);
729
+ });
730
+ });
731
+ });