create-ailk 0.5.0 → 0.6.1
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 +55 -8
- package/component-catalog.json +336 -27
- package/component-catalog.md +25 -12
- package/dist/cli.js +54 -5
- package/dist/copy.d.ts +14 -0
- package/dist/copy.js +38 -1
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -1
- package/dist/lib/extract-module-bundle.js +64 -5
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/lib/module-license-gate.d.ts +6 -1
- package/dist/lib/module-license-gate.js +6 -1
- package/dist/lib/pro-callout.js +1 -1
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/programmatic.js +14 -10
- package/dist/surfaces.js +105 -7
- package/dist/template-manifest.d.ts +33 -0
- package/dist/template-manifest.js +99 -16
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +3 -2
- package/site-templates/marketing-site/DESIGN.md +148 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
- package/site-templates/marketing-site/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/DESIGN.md +148 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
- package/site-templates/minimalism-site/DESIGN.md +148 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
- package/site-templates/minimalism-site/ailk-template.json +2 -1
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
- package/site-templates/saas-product/DESIGN.md +148 -0
- package/site-templates/simple-service/DESIGN.md +148 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/CLAUDE.md +2 -3
- package/templates/DESIGN.md +155 -0
- package/templates/README.md +42 -8
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +26 -2
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +439 -49
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +70 -31
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +13 -14
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +23 -2
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +57 -24
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +2 -0
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +59 -35
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +6 -4
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +71 -5
- package/templates/apps/web/CLAUDE.md +44 -0
- package/templates/apps/web/PRODUCT.md +86 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/layout.tsx +133 -87
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/jest.config.cjs +8 -4
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -8
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/middleware.ts +1 -1
- package/templates/apps/web/package.json +2 -1
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
- package/templates/content/_site.mdx +18 -3
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +3 -2
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- package/templates/apps/web/public/logo-dark.svg +0 -21
- package/templates/apps/web/public/logo-light.svg +0 -21
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file dunning-policy.test.ts
|
|
3
|
+
* @description Unit tests for the per-tenant dunning grace policy (#6034
|
|
4
|
+
* §§ D4, D5) — the § D4 resolution table row by row, the key form, the
|
|
5
|
+
* rejecting query, and both exported predicates across every recorded
|
|
6
|
+
* state.
|
|
7
|
+
*
|
|
8
|
+
* External dependency boundaries mocked:
|
|
9
|
+
* - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
|
|
10
|
+
* - @working-theory/observability logger
|
|
11
|
+
*
|
|
12
|
+
* `isModuleEnabled` reads `process.env.AILK_ENABLED_MODULES` directly — the
|
|
13
|
+
* REAL env boundary, set/restored per `lead-promotion.test.ts`'s convention,
|
|
14
|
+
* rather than a `jest.mock` of the internal `../../config/modules.js` module
|
|
15
|
+
* (CONTRIBUTING.md § Test code quality forbids mocking an internal module).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
19
|
+
jest.mock("../../lib/prisma.js");
|
|
20
|
+
|
|
21
|
+
const mockLoggerInfo = jest.fn();
|
|
22
|
+
const mockLoggerWarn = jest.fn();
|
|
23
|
+
const mockLoggerError = jest.fn();
|
|
24
|
+
jest.mock("@working-theory/observability", () => ({
|
|
25
|
+
logger: {
|
|
26
|
+
info: (...args: unknown[]) => mockLoggerInfo(...args),
|
|
27
|
+
warn: (...args: unknown[]) => mockLoggerWarn(...args),
|
|
28
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
29
|
+
debug: jest.fn(),
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
mockNoWorkspace,
|
|
35
|
+
mockSentinelWorkspaceExists,
|
|
36
|
+
mockWorkspaceExists,
|
|
37
|
+
SENTINEL_WORKSPACE_ID,
|
|
38
|
+
} from "../../lib/__mocks__/prisma.js";
|
|
39
|
+
import { prisma } from "../../lib/prisma.js";
|
|
40
|
+
import {
|
|
41
|
+
DUNNING_GRACE_DAYS_MAX,
|
|
42
|
+
DUNNING_POLICY_DEFAULTS,
|
|
43
|
+
dunningPolicyFor,
|
|
44
|
+
isSubscriberActionRequired,
|
|
45
|
+
isSubscriberRevoked,
|
|
46
|
+
type DunningEntry,
|
|
47
|
+
} from "../dunning-policy.js";
|
|
48
|
+
|
|
49
|
+
const SITE_ID = "site-abc";
|
|
50
|
+
const EXPECTED_KEY = `commerce.dunning_policy:${SITE_ID}`;
|
|
51
|
+
|
|
52
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks();
|
|
56
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
57
|
+
// #6335 — the shared prisma mock no longer resolves a workspace for any id
|
|
58
|
+
// asked of it, so this suite states the one it expects. Every case below
|
|
59
|
+
// reaches `dunningPolicyFor(SITE_ID)`, which resolves the alias before it
|
|
60
|
+
// reads `site_settings`; without this the resolution returns null and the
|
|
61
|
+
// read never happens, so the § D4 table would assert nothing.
|
|
62
|
+
mockWorkspaceExists(prisma, SITE_ID);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(() => {
|
|
66
|
+
if (priorEnabledModules === undefined) {
|
|
67
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
68
|
+
} else {
|
|
69
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── § D4's resolution table, row by row ────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("dunningPolicyFor — the resolution table (§ D4)", () => {
|
|
76
|
+
it("no row → the platform default, logged info 'no_policy_row'", async () => {
|
|
77
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue(null);
|
|
78
|
+
|
|
79
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
80
|
+
|
|
81
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
82
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ outcome: "no_policy_row", key: EXPECTED_KEY }),
|
|
84
|
+
expect.any(String),
|
|
85
|
+
);
|
|
86
|
+
expect(mockLoggerError).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
["a string", "not-json"],
|
|
91
|
+
["an array", [1, 2, 3]],
|
|
92
|
+
["a number", 5],
|
|
93
|
+
["null", null],
|
|
94
|
+
])(
|
|
95
|
+
"stored value is %s (not a JSON object) → default, error 'malformed'",
|
|
96
|
+
async (_label, value) => {
|
|
97
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({ value });
|
|
98
|
+
|
|
99
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
100
|
+
|
|
101
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
102
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
103
|
+
expect.objectContaining({ outcome: "malformed" }),
|
|
104
|
+
expect.any(String),
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it("graceDays absent → default, error 'malformed'", async () => {
|
|
110
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
111
|
+
value: {},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
115
|
+
|
|
116
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
117
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
118
|
+
expect.objectContaining({ outcome: "malformed" }),
|
|
119
|
+
expect.any(String),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it.each([
|
|
124
|
+
["a string", "14"],
|
|
125
|
+
["null", null],
|
|
126
|
+
["a bool", true],
|
|
127
|
+
["an array", [14]],
|
|
128
|
+
["an object", { days: 14 }],
|
|
129
|
+
])(
|
|
130
|
+
"graceDays is %s → default, error 'malformed'",
|
|
131
|
+
async (_label, graceDays) => {
|
|
132
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
133
|
+
value: { graceDays },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
137
|
+
|
|
138
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
139
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
140
|
+
expect.objectContaining({ outcome: "malformed" }),
|
|
141
|
+
expect.any(String),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
it("graceDays is a magnitude JSON can hold but JS cannot (1e999 → Infinity) → default, error 'non_finite'", async () => {
|
|
147
|
+
// `1e999` is the real-world value (a magnitude `JSON.parse` overflows to
|
|
148
|
+
// `Infinity`); `Number.POSITIVE_INFINITY` is used directly here so the
|
|
149
|
+
// literal itself doesn't trip `no-loss-of-precision` — both reach the
|
|
150
|
+
// exact same `Number.isFinite()` guard.
|
|
151
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
152
|
+
value: { graceDays: Number.POSITIVE_INFINITY },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
156
|
+
|
|
157
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
158
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
159
|
+
expect.objectContaining({ outcome: "non_finite" }),
|
|
160
|
+
expect.any(String),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("graceDays negative and finite → clamped to 0, error 'clamped_low'", async () => {
|
|
165
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
166
|
+
value: { graceDays: -5 },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
170
|
+
|
|
171
|
+
expect(policy).toEqual({ graceDays: 0 });
|
|
172
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
173
|
+
expect.objectContaining({ outcome: "clamped_low", stored: -5 }),
|
|
174
|
+
expect.any(String),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("graceDays finite and > the ceiling → clamped to the ceiling, error 'clamped_high'", async () => {
|
|
179
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
180
|
+
value: { graceDays: 365 },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
184
|
+
|
|
185
|
+
expect(policy).toEqual({ graceDays: DUNNING_GRACE_DAYS_MAX });
|
|
186
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
187
|
+
expect.objectContaining({ outcome: "clamped_high", stored: 365 }),
|
|
188
|
+
expect.any(String),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("graceDays non-integer (e.g. 7.5) → floored, warn 'floored'", async () => {
|
|
193
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
194
|
+
value: { graceDays: 7.5 },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
198
|
+
|
|
199
|
+
expect(policy).toEqual({ graceDays: 7 });
|
|
200
|
+
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({ outcome: "floored", stored: 7.5 }),
|
|
202
|
+
expect.any(String),
|
|
203
|
+
);
|
|
204
|
+
expect(mockLoggerError).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("the query itself throws → default, error 'read_failed'", async () => {
|
|
208
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockRejectedValue(
|
|
209
|
+
new Error("connection reset"),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
213
|
+
|
|
214
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
215
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({ outcome: "read_failed" }),
|
|
217
|
+
expect.any(String),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("a valid integer in [0, DUNNING_GRACE_DAYS_MAX] → that value, no log", async () => {
|
|
222
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
223
|
+
value: { graceDays: 21 },
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const policy = await dunningPolicyFor(SITE_ID);
|
|
227
|
+
|
|
228
|
+
expect(policy).toEqual({ graceDays: 21 });
|
|
229
|
+
expect(mockLoggerInfo).not.toHaveBeenCalled();
|
|
230
|
+
expect(mockLoggerWarn).not.toHaveBeenCalled();
|
|
231
|
+
expect(mockLoggerError).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── The key form ────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("dunningPolicyFor — the key form", () => {
|
|
238
|
+
it("queries exactly commerce.dunning_policy:<siteId>, never an unsuffixed key", async () => {
|
|
239
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
240
|
+
value: { graceDays: 14 },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await dunningPolicyFor(SITE_ID);
|
|
244
|
+
|
|
245
|
+
expect(prisma.siteSetting.findUnique).toHaveBeenCalledTimes(1);
|
|
246
|
+
const call = (prisma.siteSetting.findUnique as jest.Mock).mock.calls[0][0];
|
|
247
|
+
// #6374 D3 — `site_settings` is now unique on (workspace_id, key), so
|
|
248
|
+
// the selector is the COMPOUND `workspaceId_key`, not a bare `key`.
|
|
249
|
+
expect(call).toEqual({
|
|
250
|
+
where: { workspaceId_key: { workspaceId: SITE_ID, key: EXPECTED_KEY } },
|
|
251
|
+
select: { value: true },
|
|
252
|
+
});
|
|
253
|
+
expect(call.where.workspaceId_key.key).not.toBe("commerce.dunning_policy");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── #6374 AC-7 — the key is built from the UNRESOLVED siteId ─────────────
|
|
257
|
+
it("builds `key` from the UNRESOLVED siteId parameter, even when the alias resolves to something else", async () => {
|
|
258
|
+
// The alias `"default"` resolves to the sentinel workspace id. The KEY
|
|
259
|
+
// must still read `commerce.dunning_policy:default` — the alias is
|
|
260
|
+
// embedded in the key STRING because `site_settings` never had a
|
|
261
|
+
// `site_id` column, and the migration's backfill leaves every key
|
|
262
|
+
// verbatim. Rebuilding the key from the RESOLVED id would match no row
|
|
263
|
+
// and hand every seller the built-in default policy, silently. Only the
|
|
264
|
+
// WORKSPACE half is resolved.
|
|
265
|
+
mockSentinelWorkspaceExists(prisma);
|
|
266
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
267
|
+
value: { graceDays: 14 },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await dunningPolicyFor("default");
|
|
271
|
+
|
|
272
|
+
const call = (prisma.siteSetting.findUnique as jest.Mock).mock.calls[0][0];
|
|
273
|
+
expect(call.where.workspaceId_key.key).toBe(
|
|
274
|
+
"commerce.dunning_policy:default",
|
|
275
|
+
);
|
|
276
|
+
expect(call.where.workspaceId_key.workspaceId).toBe(SENTINEL_WORKSPACE_ID);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("an alias that resolves to no workspace returns the platform default and reads nothing", async () => {
|
|
280
|
+
mockNoWorkspace(prisma);
|
|
281
|
+
|
|
282
|
+
const policy = await dunningPolicyFor("no-such-site");
|
|
283
|
+
|
|
284
|
+
expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
|
|
285
|
+
expect(prisma.siteSetting.findUnique).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("multi-tenant deployments log the deployment-wide-policy warning once per process", async () => {
|
|
289
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
290
|
+
(prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
|
|
291
|
+
value: { graceDays: 14 },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await dunningPolicyFor(SITE_ID);
|
|
295
|
+
await dunningPolicyFor(SITE_ID);
|
|
296
|
+
|
|
297
|
+
const deploymentWideCalls = mockLoggerWarn.mock.calls.filter(
|
|
298
|
+
([fields]) => fields?.outcome === "deployment_wide_policy",
|
|
299
|
+
);
|
|
300
|
+
expect(deploymentWideCalls).toHaveLength(1);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── The two exported predicates, across every recorded state ───────────────
|
|
305
|
+
|
|
306
|
+
function entry(overrides: Partial<DunningEntry> = {}): DunningEntry {
|
|
307
|
+
return {
|
|
308
|
+
status: "active",
|
|
309
|
+
accessState: "granted",
|
|
310
|
+
dunningArmedAt: null,
|
|
311
|
+
accessRevokesAt: null,
|
|
312
|
+
lastEventAt: 1_700_000_000,
|
|
313
|
+
lastEventId: "evt_base",
|
|
314
|
+
...overrides,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const NOW = Date.parse("2026-09-18T00:00:00.000Z");
|
|
319
|
+
|
|
320
|
+
describe("isSubscriberRevoked / isSubscriberActionRequired — every recorded state", () => {
|
|
321
|
+
it("active — not revoked, no action required", () => {
|
|
322
|
+
const e = entry({ status: "active", accessState: "granted" });
|
|
323
|
+
expect(isSubscriberRevoked(e, NOW)).toBe(false);
|
|
324
|
+
expect(isSubscriberActionRequired(e, NOW)).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("past_due — not revoked, action required", () => {
|
|
328
|
+
const e = entry({
|
|
329
|
+
status: "past_due",
|
|
330
|
+
accessState: "granted",
|
|
331
|
+
dunningArmedAt: "2026-09-10T00:00:00.000Z",
|
|
332
|
+
});
|
|
333
|
+
expect(isSubscriberRevoked(e, NOW)).toBe(false);
|
|
334
|
+
expect(isSubscriberActionRequired(e, NOW)).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("in-grace canceled — not yet revoked, but action IS required (§ 9 SEC-09)", () => {
|
|
338
|
+
const e = entry({
|
|
339
|
+
status: "canceled",
|
|
340
|
+
accessState: "granted",
|
|
341
|
+
dunningArmedAt: "2026-09-10T00:00:00.000Z",
|
|
342
|
+
accessRevokesAt: "2026-09-24T00:00:00.000Z", // in the future relative to NOW
|
|
343
|
+
});
|
|
344
|
+
expect(isSubscriberRevoked(e, NOW)).toBe(false);
|
|
345
|
+
expect(isSubscriberActionRequired(e, NOW)).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("expired canceled — revoked, no further action predicate (already gone)", () => {
|
|
349
|
+
const e = entry({
|
|
350
|
+
status: "canceled",
|
|
351
|
+
accessState: "revoked",
|
|
352
|
+
dunningArmedAt: "2026-08-01T00:00:00.000Z",
|
|
353
|
+
accessRevokesAt: "2026-08-15T00:00:00.000Z", // in the past relative to NOW
|
|
354
|
+
});
|
|
355
|
+
expect(isSubscriberRevoked(e, NOW)).toBe(true);
|
|
356
|
+
expect(isSubscriberActionRequired(e, NOW)).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("accessRevokesAt exactly at `now` is revoked (>=, not >)", () => {
|
|
360
|
+
const revokesAt = "2026-09-18T00:00:00.000Z";
|
|
361
|
+
const e = entry({
|
|
362
|
+
status: "canceled",
|
|
363
|
+
accessState: "granted",
|
|
364
|
+
accessRevokesAt: revokesAt,
|
|
365
|
+
});
|
|
366
|
+
expect(isSubscriberRevoked(e, Date.parse(revokesAt))).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -392,4 +392,164 @@ describe("routeLead", () => {
|
|
|
392
392
|
expect(result.error.kind).toBe("invalid_input");
|
|
393
393
|
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
394
394
|
});
|
|
395
|
+
|
|
396
|
+
// ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
|
|
397
|
+
//
|
|
398
|
+
// routeLead(kind: "submit") has FOUR callers in this repo, and as of
|
|
399
|
+
// #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
|
|
400
|
+
// before calling in — the three that once passed an unresolved alias
|
|
401
|
+
// (flow-engine.ts, recommender-capture.ts, project-listings.ts's
|
|
402
|
+
// submitProjectListingDraft) now resolve it themselves
|
|
403
|
+
// (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
|
|
404
|
+
// `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
|
|
405
|
+
// database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
|
|
406
|
+
// no longer stamp `null` for an unresolved alias the way #6203 once did —
|
|
407
|
+
// it copies `siteId` unconditionally, and a caller that still somehow
|
|
408
|
+
// passed an unresolved alias hits the pre-existing
|
|
409
|
+
// `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
|
|
410
|
+
// file's other describe block proves) instead of landing a
|
|
411
|
+
// silently-unfenced row.
|
|
412
|
+
describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
|
|
413
|
+
it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
|
|
414
|
+
await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
|
|
415
|
+
|
|
416
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
417
|
+
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
418
|
+
expect(data.siteId).toBe("site-abc");
|
|
419
|
+
expect(data.workspaceId).toBe("site-abc");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
|
|
423
|
+
const resolvedInput = {
|
|
424
|
+
...leadInput,
|
|
425
|
+
siteId: "wksp_00000000-0000-7000-8000-000000000001",
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
await routeLead(resolvedInput, sourceMeta);
|
|
429
|
+
|
|
430
|
+
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
431
|
+
expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
432
|
+
expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
|
|
437
|
+
// `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
|
|
438
|
+
// never be swallowed into the file fallback and a silent `ok:true`: it
|
|
439
|
+
// means OUR OWN derivation produced a row Postgres itself refuses, which
|
|
440
|
+
// is a bug to surface, not an outage to degrade through. A GENUINE
|
|
441
|
+
// infrastructure failure (the existing "DB down → file fallback" test
|
|
442
|
+
// above, a plain `Error("connection refused")` that carries none of the
|
|
443
|
+
// signals below) must still take the file fallback, unchanged.
|
|
444
|
+
describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
|
|
445
|
+
const listTmpDirFiles = () => fs.readdir(tmpDir);
|
|
446
|
+
|
|
447
|
+
it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
448
|
+
const before = await listTmpDirFiles();
|
|
449
|
+
const constraintErr = Object.assign(
|
|
450
|
+
new Error("Null constraint violation on the field: (`workspace_id`)"),
|
|
451
|
+
{ code: "P2011" },
|
|
452
|
+
);
|
|
453
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
|
|
454
|
+
|
|
455
|
+
const result = (await routeLead(
|
|
456
|
+
leadInput,
|
|
457
|
+
sourceMeta,
|
|
458
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
459
|
+
|
|
460
|
+
expect(result.ok).toBe(false);
|
|
461
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
462
|
+
// No file was written — the fallback was never attempted.
|
|
463
|
+
const after = await listTmpDirFiles();
|
|
464
|
+
expect(after).toEqual(before);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
468
|
+
const before = await listTmpDirFiles();
|
|
469
|
+
const checkErr = new Error(
|
|
470
|
+
'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
|
|
471
|
+
);
|
|
472
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
|
|
473
|
+
|
|
474
|
+
const result = (await routeLead(
|
|
475
|
+
leadInput,
|
|
476
|
+
sourceMeta,
|
|
477
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
478
|
+
|
|
479
|
+
expect(result.ok).toBe(false);
|
|
480
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
481
|
+
const after = await listTmpDirFiles();
|
|
482
|
+
expect(after).toEqual(before);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
|
|
486
|
+
const before = await listTmpDirFiles();
|
|
487
|
+
const notNullErr = Object.assign(
|
|
488
|
+
new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
|
|
489
|
+
{},
|
|
490
|
+
);
|
|
491
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
|
|
492
|
+
|
|
493
|
+
const result = (await routeLead(
|
|
494
|
+
leadInput,
|
|
495
|
+
sourceMeta,
|
|
496
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
497
|
+
|
|
498
|
+
expect(result.ok).toBe(false);
|
|
499
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
500
|
+
const after = await listTmpDirFiles();
|
|
501
|
+
expect(after).toEqual(before);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
|
|
505
|
+
// Regression guard for the branch above: an ordinary connection error
|
|
506
|
+
// must not be misclassified as a constraint violation.
|
|
507
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
508
|
+
new Error("connection refused"),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const result = (await routeLead(
|
|
512
|
+
leadInput,
|
|
513
|
+
sourceMeta,
|
|
514
|
+
)) as Extract<RouteLeadResult, { ok: true }>;
|
|
515
|
+
|
|
516
|
+
expect(result.ok).toBe(true);
|
|
517
|
+
expect(result.persisted).toBe("file");
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
|
|
526
|
+
* READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
|
|
527
|
+
* the resolved workspace id at every caller (the same guarantee the three
|
|
528
|
+
* `create` sites above stamp on), so the lookup is scoped by it rather than
|
|
529
|
+
* resolving a second time.
|
|
530
|
+
*/
|
|
531
|
+
describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
|
|
532
|
+
beforeEach(() => {
|
|
533
|
+
jest.clearAllMocks();
|
|
534
|
+
process.env.DATABASE_URL = "postgres://test";
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
|
|
538
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
539
|
+
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
|
540
|
+
);
|
|
541
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
|
|
542
|
+
|
|
543
|
+
await routeLead(
|
|
544
|
+
{ ...subscribeInput, email: "dupe@example.com" },
|
|
545
|
+
sourceMeta,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
|
|
549
|
+
expect(where.workspaceId).toBe(subscribeInput.siteId);
|
|
550
|
+
expect(where.siteId_email).toEqual({
|
|
551
|
+
siteId: subscribeInput.siteId,
|
|
552
|
+
email: "dupe@example.com",
|
|
553
|
+
});
|
|
554
|
+
});
|
|
395
555
|
});
|