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.
- package/README.md +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file plan-offerings.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The plan offerings re-sourced from the Offerings shown set (#8934, spec
|
|
5
|
+
* docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D6; AC-1, AC-3,
|
|
6
|
+
* AC-4, AC-5): a connected, claimed workspace lists one item per sellable
|
|
7
|
+
* Price in its shown set, keyed by the Price's full lookup key; an unconnected
|
|
8
|
+
* or unclaimed workspace keeps the page list; a licence or snapshot that
|
|
9
|
+
* cannot be read answers `{ failed }`; and the three consumers accept a
|
|
10
|
+
* listed key with no change to their own code.
|
|
11
|
+
*
|
|
12
|
+
* Runs over `createInMemoryPrisma()` and #8876's `seedOfferings`. External
|
|
13
|
+
* dependency boundaries mocked:
|
|
14
|
+
* - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
|
|
15
|
+
* Its `lead.count` is arranged per test: the purchase email's cap reads
|
|
16
|
+
* with `mode: "insensitive"`, a filter the double does not model.
|
|
17
|
+
* - @working-theory/license — `checkLicense` per case; a keyless storage
|
|
18
|
+
* file (`absent`).
|
|
19
|
+
* - @working-theory/stripe-client — touching any export throws.
|
|
20
|
+
* - @working-theory/email — the operator notification and purchase email.
|
|
21
|
+
* - public-site-resolver's `workspaceContentAdapter` — a fixture adapter
|
|
22
|
+
* stands in for the workspace's content store.
|
|
23
|
+
*/
|
|
24
|
+
import { inspect } from "node:util";
|
|
25
|
+
|
|
26
|
+
// 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.
|
|
27
|
+
jest.mock("../../lib/prisma.js", () =>
|
|
28
|
+
jest
|
|
29
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
30
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
31
|
+
)
|
|
32
|
+
.createInMemoryPrisma(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mockCheckLicense = jest.fn();
|
|
36
|
+
jest.mock("@working-theory/license", () => ({
|
|
37
|
+
checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
|
|
38
|
+
readStoredJwtState: () => Promise.resolve({ state: "absent" }),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock(
|
|
42
|
+
"@working-theory/stripe-client",
|
|
43
|
+
() =>
|
|
44
|
+
new Proxy(
|
|
45
|
+
{},
|
|
46
|
+
{
|
|
47
|
+
get: (_target, key) => {
|
|
48
|
+
if (key === "__esModule") return true;
|
|
49
|
+
throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const mockSendLeadNotification = jest.fn();
|
|
56
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
57
|
+
jest.mock("@working-theory/email", () => ({
|
|
58
|
+
sendLeadNotification: (...args: unknown[]) => mockSendLeadNotification(...args),
|
|
59
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
60
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const mockWorkspaceContentAdapter = jest.fn();
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- the content adapter seam: a fixture adapter stands in for the workspace's content store
|
|
65
|
+
jest.mock("../public-site-resolver.js", () => ({
|
|
66
|
+
...jest.requireActual("../public-site-resolver.js"),
|
|
67
|
+
workspaceContentAdapter: (...args: unknown[]) => mockWorkspaceContentAdapter(...args),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { logger } from "@working-theory/observability";
|
|
71
|
+
import {
|
|
72
|
+
parseLookupKey,
|
|
73
|
+
planOfferingItemSchema,
|
|
74
|
+
planOfferingSchema,
|
|
75
|
+
planOfferingsResponseSchema,
|
|
76
|
+
} from "@working-theory/validation";
|
|
77
|
+
|
|
78
|
+
import { prisma } from "../../lib/prisma.js";
|
|
79
|
+
import { routeLead } from "../lead-routing";
|
|
80
|
+
import { magnetCompletionFor } from "../magnet-completion";
|
|
81
|
+
import {
|
|
82
|
+
FIXTURE_ORG,
|
|
83
|
+
FIXTURE_ORG_2_WS,
|
|
84
|
+
FIXTURE_WS,
|
|
85
|
+
FIXTURE_WS_2,
|
|
86
|
+
OFFERINGS_FIXTURE_CATALOG,
|
|
87
|
+
seedOfferings,
|
|
88
|
+
} from "../offerings/__tests__/fixtures.js";
|
|
89
|
+
import { normalizeCatalog, type CatalogPrice, type CatalogProduct } from "../offerings/index.js";
|
|
90
|
+
import { resolveMemberPersonId } from "../person";
|
|
91
|
+
import { listPlanOfferings } from "../plan-offerings";
|
|
92
|
+
|
|
93
|
+
type Row = Record<string, unknown>;
|
|
94
|
+
// The in-memory double's own surface (`__db`, `__calls`) and its recorded
|
|
95
|
+
// delegates are not on the PrismaClient type the import carries, so it is
|
|
96
|
+
// named here once.
|
|
97
|
+
// eslint-disable-next-line no-restricted-syntax -- justified above
|
|
98
|
+
const store = prisma as unknown as {
|
|
99
|
+
__db: Record<string, Row[]>;
|
|
100
|
+
__calls: Array<{ model: string; op: string }>;
|
|
101
|
+
lead: Record<string, jest.Mock>;
|
|
102
|
+
workspaceOfferings: Record<string, jest.Mock>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const CLAIMED = { tier: "pro", plan: null, features: ["commerce"], expiresAt: null, revoked: false };
|
|
106
|
+
const FREE = { tier: "free", plan: null, features: [], expiresAt: null, revoked: false };
|
|
107
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/** D1's table for the seed. */
|
|
110
|
+
const SHOWN_SET_ITEMS = [
|
|
111
|
+
{ key: "basic_once", name: "Basic (One-time)", type: "plan" },
|
|
112
|
+
{ key: "growth_monthly", name: "Growth (Monthly)", type: "plan" },
|
|
113
|
+
{ key: "growth_yearly", name: "Growth (Annual)", type: "plan" },
|
|
114
|
+
{ key: "seo-audit_monthly", name: "SEO audit (Monthly)", type: "addon" },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// The route suite's fixture pages and their keying (#8514 D9).
|
|
118
|
+
const page = (slug: string, title: string) => ({ slug, title });
|
|
119
|
+
const SERVICES = [
|
|
120
|
+
page("services/retainer", "Monthly retainer"),
|
|
121
|
+
page("services/Bad Key!", "Not a key"),
|
|
122
|
+
page("audit", "Brand audit"),
|
|
123
|
+
];
|
|
124
|
+
const PRODUCTS = [page("products/starter-kit", "Starter kit"), page("retainer", "Duplicate retainer")];
|
|
125
|
+
const PAGE_ITEMS = [
|
|
126
|
+
{ key: "retainer", name: "Monthly retainer", type: "service" },
|
|
127
|
+
{ key: "audit", name: "Brand audit", type: "service" },
|
|
128
|
+
{ key: "starter-kit", name: "Starter kit", type: "product" },
|
|
129
|
+
];
|
|
130
|
+
const fixtureAdapter = () => ({
|
|
131
|
+
listPages: jest.fn(async (type: string) =>
|
|
132
|
+
type === "Service" ? SERVICES : type === "Product" ? PRODUCTS : [],
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const OFFERINGS_MODELS = ["stripeConnection", "organizationOfferings", "workspaceOfferings"];
|
|
137
|
+
const offeringsReads = () => store.__calls.filter((c) => OFFERINGS_MODELS.includes(c.model));
|
|
138
|
+
|
|
139
|
+
const enabledRow = () =>
|
|
140
|
+
store.__db["organizationOfferings"]!.find((r) => r["organizationId"] === FIXTURE_ORG)!;
|
|
141
|
+
const offeringsRow = () =>
|
|
142
|
+
store.__db["workspaceOfferings"]!.find((r) => r["workspaceId"] === FIXTURE_WS)!;
|
|
143
|
+
const interactions = () => (store.__db["interaction"] ??= []);
|
|
144
|
+
const people = () => (store.__db["person"] ??= []);
|
|
145
|
+
|
|
146
|
+
// ─── Logs: no line carries Stripe text (AC-5) ───────────────────────────────
|
|
147
|
+
|
|
148
|
+
const EMOJI_NAME = "\u{1F600}".repeat(200);
|
|
149
|
+
const FORBIDDEN_LOG_TEXT = [
|
|
150
|
+
...OFFERINGS_FIXTURE_CATALOG.products.flatMap((p) => [
|
|
151
|
+
p.name,
|
|
152
|
+
...(p.description ? [p.description] : []),
|
|
153
|
+
...(p.marketing_features ?? []).map((f) => f.name),
|
|
154
|
+
]),
|
|
155
|
+
EMOJI_NAME.slice(0, 20),
|
|
156
|
+
"prod_",
|
|
157
|
+
"price_",
|
|
158
|
+
];
|
|
159
|
+
const LOGGER_METHODS = ["error", "warn", "info", "debug"] as const;
|
|
160
|
+
const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
|
|
161
|
+
let spies: jest.SpyInstance[] = [];
|
|
162
|
+
|
|
163
|
+
function leakedLines(): string[] {
|
|
164
|
+
return spies
|
|
165
|
+
.flatMap((spy) => spy.mock.calls.map((args) => inspect(args, { depth: 8 })))
|
|
166
|
+
.filter((line) => FORBIDDEN_LOG_TEXT.some((text) => line.includes(text)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── The lead route (AC-3) ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const BUYER = "buyer@x.io";
|
|
172
|
+
const U = "user_00000000-0000-7000-8000-000000008934";
|
|
173
|
+
let keySeq = 0;
|
|
174
|
+
const sourceMeta = { ip: "127.0.0.1", userAgent: "jest", receivedAt: new Date().toISOString(), viaSpoke: true };
|
|
175
|
+
|
|
176
|
+
/** A marked spoke checkout lead (#8793's shape) for `offering`, from slug site `wendi`. */
|
|
177
|
+
async function checkoutLead(offering: string): Promise<void> {
|
|
178
|
+
keySeq += 1;
|
|
179
|
+
await routeLead(
|
|
180
|
+
{
|
|
181
|
+
kind: "submit",
|
|
182
|
+
siteId: FIXTURE_WS,
|
|
183
|
+
source: "web",
|
|
184
|
+
email: BUYER,
|
|
185
|
+
payload: { site: "wendi", page: "checkout", offering, amountTotal: 900, currency: "usd" },
|
|
186
|
+
},
|
|
187
|
+
sourceMeta,
|
|
188
|
+
{ idempotencyKey: `cs_live_h8934Plan${keySeq}` }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The newest purchase message's body. */
|
|
193
|
+
const lastPurchaseBody = () => String(interactions().at(-1)?.["body"]);
|
|
194
|
+
const GENERIC = /^Thanks for your purchase\./;
|
|
195
|
+
|
|
196
|
+
const ENV_KEYS = [
|
|
197
|
+
"DATABASE_URL",
|
|
198
|
+
"NODE_ENV",
|
|
199
|
+
"AILK_LICENSE_KEY",
|
|
200
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
201
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
202
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
203
|
+
"AILK_ENABLED_MODULES",
|
|
204
|
+
"STRIPE_ENV_ORGANIZATION_ID",
|
|
205
|
+
] as const;
|
|
206
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
207
|
+
|
|
208
|
+
beforeEach(async () => {
|
|
209
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
210
|
+
saved = {};
|
|
211
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
212
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
213
|
+
// With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
|
|
214
|
+
// environment key (#9285 AC-8), so "no connection" stays no connection here.
|
|
215
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
216
|
+
delete process.env.STRIPE_ENV_ORGANIZATION_ID;
|
|
217
|
+
delete process.env.NODE_ENV;
|
|
218
|
+
delete process.env.AILK_LICENSE_KEY;
|
|
219
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
220
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
221
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
|
|
222
|
+
mockCheckLicense.mockReset().mockResolvedValue(CLAIMED);
|
|
223
|
+
mockWorkspaceContentAdapter.mockReset().mockImplementation(async () => fixtureAdapter());
|
|
224
|
+
mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
|
|
225
|
+
mockSendPurchaseConfirmationEmail.mockReset().mockResolvedValue({ ok: true, messageId: "m2" });
|
|
226
|
+
store.lead["count"]!.mockReset().mockResolvedValue(1);
|
|
227
|
+
await seedOfferings(prisma);
|
|
228
|
+
store.__calls.length = 0;
|
|
229
|
+
spies = [
|
|
230
|
+
...LOGGER_METHODS.map((m) => jest.spyOn(logger, m).mockImplementation(() => undefined as never)),
|
|
231
|
+
...CONSOLE_METHODS.map((m) => jest.spyOn(console, m).mockImplementation(() => undefined)),
|
|
232
|
+
];
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
const leaked = leakedLines();
|
|
237
|
+
for (const spy of spies) spy.mockRestore();
|
|
238
|
+
for (const k of ENV_KEYS) {
|
|
239
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
240
|
+
else process.env[k] = saved[k];
|
|
241
|
+
}
|
|
242
|
+
// AC-5: across every case, no log or console line carries a product name,
|
|
243
|
+
// description, feature string, or a `prod_` or `price_` id.
|
|
244
|
+
expect(leaked).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("a connected workspace lists its shown set's lookup keys, and only those (AC-1)", () => {
|
|
248
|
+
it("one item per sellable Price, keyed by its full lookup key, with no page and no Stripe call", async () => {
|
|
249
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
250
|
+
expect(offerings).toEqual(SHOWN_SET_ITEMS);
|
|
251
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
252
|
+
for (const { key } of offerings) {
|
|
253
|
+
expect(parseLookupKey(key)).not.toBeNull();
|
|
254
|
+
expect(planOfferingSchema.safeParse(key).success).toBe(true);
|
|
255
|
+
}
|
|
256
|
+
expect(() => planOfferingsResponseSchema.parse({ offerings })).not.toThrow();
|
|
257
|
+
expect(mockWorkspaceContentAdapter).not.toHaveBeenCalled();
|
|
258
|
+
// The bidi add-on is in the catalog but neither enabled nor shown.
|
|
259
|
+
expect(offerings.map((o) => o.key)).not.toContain("audit-plus_monthly");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("a product switched off in the Organization's enabled set is absent on the next call", async () => {
|
|
263
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
264
|
+
enabledRow()["enabledProductIds"] = (enabledRow()["enabledProductIds"] as string[]).filter(
|
|
265
|
+
(id) => id !== "prod_fixture_seo_audit",
|
|
266
|
+
);
|
|
267
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
268
|
+
SHOWN_SET_ITEMS.filter((item) => item.key !== "seo-audit_monthly"),
|
|
269
|
+
);
|
|
270
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
271
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("a product switched off in the workspace's shown set is absent on the next call", async () => {
|
|
275
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
276
|
+
offeringsRow()["shownProductIds"] = (offeringsRow()["shownProductIds"] as string[]).filter(
|
|
277
|
+
(id) => id !== "prod_fixture_growth",
|
|
278
|
+
);
|
|
279
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
280
|
+
SHOWN_SET_ITEMS.filter((item) => !item.key.startsWith("growth_")),
|
|
281
|
+
);
|
|
282
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
283
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("an empty shown set lists none: a fact, not a failure", async () => {
|
|
287
|
+
offeringsRow()["shownProductIds"] = [];
|
|
288
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("the consumers accept a listed key with no change to their own code (AC-3)", () => {
|
|
293
|
+
it("(a) the purchase message names a listed lookup key, and only a listed one", async () => {
|
|
294
|
+
await checkoutLead("growth_monthly");
|
|
295
|
+
expect(interactions()).toHaveLength(1);
|
|
296
|
+
expect(lastPurchaseBody()).toContain("Growth (Monthly)");
|
|
297
|
+
|
|
298
|
+
await checkoutLead("growth_quarterly");
|
|
299
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
300
|
+
// A page the adapter lists is not a connected workspace's offering.
|
|
301
|
+
await checkoutLead("retainer");
|
|
302
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
303
|
+
expect(interactions()).toHaveLength(3);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("(b) the held plan released at the verified first link is a key the list names", async () => {
|
|
307
|
+
await checkoutLead("growth_monthly");
|
|
308
|
+
(store.__db["user"] ??= []).push({ id: U, email: BUYER, emailVerified: true });
|
|
309
|
+
const personId = await resolveMemberPersonId(FIXTURE_WS, U, BUYER);
|
|
310
|
+
const buyer = people().find((p) => p["id"] === personId)!;
|
|
311
|
+
expect(buyer["planOffering"]).toBe("growth_monthly");
|
|
312
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
313
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
314
|
+
expect(offerings.find((o) => o.key === buyer["planOffering"])?.name).toBe("Growth (Monthly)");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("(c) a checkout magnet completes on an unconnected workspace's page key, and degrades to lead on a connected one", async () => {
|
|
318
|
+
const magnet = (workspaceId: string, checkoutOffering: string): Row => ({
|
|
319
|
+
id: `flow_${workspaceId.slice(-4)}`,
|
|
320
|
+
workspaceId,
|
|
321
|
+
siteId: workspaceId,
|
|
322
|
+
slug: "buy",
|
|
323
|
+
status: "live",
|
|
324
|
+
title: "Buy",
|
|
325
|
+
config: {},
|
|
326
|
+
completion: "checkout",
|
|
327
|
+
checkoutOffering,
|
|
328
|
+
});
|
|
329
|
+
store.__db["flowDefinition"] = [magnet(FIXTURE_ORG_2_WS, "retainer"), magnet(FIXTURE_WS, "growth")];
|
|
330
|
+
const noTarget = () => Promise.resolve(null);
|
|
331
|
+
expect(await magnetCompletionFor(FIXTURE_ORG_2_WS, "buy", noTarget)).toMatchObject({
|
|
332
|
+
next: { completion: "checkout", checkoutOffering: "retainer" },
|
|
333
|
+
});
|
|
334
|
+
expect(await magnetCompletionFor(FIXTURE_WS, "buy", noTarget)).toBeNull();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("(d) a licence or snapshot that cannot be read is { failed }, never a pass", () => {
|
|
338
|
+
const failures: Array<[string, () => Promise<void>, string]> = [
|
|
339
|
+
[
|
|
340
|
+
"an undetermined licence",
|
|
341
|
+
async () => {
|
|
342
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
343
|
+
},
|
|
344
|
+
"LicenseUndetermined",
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
"a snapshot 25 hours old",
|
|
348
|
+
async () => {
|
|
349
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
350
|
+
await seedOfferings(prisma, { now: new Date(Date.now() - 25 * HOUR_MS) });
|
|
351
|
+
},
|
|
352
|
+
"OfferingsSnapshotExpired",
|
|
353
|
+
],
|
|
354
|
+
[
|
|
355
|
+
"an unreadable connection",
|
|
356
|
+
async () => {
|
|
357
|
+
store.__db["stripeConnection"]![0]!["mode"] = "prod";
|
|
358
|
+
},
|
|
359
|
+
"OfferingsSnapshotEmpty",
|
|
360
|
+
],
|
|
361
|
+
[
|
|
362
|
+
"a selection read that rejects",
|
|
363
|
+
async () => {
|
|
364
|
+
store.workspaceOfferings["findUnique"]!.mockRejectedValueOnce(new Error("db down"));
|
|
365
|
+
},
|
|
366
|
+
"Error",
|
|
367
|
+
],
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
it.each(failures)("%s", async (_label, arm, failed) => {
|
|
371
|
+
await arm();
|
|
372
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed });
|
|
373
|
+
await arm();
|
|
374
|
+
await checkoutLead("growth_monthly");
|
|
375
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("an undetermined licence reads no Offerings or workspace row", async () => {
|
|
379
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
380
|
+
store.__calls.length = 0;
|
|
381
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed: "LicenseUndetermined" });
|
|
382
|
+
expect(store.__calls.filter((c) => [...OFFERINGS_MODELS, "workspace"].includes(c.model))).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("an unconnected or unclaimed workspace keeps the page list (AC-4)", () => {
|
|
388
|
+
it.each([
|
|
389
|
+
["a selection with no connection", FIXTURE_ORG_2_WS],
|
|
390
|
+
["a connection with no selection", FIXTURE_WS_2],
|
|
391
|
+
["an unknown workspace", "wksp_01920000-8934-7000-8000-0000000000ff"],
|
|
392
|
+
])("%s answers the page list", async (_label, workspaceId) => {
|
|
393
|
+
expect(await listPlanOfferings(workspaceId)).toEqual(PAGE_ITEMS);
|
|
394
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(workspaceId);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("an unclaimed, connected workspace answers the page list and reads no Offerings row", async () => {
|
|
398
|
+
mockCheckLicense.mockReset().mockResolvedValue(FREE);
|
|
399
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(PAGE_ITEMS);
|
|
400
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(FIXTURE_WS);
|
|
401
|
+
expect(offeringsReads()).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("names and the item schema (AC-5)", () => {
|
|
406
|
+
it("a 200-code-point emoji name keeps its billing word, fits 200 units and splits no surrogate", async () => {
|
|
407
|
+
const product = {
|
|
408
|
+
id: "prod_fixture_emoji",
|
|
409
|
+
active: true,
|
|
410
|
+
name: EMOJI_NAME,
|
|
411
|
+
description: null,
|
|
412
|
+
marketing_features: [],
|
|
413
|
+
unit_label: null,
|
|
414
|
+
metadata: { ailk_group: "plan" },
|
|
415
|
+
} as CatalogProduct;
|
|
416
|
+
const price = {
|
|
417
|
+
id: "price_fixture_emoji_monthly",
|
|
418
|
+
active: true,
|
|
419
|
+
product: "prod_fixture_emoji",
|
|
420
|
+
lookup_key: "emoji_monthly",
|
|
421
|
+
type: "recurring",
|
|
422
|
+
recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
|
|
423
|
+
billing_scheme: "per_unit",
|
|
424
|
+
unit_amount: 100,
|
|
425
|
+
currency: "usd",
|
|
426
|
+
} as CatalogPrice;
|
|
427
|
+
const [emoji] = normalizeCatalog({ products: [product], prices: [price] }).offerings;
|
|
428
|
+
expect(emoji?.name).toBe(EMOJI_NAME);
|
|
429
|
+
enabledRow()["enabledProductIds"] = ["prod_fixture_emoji"];
|
|
430
|
+
offeringsRow()["shownProductIds"] = ["prod_fixture_emoji"];
|
|
431
|
+
offeringsRow()["snapshot"] = { v: 1, offerings: [emoji] };
|
|
432
|
+
|
|
433
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
434
|
+
if (!Array.isArray(offerings)) throw new Error(`expected a list, got ${offerings.failed}`);
|
|
435
|
+
const [item] = offerings;
|
|
436
|
+
expect(item?.key).toBe("emoji_monthly");
|
|
437
|
+
expect(item?.name.endsWith(" (Monthly)")).toBe(true);
|
|
438
|
+
expect(item!.name.length).toBeLessThanOrEqual(200);
|
|
439
|
+
expect(item!.name).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
|
|
440
|
+
expect(planOfferingItemSchema.safeParse(item).success).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("the item schema takes the Offerings groups beside the page types", () => {
|
|
444
|
+
const item = (type: string) => ({ key: "growth_monthly", name: "Growth (Monthly)", type });
|
|
445
|
+
for (const type of ["service", "product", "plan", "addon"]) {
|
|
446
|
+
expect(planOfferingItemSchema.safeParse(item(type)).success).toBe(true);
|
|
447
|
+
}
|
|
448
|
+
expect(planOfferingItemSchema.safeParse(item("offering")).success).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file project-listings.counts.test.ts
|
|
4
|
+
*
|
|
5
|
+
* #9243 — `attachWaitlistCounts` counts waitlist scores with ONE
|
|
6
|
+
* `waitlistScore.groupBy` per call, however many distinct experiment keys the
|
|
7
|
+
* rows carry, and `publicProjectListings` — the anonymous page-render read —
|
|
8
|
+
* bounds its `findMany` with a `take`. Both are the AC evidence for audit HIGH
|
|
9
|
+
* #1 (docs/audits/query-efficiency-audit-2026-10-19.md): the per-key
|
|
10
|
+
* `count()` loop and the unbounded public read.
|
|
11
|
+
*
|
|
12
|
+
* Mock pattern follows `project-listings.fence.test.ts` (jest.fn() doubles on
|
|
13
|
+
* `lib/prisma.js`).
|
|
14
|
+
*/
|
|
15
|
+
import { LISTING_DIRECTORY_MAX_ENTRIES } from "@working-theory/validation";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
listProjectListings,
|
|
19
|
+
publicProjectListings,
|
|
20
|
+
} from "../project-listings.js";
|
|
21
|
+
|
|
22
|
+
// Boundary mocks — `project-listings.ts`'s import chain reaches the Resend
|
|
23
|
+
// client and the licence check; neither is exercised by the reads under test.
|
|
24
|
+
jest.mock("@working-theory/email", () => ({
|
|
25
|
+
sendLeadNotification: jest
|
|
26
|
+
.fn()
|
|
27
|
+
.mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
28
|
+
sendDeliverableEmail: jest
|
|
29
|
+
.fn()
|
|
30
|
+
.mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock("@working-theory/license", () => ({
|
|
34
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
35
|
+
tier: "free",
|
|
36
|
+
plan: null,
|
|
37
|
+
features: [],
|
|
38
|
+
expiresAt: null,
|
|
39
|
+
revoked: false,
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
44
|
+
jest.mock("../../lib/prisma.js", () => ({
|
|
45
|
+
prisma: {
|
|
46
|
+
projectListing: { findMany: jest.fn() },
|
|
47
|
+
siteListingConfig: { findUnique: jest.fn() },
|
|
48
|
+
waitlistScore: { count: jest.fn(), groupBy: jest.fn() },
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
import { prisma } from "../../lib/prisma.js";
|
|
53
|
+
|
|
54
|
+
const SITE = "wksp_00000000-0000-7000-8000-000000000001";
|
|
55
|
+
const EXPERIMENT_A = "ailk-founder-diagnostic";
|
|
56
|
+
const EXPERIMENT_B = "ailk-agency-waitlist";
|
|
57
|
+
const EXPERIMENT_C = "ailk-studio-waitlist";
|
|
58
|
+
|
|
59
|
+
function listingRow(
|
|
60
|
+
id: string,
|
|
61
|
+
experimentKey: string | null,
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
siteId: SITE,
|
|
66
|
+
workspaceId: SITE,
|
|
67
|
+
leadId: null,
|
|
68
|
+
slug: `slug-${id}`,
|
|
69
|
+
name: "Acme",
|
|
70
|
+
headline: null,
|
|
71
|
+
oneLiner: null,
|
|
72
|
+
problem: null,
|
|
73
|
+
solution: null,
|
|
74
|
+
url: null,
|
|
75
|
+
product: null,
|
|
76
|
+
commitment: null,
|
|
77
|
+
teamComposition: null,
|
|
78
|
+
faqs: null,
|
|
79
|
+
anythingElse: null,
|
|
80
|
+
address: null,
|
|
81
|
+
addressLine1: null,
|
|
82
|
+
addressLine2: null,
|
|
83
|
+
addressCity: null,
|
|
84
|
+
addressRegion: null,
|
|
85
|
+
addressPostalCode: null,
|
|
86
|
+
addressCountry: null,
|
|
87
|
+
questions: null,
|
|
88
|
+
siteAnswers: null,
|
|
89
|
+
windowDays: null,
|
|
90
|
+
founderName: null,
|
|
91
|
+
founderFirstName: null,
|
|
92
|
+
founderLastName: null,
|
|
93
|
+
founderEmail: null,
|
|
94
|
+
founderPhone: null,
|
|
95
|
+
founderCompany: null,
|
|
96
|
+
emailVerifiedAt: null,
|
|
97
|
+
logoMarkUrl: null,
|
|
98
|
+
logoWordmarkUrl: null,
|
|
99
|
+
featured: false,
|
|
100
|
+
status: "active",
|
|
101
|
+
consentAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
102
|
+
decidedAt: new Date("2026-09-02T00:00:00.000Z"),
|
|
103
|
+
decidedBy: null,
|
|
104
|
+
issueUrl: null,
|
|
105
|
+
experimentKey,
|
|
106
|
+
windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
|
|
107
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
108
|
+
updatedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The shape `prisma.waitlistScore.groupBy({ by: ["experimentKey"], _count: { _all: true } })` returns. */
|
|
113
|
+
function grouped(
|
|
114
|
+
experimentKey: string,
|
|
115
|
+
count: number,
|
|
116
|
+
): { experimentKey: string; _count: { _all: number } } {
|
|
117
|
+
return { experimentKey, _count: { _all: count } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
jest.clearAllMocks();
|
|
122
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
123
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
|
|
124
|
+
(prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("attachWaitlistCounts — one waitlistScore query per call (#9243 AC-1)", () => {
|
|
128
|
+
it("three distinct experiment keys across four rows → exactly ONE groupBy, no count()", async () => {
|
|
129
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
130
|
+
listingRow("plst_a", EXPERIMENT_A),
|
|
131
|
+
listingRow("plst_b", EXPERIMENT_B),
|
|
132
|
+
listingRow("plst_c", EXPERIMENT_C),
|
|
133
|
+
listingRow("plst_d", EXPERIMENT_A),
|
|
134
|
+
]);
|
|
135
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([
|
|
136
|
+
grouped(EXPERIMENT_A, 5),
|
|
137
|
+
grouped(EXPERIMENT_B, 2),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const rows = await listProjectListings(SITE);
|
|
141
|
+
|
|
142
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
144
|
+
expect(
|
|
145
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0],
|
|
146
|
+
).toEqual({
|
|
147
|
+
by: ["experimentKey"],
|
|
148
|
+
// fence: workspaceId === siteId beside siteId, by value (#6258 AC-6),
|
|
149
|
+
// and every DISTINCT key in one `in` — no per-key fan-out.
|
|
150
|
+
where: {
|
|
151
|
+
siteId: SITE,
|
|
152
|
+
workspaceId: SITE,
|
|
153
|
+
experimentKey: { in: [EXPERIMENT_A, EXPERIMENT_B, EXPERIMENT_C] },
|
|
154
|
+
},
|
|
155
|
+
_count: { _all: true },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const countsById = new Map(rows.map((r) => [r.id, r.waitlistCount]));
|
|
159
|
+
expect(countsById.get("plst_a")).toBe(5);
|
|
160
|
+
expect(countsById.get("plst_d")).toBe(5);
|
|
161
|
+
expect(countsById.get("plst_b")).toBe(2);
|
|
162
|
+
// A key with ZERO score rows is absent from a GROUP BY result; it reads 0,
|
|
163
|
+
// exactly the value the per-key `count()` produced for it.
|
|
164
|
+
expect(countsById.get("plst_c")).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("no listing carries an experimentKey → no waitlistScore read is issued at all", async () => {
|
|
168
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
169
|
+
listingRow("plst_a", null),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const rows = await listProjectListings(SITE);
|
|
173
|
+
|
|
174
|
+
expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
|
|
175
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
176
|
+
expect(rows[0]!.waitlistCount).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("publicProjectListings — the anonymous read is bounded (#9243 AC-2)", () => {
|
|
181
|
+
it("passes a take; more active listings than the bound returns exactly the bound", async () => {
|
|
182
|
+
const overBound = Array.from(
|
|
183
|
+
{ length: LISTING_DIRECTORY_MAX_ENTRIES + 1 },
|
|
184
|
+
(_, i) => listingRow(`plst_${i}`, EXPERIMENT_A),
|
|
185
|
+
);
|
|
186
|
+
// Honour `take` the way the database would, so an unbounded read is the
|
|
187
|
+
// failure this test sees rather than one the double hides.
|
|
188
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
189
|
+
({ take }: { take?: number }) =>
|
|
190
|
+
Promise.resolve(overBound.slice(0, take)),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await publicProjectListings(SITE);
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
(prisma.projectListing.findMany as jest.Mock).mock.calls[0]![0].take,
|
|
197
|
+
).toBe(LISTING_DIRECTORY_MAX_ENTRIES);
|
|
198
|
+
expect(result).toHaveLength(LISTING_DIRECTORY_MAX_ENTRIES);
|
|
199
|
+
// Still one count query for the whole bounded page.
|
|
200
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
});
|