create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/webhooks/stripe — org-scoped billing path (#3601, spec AC2/AC4)
|
|
3
|
-
*
|
|
4
|
-
* Covers the Organization billing-link branch keyed on
|
|
5
|
-
* metadata.organizationId (spec D2): a checkout with organizationId links the
|
|
6
|
-
* Organization (real org AND individual-as-default-org), idempotently; a
|
|
7
|
-
* checkout WITHOUT organizationId takes the User path and never touches the
|
|
8
|
-
* Organization (single-tenant, degrade-clean regression floor).
|
|
9
|
-
*
|
|
10
|
-
* Companion to stripe.test.ts (the User-path tests, which stay unmodified —
|
|
11
|
-
* AC4 regression floor).
|
|
12
|
-
*/
|
|
13
|
-
import type { FastifyInstance } from "fastify";
|
|
14
|
-
import Fastify from "fastify";
|
|
15
|
-
|
|
16
|
-
// ── Mocks (mirror stripe.test.ts) ────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
19
|
-
jest.mock("@working-theory/analytics/server", () => ({
|
|
20
|
-
track: (...args: unknown[]) => mockTrack(...args),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
24
|
-
jest.mock("../../../lib/prisma.js");
|
|
25
|
-
|
|
26
|
-
const mockVerifyWebhookSignature = jest.fn();
|
|
27
|
-
class MockStripeWebhookSignatureError extends Error {
|
|
28
|
-
constructor(message: string) {
|
|
29
|
-
super(message);
|
|
30
|
-
this.name = "StripeWebhookSignatureError";
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
jest.mock("@working-theory/stripe-client", () => ({
|
|
34
|
-
verifyWebhookSignature: (...args: unknown[]) =>
|
|
35
|
-
mockVerifyWebhookSignature(...args),
|
|
36
|
-
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
37
|
-
assertSandboxKey: jest.fn(),
|
|
38
|
-
createCheckoutSession: jest.fn(),
|
|
39
|
-
getStripeClient: jest.fn(),
|
|
40
|
-
STRIPE_API_VERSION: "2024-06-20",
|
|
41
|
-
StripeConfigError: class StripeConfigError extends Error {},
|
|
42
|
-
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
43
|
-
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
import * as configModule from "../../../config/index.js";
|
|
47
|
-
import { prisma } from "../../../lib/prisma.js";
|
|
48
|
-
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
49
|
-
import { stripeWebhookHandler } from "../stripe.js";
|
|
50
|
-
|
|
51
|
-
// The sentinel default Organization id (orgs-of-one billing anchor).
|
|
52
|
-
const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
|
|
53
|
-
const REAL_ORG_ID = "org_00000000-0000-7000-8000-0000000000ab";
|
|
54
|
-
|
|
55
|
-
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
56
|
-
const VALID_SIGNATURE = "t=1234,v1=abc";
|
|
57
|
-
|
|
58
|
-
async function buildTestServer(): Promise<FastifyInstance> {
|
|
59
|
-
const server = Fastify({ logger: false });
|
|
60
|
-
await server.register(async (scope) => {
|
|
61
|
-
scope.addContentTypeParser(
|
|
62
|
-
"application/json",
|
|
63
|
-
{ parseAs: "buffer" },
|
|
64
|
-
(_req, body, done) => done(null, body),
|
|
65
|
-
);
|
|
66
|
-
scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
67
|
-
});
|
|
68
|
-
await server.ready();
|
|
69
|
-
return server;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function checkoutEvent(
|
|
73
|
-
id: string,
|
|
74
|
-
metadata: Record<string, string> | null,
|
|
75
|
-
customer = "cus_test_org_123",
|
|
76
|
-
) {
|
|
77
|
-
return {
|
|
78
|
-
id,
|
|
79
|
-
type: "checkout.session.completed",
|
|
80
|
-
data: {
|
|
81
|
-
object: {
|
|
82
|
-
id: "cs_test_org",
|
|
83
|
-
amount_total: 9900,
|
|
84
|
-
currency: "usd",
|
|
85
|
-
customer,
|
|
86
|
-
customer_details: { email: "org-admin@example.com" },
|
|
87
|
-
metadata,
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function fire(server: FastifyInstance) {
|
|
94
|
-
return server.inject({
|
|
95
|
-
method: "POST",
|
|
96
|
-
url: "/v1/webhooks/stripe",
|
|
97
|
-
payload: VALID_BODY,
|
|
98
|
-
headers: {
|
|
99
|
-
"content-type": "application/json",
|
|
100
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
106
|
-
let server: FastifyInstance;
|
|
107
|
-
|
|
108
|
-
beforeEach(async () => {
|
|
109
|
-
jest.clearAllMocks();
|
|
110
|
-
__resetIdempotencyStore();
|
|
111
|
-
jest.replaceProperty(configModule, "config", {
|
|
112
|
-
...configModule.config,
|
|
113
|
-
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
114
|
-
});
|
|
115
|
-
server = await buildTestServer();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
afterEach(async () => {
|
|
119
|
-
await server.close();
|
|
120
|
-
jest.restoreAllMocks();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// AC2 — a real org checkout links the Organization by id; replay is a no-op.
|
|
124
|
-
it("links the Organization on checkout with metadata.organizationId, idempotent on replay", async () => {
|
|
125
|
-
const customerId = "cus_test_org_123";
|
|
126
|
-
|
|
127
|
-
// First fire: no prior composite link → update the target org.
|
|
128
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
129
|
-
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
130
|
-
id: REAL_ORG_ID,
|
|
131
|
-
billingProvider: "stripe",
|
|
132
|
-
billingCustomerId: customerId,
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
136
|
-
checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
|
|
137
|
-
);
|
|
138
|
-
const r1 = await fire(server);
|
|
139
|
-
expect(r1.statusCode).toBe(200);
|
|
140
|
-
expect(prisma.organization.update).toHaveBeenCalledTimes(1);
|
|
141
|
-
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
142
|
-
expect.objectContaining({
|
|
143
|
-
where: { id: REAL_ORG_ID },
|
|
144
|
-
data: expect.objectContaining({
|
|
145
|
-
billingProvider: "stripe",
|
|
146
|
-
billingCustomerId: customerId,
|
|
147
|
-
}),
|
|
148
|
-
}),
|
|
149
|
-
);
|
|
150
|
-
// The User path is never taken for an org checkout.
|
|
151
|
-
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
152
|
-
|
|
153
|
-
// Replay the SAME event id → idempotency store short-circuits, no 2nd write.
|
|
154
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
155
|
-
checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
|
|
156
|
-
);
|
|
157
|
-
const r2 = await fire(server);
|
|
158
|
-
expect(r2.statusCode).toBe(200);
|
|
159
|
-
expect(prisma.organization.update).toHaveBeenCalledTimes(1);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// AC2 — typed-composite match already pointing at this org → no-op (a fresh
|
|
163
|
-
// event id, but the customer is already linked to the target org).
|
|
164
|
-
it("is a no-op when the org is already linked to this customer (typed-composite match)", async () => {
|
|
165
|
-
const customerId = "cus_already_linked";
|
|
166
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce({
|
|
167
|
-
id: REAL_ORG_ID,
|
|
168
|
-
billingProvider: "stripe",
|
|
169
|
-
billingCustomerId: customerId,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
173
|
-
checkoutEvent("evt_org_002", { organizationId: REAL_ORG_ID }, customerId),
|
|
174
|
-
);
|
|
175
|
-
const r = await fire(server);
|
|
176
|
-
expect(r.statusCode).toBe(200);
|
|
177
|
-
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// AC2 — an individual is billed on their sentinel default Organization
|
|
181
|
-
// (orgs-of-one): the SAME org path, targeting DEFAULT_ORG_ID.
|
|
182
|
-
it("bills an individual on their sentinel default Organization (orgs-of-one)", async () => {
|
|
183
|
-
const customerId = "cus_individual_xyz";
|
|
184
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
185
|
-
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
186
|
-
id: DEFAULT_ORG_ID,
|
|
187
|
-
billingProvider: "stripe",
|
|
188
|
-
billingCustomerId: customerId,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
192
|
-
checkoutEvent(
|
|
193
|
-
"evt_org_003",
|
|
194
|
-
{ organizationId: DEFAULT_ORG_ID },
|
|
195
|
-
customerId,
|
|
196
|
-
),
|
|
197
|
-
);
|
|
198
|
-
const r = await fire(server);
|
|
199
|
-
expect(r.statusCode).toBe(200);
|
|
200
|
-
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
201
|
-
expect.objectContaining({ where: { id: DEFAULT_ORG_ID } }),
|
|
202
|
-
);
|
|
203
|
-
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// AC4 — a checkout WITHOUT organizationId takes the User path unchanged; the
|
|
207
|
-
// Organization is never touched (single-tenant degrade-clean regression).
|
|
208
|
-
it("takes the User path (never touches Organization) when no organizationId is present", async () => {
|
|
209
|
-
const customerId = "cus_single_tenant";
|
|
210
|
-
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
211
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
212
|
-
id: "usr_st",
|
|
213
|
-
email: "org-admin@example.com",
|
|
214
|
-
billingProvider: null,
|
|
215
|
-
billingCustomerId: null,
|
|
216
|
-
});
|
|
217
|
-
(prisma.user.update as jest.Mock).mockResolvedValueOnce({ id: "usr_st" });
|
|
218
|
-
|
|
219
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
220
|
-
checkoutEvent("evt_st_004", { productId: "pro" }, customerId),
|
|
221
|
-
);
|
|
222
|
-
const r = await fire(server);
|
|
223
|
-
expect(r.statusCode).toBe(200);
|
|
224
|
-
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
225
|
-
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
226
|
-
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// customer.created also routes on metadata.organizationId.
|
|
230
|
-
it("links the Organization on customer.created with metadata.organizationId", async () => {
|
|
231
|
-
const customerId = "cus_created_org";
|
|
232
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
233
|
-
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
234
|
-
id: REAL_ORG_ID,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
mockVerifyWebhookSignature.mockReturnValue({
|
|
238
|
-
id: "evt_cust_005",
|
|
239
|
-
type: "customer.created",
|
|
240
|
-
data: {
|
|
241
|
-
object: {
|
|
242
|
-
id: customerId,
|
|
243
|
-
email: "org-admin@example.com",
|
|
244
|
-
metadata: { organizationId: REAL_ORG_ID },
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
const r = await fire(server);
|
|
249
|
-
expect(r.statusCode).toBe(200);
|
|
250
|
-
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
251
|
-
expect.objectContaining({ where: { id: REAL_ORG_ID } }),
|
|
252
|
-
);
|
|
253
|
-
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// A DB failure on the org link never throws (200 still returned).
|
|
257
|
-
it("returns 200 even when the org billing update throws", async () => {
|
|
258
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
259
|
-
(prisma.organization.update as jest.Mock).mockRejectedValueOnce(
|
|
260
|
-
new Error("db unreachable"),
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
264
|
-
checkoutEvent("evt_org_006", { organizationId: REAL_ORG_ID }),
|
|
265
|
-
);
|
|
266
|
-
const r = await fire(server);
|
|
267
|
-
expect(r.statusCode).toBe(200);
|
|
268
|
-
expect(JSON.parse(r.body)).toEqual({ received: true });
|
|
269
|
-
});
|
|
270
|
-
});
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stripe webhook rate-limit — per-IP cap on the webhook endpoint.
|
|
3
|
-
*
|
|
4
|
-
* The webhook is a public endpoint. Without a rate limit, an attacker can
|
|
5
|
-
* trigger CPU-intensive signature verification at unbounded rate (DoS) or
|
|
6
|
-
* pin our log infrastructure with garbage payloads. We allow 100 req/min
|
|
7
|
-
* per IP — well above Stripe's actual retry cadence (~ a few per hour for
|
|
8
|
-
* a healthy account) but tight enough to make abuse unattractive.
|
|
9
|
-
*
|
|
10
|
-
* The limit applies regardless of signature validity — we don't want to
|
|
11
|
-
* permit unlimited *invalid* requests either, since those still consume CPU.
|
|
12
|
-
*/
|
|
13
|
-
import type { FastifyInstance } from "fastify";
|
|
14
|
-
import Fastify from "fastify";
|
|
15
|
-
|
|
16
|
-
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
const mockConfig: { STRIPE_WEBHOOK_SECRET: string | undefined } = {
|
|
19
|
-
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
20
|
-
};
|
|
21
|
-
// eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
|
|
22
|
-
jest.mock("../../../config/index.js", () => ({
|
|
23
|
-
get config() {
|
|
24
|
-
return mockConfig;
|
|
25
|
-
},
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
jest.mock("@working-theory/analytics/server", () => ({
|
|
29
|
-
track: jest.fn().mockResolvedValue(undefined),
|
|
30
|
-
}));
|
|
31
|
-
|
|
32
|
-
// Mock prisma so customer.created billing upsert doesn't throw
|
|
33
|
-
// eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
|
|
34
|
-
jest.mock("../../../lib/prisma.js");
|
|
35
|
-
|
|
36
|
-
const mockVerifyWebhookSignature = jest.fn();
|
|
37
|
-
class MockStripeWebhookSignatureError extends Error {
|
|
38
|
-
constructor(message: string) {
|
|
39
|
-
super(message);
|
|
40
|
-
this.name = "StripeWebhookSignatureError";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// eslint-disable-next-line no-restricted-syntax -- mirrors stripe.test.ts mock pattern
|
|
44
|
-
jest.mock("../../../lib/stripe.js", () => ({
|
|
45
|
-
verifyWebhookSignature: (...args: unknown[]) =>
|
|
46
|
-
mockVerifyWebhookSignature(...args),
|
|
47
|
-
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
48
|
-
}));
|
|
49
|
-
|
|
50
|
-
import { webhooksRoutes } from "../index.js";
|
|
51
|
-
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
52
|
-
|
|
53
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
56
|
-
const VALID_SIGNATURE = "t=1234,v1=abc";
|
|
57
|
-
|
|
58
|
-
async function buildServerWithIp(ip: string): Promise<FastifyInstance> {
|
|
59
|
-
const server = Fastify({ logger: false });
|
|
60
|
-
|
|
61
|
-
// Stub req.ip so we can simulate distinct callers reliably (light.my.request
|
|
62
|
-
// sends localhost by default which makes per-IP testing brittle).
|
|
63
|
-
server.addHook("onRequest", async (req) => {
|
|
64
|
-
Object.defineProperty(req, "ip", { value: ip, configurable: true });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
await server.register(async (scope) => {
|
|
68
|
-
scope.addContentTypeParser(
|
|
69
|
-
"application/json",
|
|
70
|
-
{ parseAs: "buffer" },
|
|
71
|
-
(_req, body, done) => {
|
|
72
|
-
done(null, body);
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
await scope.register(webhooksRoutes);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
await server.ready();
|
|
79
|
-
return server;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
describe("POST /api/webhooks/stripe — rate limit", () => {
|
|
85
|
-
beforeEach(() => {
|
|
86
|
-
jest.clearAllMocks();
|
|
87
|
-
__resetIdempotencyStore();
|
|
88
|
-
mockConfig.STRIPE_WEBHOOK_SECRET = "whsec_test_secret";
|
|
89
|
-
// Each request gets a unique event.id so idempotency doesn't short-circuit
|
|
90
|
-
let counter = 0;
|
|
91
|
-
mockVerifyWebhookSignature.mockImplementation(() => ({
|
|
92
|
-
id: `evt_rate_${(counter++).toString().padStart(12, "0")}`,
|
|
93
|
-
type: "customer.created",
|
|
94
|
-
data: { object: {} },
|
|
95
|
-
}));
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("allows requests up to the per-minute cap", async () => {
|
|
99
|
-
const server = await buildServerWithIp("10.10.10.1");
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
// 100 requests within the cap should all succeed
|
|
103
|
-
for (let i = 0; i < 100; i++) {
|
|
104
|
-
const response = await server.inject({
|
|
105
|
-
method: "POST",
|
|
106
|
-
url: "/v1/webhooks/stripe",
|
|
107
|
-
payload: VALID_BODY,
|
|
108
|
-
headers: {
|
|
109
|
-
"content-type": "application/json",
|
|
110
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
expect(response.statusCode).toBe(200);
|
|
114
|
-
}
|
|
115
|
-
} finally {
|
|
116
|
-
await server.close();
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("returns 429 on the 101st request within the window", async () => {
|
|
121
|
-
const server = await buildServerWithIp("10.10.10.2");
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
for (let i = 0; i < 100; i++) {
|
|
125
|
-
await server.inject({
|
|
126
|
-
method: "POST",
|
|
127
|
-
url: "/v1/webhooks/stripe",
|
|
128
|
-
payload: VALID_BODY,
|
|
129
|
-
headers: {
|
|
130
|
-
"content-type": "application/json",
|
|
131
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const overLimit = await server.inject({
|
|
137
|
-
method: "POST",
|
|
138
|
-
url: "/v1/webhooks/stripe",
|
|
139
|
-
payload: VALID_BODY,
|
|
140
|
-
headers: {
|
|
141
|
-
"content-type": "application/json",
|
|
142
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(overLimit.statusCode).toBe(429);
|
|
147
|
-
} finally {
|
|
148
|
-
await server.close();
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("tracks rate-limit buckets per IP independently", async () => {
|
|
153
|
-
const serverA = await buildServerWithIp("10.10.10.3");
|
|
154
|
-
const serverB = await buildServerWithIp("10.10.10.4");
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
// Saturate server A
|
|
158
|
-
for (let i = 0; i < 100; i++) {
|
|
159
|
-
await serverA.inject({
|
|
160
|
-
method: "POST",
|
|
161
|
-
url: "/v1/webhooks/stripe",
|
|
162
|
-
payload: VALID_BODY,
|
|
163
|
-
headers: {
|
|
164
|
-
"content-type": "application/json",
|
|
165
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// A is over the cap
|
|
171
|
-
const aOverLimit = await serverA.inject({
|
|
172
|
-
method: "POST",
|
|
173
|
-
url: "/v1/webhooks/stripe",
|
|
174
|
-
payload: VALID_BODY,
|
|
175
|
-
headers: {
|
|
176
|
-
"content-type": "application/json",
|
|
177
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
expect(aOverLimit.statusCode).toBe(429);
|
|
181
|
-
|
|
182
|
-
// B still has its full budget
|
|
183
|
-
const bFirst = await serverB.inject({
|
|
184
|
-
method: "POST",
|
|
185
|
-
url: "/v1/webhooks/stripe",
|
|
186
|
-
payload: VALID_BODY,
|
|
187
|
-
headers: {
|
|
188
|
-
"content-type": "application/json",
|
|
189
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
expect(bFirst.statusCode).toBe(200);
|
|
193
|
-
} finally {
|
|
194
|
-
await serverA.close();
|
|
195
|
-
await serverB.close();
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
});
|