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,633 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/webhooks/stripe — handler unit tests
|
|
3
|
-
*
|
|
4
|
-
* Uses jest.useFakeTimers for the telemetry-timeout test so the 2s wall-clock
|
|
5
|
-
* wait doesn't slow the suite.
|
|
6
|
-
*/
|
|
7
|
-
import type { FastifyInstance } from "fastify";
|
|
8
|
-
import Fastify from "fastify";
|
|
9
|
-
|
|
10
|
-
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
// Mock analytics track (external boundary: @working-theory/analytics/server)
|
|
13
|
-
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
14
|
-
jest.mock("@working-theory/analytics/server", () => ({
|
|
15
|
-
track: (...args: unknown[]) => mockTrack(...args),
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
// Mock Prisma — lib/prisma.ts wraps the external DB boundary (PrismaClient
|
|
19
|
-
// requires a live connection). This call auto-resolves to the manual mock at
|
|
20
|
-
// src/lib/__mocks__/prisma.ts (covers user.findFirst/findUnique/update/create).
|
|
21
|
-
// 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.
|
|
22
|
-
jest.mock("../../../lib/prisma.js");
|
|
23
|
-
|
|
24
|
-
// Mock verifyWebhookSignature at the external boundary (@working-theory/stripe-client)
|
|
25
|
-
// rather than the internal wrapper (lib/stripe.js). lib/stripe.js is a
|
|
26
|
-
// thin re-export with no logic of its own; the external SDK boundary is the
|
|
27
|
-
// correct mock point for signature verification.
|
|
28
|
-
const mockVerifyWebhookSignature = jest.fn();
|
|
29
|
-
class MockStripeWebhookSignatureError extends Error {
|
|
30
|
-
constructor(message: string) {
|
|
31
|
-
super(message);
|
|
32
|
-
this.name = "StripeWebhookSignatureError";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
jest.mock("@working-theory/stripe-client", () => ({
|
|
36
|
-
verifyWebhookSignature: (...args: unknown[]) =>
|
|
37
|
-
mockVerifyWebhookSignature(...args),
|
|
38
|
-
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
39
|
-
assertSandboxKey: jest.fn(),
|
|
40
|
-
createCheckoutSession: jest.fn(),
|
|
41
|
-
getStripeClient: jest.fn(),
|
|
42
|
-
STRIPE_API_VERSION: "2024-06-20",
|
|
43
|
-
StripeConfigError: class StripeConfigError extends Error {},
|
|
44
|
-
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
45
|
-
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
// Import after mocks are in place
|
|
49
|
-
import * as configModule from "../../../config/index.js";
|
|
50
|
-
import { stripeWebhookHandler } from "../stripe.js";
|
|
51
|
-
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
52
|
-
import { prisma } from "../../../lib/prisma.js";
|
|
53
|
-
|
|
54
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
function buildFakeEvent(overrides: Record<string, unknown> = {}) {
|
|
57
|
-
return {
|
|
58
|
-
id: "evt_test_12345678abcdef",
|
|
59
|
-
type: "checkout.session.completed",
|
|
60
|
-
data: {
|
|
61
|
-
object: {
|
|
62
|
-
amount_total: 4999,
|
|
63
|
-
currency: "usd",
|
|
64
|
-
metadata: { productId: "starter" },
|
|
65
|
-
customer_details: { email: "alice@example.com" },
|
|
66
|
-
id: "cs_test_session_12345678901234567890",
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
...overrides,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function buildTestServer(): Promise<FastifyInstance> {
|
|
74
|
-
const server = Fastify({ logger: false });
|
|
75
|
-
|
|
76
|
-
await server.register(async (webhooksScope) => {
|
|
77
|
-
webhooksScope.addContentTypeParser(
|
|
78
|
-
"application/json",
|
|
79
|
-
{ parseAs: "buffer" },
|
|
80
|
-
(_req, body, done) => {
|
|
81
|
-
done(null, body);
|
|
82
|
-
},
|
|
83
|
-
);
|
|
84
|
-
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await server.ready();
|
|
88
|
-
return server;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Valid raw body payload
|
|
92
|
-
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
93
|
-
const VALID_SIGNATURE = "t=1234,v1=abc";
|
|
94
|
-
|
|
95
|
-
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
describe("POST /api/webhooks/stripe", () => {
|
|
98
|
-
let server: FastifyInstance;
|
|
99
|
-
|
|
100
|
-
beforeEach(async () => {
|
|
101
|
-
jest.clearAllMocks();
|
|
102
|
-
__resetIdempotencyStore();
|
|
103
|
-
// Control STRIPE_WEBHOOK_SECRET per-test via jest.replaceProperty.
|
|
104
|
-
// config is a module-level singleton frozen at load time; replaceProperty
|
|
105
|
-
// restores the original value automatically in afterEach.
|
|
106
|
-
jest.replaceProperty(configModule, "config", {
|
|
107
|
-
...configModule.config,
|
|
108
|
-
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
109
|
-
});
|
|
110
|
-
server = await buildTestServer();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
afterEach(async () => {
|
|
114
|
-
await server.close();
|
|
115
|
-
jest.restoreAllMocks();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// ── Case 1: Missing config returns 503 ──────────────────────────────────
|
|
119
|
-
|
|
120
|
-
it("returns 503 when STRIPE_WEBHOOK_SECRET is not set", async () => {
|
|
121
|
-
jest.replaceProperty(configModule, "config", {
|
|
122
|
-
...configModule.config,
|
|
123
|
-
STRIPE_WEBHOOK_SECRET: undefined,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const response = await server.inject({
|
|
127
|
-
method: "POST",
|
|
128
|
-
url: "/v1/webhooks/stripe",
|
|
129
|
-
payload: VALID_BODY,
|
|
130
|
-
headers: {
|
|
131
|
-
"content-type": "application/json",
|
|
132
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
expect(response.statusCode).toBe(503);
|
|
137
|
-
expect(JSON.parse(response.body)).toEqual({
|
|
138
|
-
error: "webhook-not-configured",
|
|
139
|
-
});
|
|
140
|
-
expect(mockVerifyWebhookSignature).not.toHaveBeenCalled();
|
|
141
|
-
expect(mockTrack).not.toHaveBeenCalled();
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// ── Case 2: Invalid signature returns 400 ──────────────────────────────
|
|
145
|
-
|
|
146
|
-
it("returns 400 when signature verification fails", async () => {
|
|
147
|
-
mockVerifyWebhookSignature.mockImplementation(() => {
|
|
148
|
-
throw new MockStripeWebhookSignatureError(
|
|
149
|
-
"Signature verification failed.",
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const response = await server.inject({
|
|
154
|
-
method: "POST",
|
|
155
|
-
url: "/v1/webhooks/stripe",
|
|
156
|
-
payload: VALID_BODY,
|
|
157
|
-
headers: {
|
|
158
|
-
"content-type": "application/json",
|
|
159
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
expect(response.statusCode).toBe(400);
|
|
164
|
-
expect(JSON.parse(response.body)).toEqual({ error: "signature-invalid" });
|
|
165
|
-
expect(mockTrack).not.toHaveBeenCalled();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// ── Case 3: Missing Stripe-Signature header returns 400 ─────────────────
|
|
169
|
-
|
|
170
|
-
it("returns 400 when Stripe-Signature header is missing", async () => {
|
|
171
|
-
const response = await server.inject({
|
|
172
|
-
method: "POST",
|
|
173
|
-
url: "/v1/webhooks/stripe",
|
|
174
|
-
payload: VALID_BODY,
|
|
175
|
-
headers: {
|
|
176
|
-
"content-type": "application/json",
|
|
177
|
-
// no stripe-signature
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
expect(response.statusCode).toBe(400);
|
|
182
|
-
expect(JSON.parse(response.body)).toEqual({ error: "signature-missing" });
|
|
183
|
-
expect(mockTrack).not.toHaveBeenCalled();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
// ── Case 4: Body not a Buffer returns 400 ──────────────────────────────
|
|
187
|
-
|
|
188
|
-
it("returns 400 when body is not a Buffer (parser misconfig)", async () => {
|
|
189
|
-
// Build a server that passes a string body instead of Buffer
|
|
190
|
-
const stringBodyServer = Fastify({ logger: false });
|
|
191
|
-
await stringBodyServer.register(async (scope) => {
|
|
192
|
-
scope.addContentTypeParser(
|
|
193
|
-
"application/json",
|
|
194
|
-
{ parseAs: "string" },
|
|
195
|
-
(_req, body, done) => {
|
|
196
|
-
done(null, body);
|
|
197
|
-
},
|
|
198
|
-
);
|
|
199
|
-
scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
200
|
-
});
|
|
201
|
-
await stringBodyServer.ready();
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
const response = await stringBodyServer.inject({
|
|
205
|
-
method: "POST",
|
|
206
|
-
url: "/v1/webhooks/stripe",
|
|
207
|
-
payload: '{"type":"test"}',
|
|
208
|
-
headers: {
|
|
209
|
-
"content-type": "application/json",
|
|
210
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
expect(response.statusCode).toBe(400);
|
|
215
|
-
expect(JSON.parse(response.body)).toEqual({ error: "invalid-request" });
|
|
216
|
-
expect(mockTrack).not.toHaveBeenCalled();
|
|
217
|
-
} finally {
|
|
218
|
-
await stringBodyServer.close();
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// ── Case 5: Valid checkout.session.completed fires telemetry and 200s ──
|
|
223
|
-
|
|
224
|
-
it("returns 200 and fires track on valid checkout.session.completed", async () => {
|
|
225
|
-
const fakeEvent = buildFakeEvent();
|
|
226
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
227
|
-
|
|
228
|
-
const response = await server.inject({
|
|
229
|
-
method: "POST",
|
|
230
|
-
url: "/v1/webhooks/stripe",
|
|
231
|
-
payload: VALID_BODY,
|
|
232
|
-
headers: {
|
|
233
|
-
"content-type": "application/json",
|
|
234
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
expect(response.statusCode).toBe(200);
|
|
239
|
-
expect(JSON.parse(response.body)).toEqual({ received: true });
|
|
240
|
-
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
241
|
-
expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
|
|
242
|
-
amount: 4999,
|
|
243
|
-
currency: "usd",
|
|
244
|
-
productId: "starter",
|
|
245
|
-
eventType: "checkout.session.completed",
|
|
246
|
-
eventIdPrefix: "evt_test",
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ── Case 6: metadata.product_id (snake_case) is accepted ───────────────
|
|
251
|
-
|
|
252
|
-
it("accepts metadata.product_id (snake_case) for productId", async () => {
|
|
253
|
-
const fakeEvent = buildFakeEvent({
|
|
254
|
-
data: {
|
|
255
|
-
object: {
|
|
256
|
-
amount_total: 9900,
|
|
257
|
-
currency: "usd",
|
|
258
|
-
metadata: { product_id: "pro" },
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
});
|
|
262
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
263
|
-
|
|
264
|
-
const response = await server.inject({
|
|
265
|
-
method: "POST",
|
|
266
|
-
url: "/v1/webhooks/stripe",
|
|
267
|
-
payload: VALID_BODY,
|
|
268
|
-
headers: {
|
|
269
|
-
"content-type": "application/json",
|
|
270
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
expect(response.statusCode).toBe(200);
|
|
275
|
-
expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
|
|
276
|
-
amount: 9900,
|
|
277
|
-
currency: "usd",
|
|
278
|
-
productId: "pro",
|
|
279
|
-
eventType: "checkout.session.completed",
|
|
280
|
-
eventIdPrefix: "evt_test",
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// ── Case 7: no metadata produces undefined productId ───────────────────
|
|
285
|
-
|
|
286
|
-
it("sends undefined productId when metadata is absent", async () => {
|
|
287
|
-
const fakeEvent = buildFakeEvent({
|
|
288
|
-
data: {
|
|
289
|
-
object: {
|
|
290
|
-
amount_total: 1999,
|
|
291
|
-
currency: "gbp",
|
|
292
|
-
metadata: null,
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
297
|
-
|
|
298
|
-
await server.inject({
|
|
299
|
-
method: "POST",
|
|
300
|
-
url: "/v1/webhooks/stripe",
|
|
301
|
-
payload: VALID_BODY,
|
|
302
|
-
headers: {
|
|
303
|
-
"content-type": "application/json",
|
|
304
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
const call = mockTrack.mock.calls[0];
|
|
309
|
-
expect(call[1]).toMatchObject({ productId: undefined });
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// ── Case 8: Unknown event type 200s with info log ───────────────────────
|
|
313
|
-
|
|
314
|
-
it("returns 200 and does not call track for unknown event type", async () => {
|
|
315
|
-
const fakeEvent = buildFakeEvent({ type: "payment_intent.created" });
|
|
316
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
317
|
-
|
|
318
|
-
const response = await server.inject({
|
|
319
|
-
method: "POST",
|
|
320
|
-
url: "/v1/webhooks/stripe",
|
|
321
|
-
payload: VALID_BODY,
|
|
322
|
-
headers: {
|
|
323
|
-
"content-type": "application/json",
|
|
324
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
325
|
-
},
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
expect(response.statusCode).toBe(200);
|
|
329
|
-
expect(JSON.parse(response.body)).toEqual({ received: true });
|
|
330
|
-
expect(mockTrack).not.toHaveBeenCalled();
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
// ── Case 9: Telemetry failure does not block 200 ────────────────────────
|
|
334
|
-
|
|
335
|
-
it("returns 200 even when track throws synchronously", async () => {
|
|
336
|
-
const fakeEvent = buildFakeEvent();
|
|
337
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
338
|
-
mockTrack.mockRejectedValueOnce(new Error("analytics unreachable"));
|
|
339
|
-
|
|
340
|
-
const response = await server.inject({
|
|
341
|
-
method: "POST",
|
|
342
|
-
url: "/v1/webhooks/stripe",
|
|
343
|
-
payload: VALID_BODY,
|
|
344
|
-
headers: {
|
|
345
|
-
"content-type": "application/json",
|
|
346
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
expect(response.statusCode).toBe(200);
|
|
351
|
-
expect(JSON.parse(response.body)).toEqual({ received: true });
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// ── Case 10: Telemetry timeout does not block 200 ───────────────────────
|
|
355
|
-
|
|
356
|
-
it("returns 200 within timeout window when track never resolves", async () => {
|
|
357
|
-
const fakeEvent = buildFakeEvent();
|
|
358
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
359
|
-
|
|
360
|
-
// track returns a promise that never resolves — simulates a hung adapter
|
|
361
|
-
let resolveTimeout!: () => void;
|
|
362
|
-
const neverResolves = new Promise<void>((resolve) => {
|
|
363
|
-
resolveTimeout = resolve;
|
|
364
|
-
});
|
|
365
|
-
mockTrack.mockReturnValueOnce(neverResolves);
|
|
366
|
-
|
|
367
|
-
// Use real timers; inject completes once the 2s timeout fires.
|
|
368
|
-
// We set a generous test timeout so jest doesn't kill it first.
|
|
369
|
-
const responsePromise = server.inject({
|
|
370
|
-
method: "POST",
|
|
371
|
-
url: "/v1/webhooks/stripe",
|
|
372
|
-
payload: VALID_BODY,
|
|
373
|
-
headers: {
|
|
374
|
-
"content-type": "application/json",
|
|
375
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
376
|
-
},
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
// Wait for the 2s telemetry timeout to fire naturally, then resolve the
|
|
380
|
-
// hung track promise so the suite doesn't leak a pending microtask.
|
|
381
|
-
const response = await responsePromise;
|
|
382
|
-
resolveTimeout();
|
|
383
|
-
|
|
384
|
-
expect(response.statusCode).toBe(200);
|
|
385
|
-
expect(JSON.parse(response.body)).toEqual({ received: true });
|
|
386
|
-
}, 6000); // 6s test timeout > 2s TELEMETRY_TIMEOUT_MS
|
|
387
|
-
|
|
388
|
-
// ── Case 11: Logs contain no PII ────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
it("does not log customer email, full event ID, or full session ID", async () => {
|
|
391
|
-
const logLines: unknown[] = [];
|
|
392
|
-
const logCapture = Fastify({
|
|
393
|
-
logger: {
|
|
394
|
-
level: "info",
|
|
395
|
-
stream: {
|
|
396
|
-
write(msg: string) {
|
|
397
|
-
logLines.push(JSON.parse(msg));
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
},
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
await logCapture.register(async (scope) => {
|
|
404
|
-
scope.addContentTypeParser(
|
|
405
|
-
"application/json",
|
|
406
|
-
{ parseAs: "buffer" },
|
|
407
|
-
(_req, body, done) => {
|
|
408
|
-
done(null, body);
|
|
409
|
-
},
|
|
410
|
-
);
|
|
411
|
-
scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
412
|
-
});
|
|
413
|
-
await logCapture.ready();
|
|
414
|
-
|
|
415
|
-
try {
|
|
416
|
-
const fakeEvent = buildFakeEvent();
|
|
417
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
|
|
418
|
-
|
|
419
|
-
await logCapture.inject({
|
|
420
|
-
method: "POST",
|
|
421
|
-
url: "/v1/webhooks/stripe",
|
|
422
|
-
payload: VALID_BODY,
|
|
423
|
-
headers: {
|
|
424
|
-
"content-type": "application/json",
|
|
425
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
426
|
-
},
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
const logText = JSON.stringify(logLines);
|
|
430
|
-
// Must NOT contain full event ID
|
|
431
|
-
expect(logText).not.toContain("evt_test_12345678abcdef");
|
|
432
|
-
// Must NOT contain customer email
|
|
433
|
-
expect(logText).not.toContain("alice@example.com");
|
|
434
|
-
// Must NOT contain full session ID
|
|
435
|
-
expect(logText).not.toContain("cs_test_session_12345678901234567890");
|
|
436
|
-
// Should contain the 8-char prefix
|
|
437
|
-
expect(logText).toContain("evt_test");
|
|
438
|
-
} finally {
|
|
439
|
-
await logCapture.close();
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
// ── Case 12: Non-POST methods return 404 ────────────────────────────────
|
|
444
|
-
|
|
445
|
-
it("returns 404 for GET /api/webhooks/stripe", async () => {
|
|
446
|
-
const response = await server.inject({
|
|
447
|
-
method: "GET",
|
|
448
|
-
url: "/v1/webhooks/stripe",
|
|
449
|
-
});
|
|
450
|
-
expect(response.statusCode).toBe(404);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it("returns 404 for PUT /api/webhooks/stripe", async () => {
|
|
454
|
-
const response = await server.inject({
|
|
455
|
-
method: "PUT",
|
|
456
|
-
url: "/v1/webhooks/stripe",
|
|
457
|
-
payload: VALID_BODY,
|
|
458
|
-
headers: { "content-type": "application/json" },
|
|
459
|
-
});
|
|
460
|
-
expect(response.statusCode).toBe(404);
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it("returns 404 for DELETE /api/webhooks/stripe", async () => {
|
|
464
|
-
const response = await server.inject({
|
|
465
|
-
method: "DELETE",
|
|
466
|
-
url: "/v1/webhooks/stripe",
|
|
467
|
-
});
|
|
468
|
-
expect(response.statusCode).toBe(404);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
// ── Billing upsert tests (AC bullets 4-6) ──────────────────────────────────
|
|
472
|
-
|
|
473
|
-
// AC bullet 4: customer.created → upsert; re-fire same event = no second write
|
|
474
|
-
it("upserts billing fields on customer.created and is idempotent on re-fire", async () => {
|
|
475
|
-
const customerId = "cus_test_abc123";
|
|
476
|
-
const fakeCustomerCreated = {
|
|
477
|
-
id: "evt_customer_created_001",
|
|
478
|
-
type: "customer.created",
|
|
479
|
-
data: {
|
|
480
|
-
object: {
|
|
481
|
-
id: customerId,
|
|
482
|
-
email: "alice@example.com",
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
};
|
|
486
|
-
const upsertedUser = {
|
|
487
|
-
id: "usr_1",
|
|
488
|
-
email: "alice@example.com",
|
|
489
|
-
billingProvider: "stripe",
|
|
490
|
-
billingCustomerId: customerId,
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
// First fire: typed composite match misses (user not yet linked)
|
|
494
|
-
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
495
|
-
// email fallback finds the user
|
|
496
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
497
|
-
id: "usr_1",
|
|
498
|
-
email: "alice@example.com",
|
|
499
|
-
billingProvider: null,
|
|
500
|
-
billingCustomerId: null,
|
|
501
|
-
});
|
|
502
|
-
(prisma.user.update as jest.Mock).mockResolvedValueOnce(upsertedUser);
|
|
503
|
-
|
|
504
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
|
|
505
|
-
const response1 = await server.inject({
|
|
506
|
-
method: "POST",
|
|
507
|
-
url: "/v1/webhooks/stripe",
|
|
508
|
-
payload: VALID_BODY,
|
|
509
|
-
headers: {
|
|
510
|
-
"content-type": "application/json",
|
|
511
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
512
|
-
},
|
|
513
|
-
});
|
|
514
|
-
expect(response1.statusCode).toBe(200);
|
|
515
|
-
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
516
|
-
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
517
|
-
expect.objectContaining({
|
|
518
|
-
data: expect.objectContaining({
|
|
519
|
-
billingProvider: "stripe",
|
|
520
|
-
billingCustomerId: customerId,
|
|
521
|
-
}),
|
|
522
|
-
}),
|
|
523
|
-
);
|
|
524
|
-
|
|
525
|
-
// Re-fire same event: idempotency store short-circuits — no second write
|
|
526
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
|
|
527
|
-
const response2 = await server.inject({
|
|
528
|
-
method: "POST",
|
|
529
|
-
url: "/v1/webhooks/stripe",
|
|
530
|
-
payload: VALID_BODY,
|
|
531
|
-
headers: {
|
|
532
|
-
"content-type": "application/json",
|
|
533
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
534
|
-
},
|
|
535
|
-
});
|
|
536
|
-
expect(response2.statusCode).toBe(200);
|
|
537
|
-
// update should still be 1 — idempotency store prevented second execution
|
|
538
|
-
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// AC bullet 5: checkout.session.completed + existing User matched by email → billing fields populated
|
|
542
|
-
it("populates billingProvider/Id on checkout.session.completed for user matched by email", async () => {
|
|
543
|
-
const customerId = "cus_test_checkout_xyz";
|
|
544
|
-
const fakeCheckout = {
|
|
545
|
-
id: "evt_checkout_002",
|
|
546
|
-
type: "checkout.session.completed",
|
|
547
|
-
data: {
|
|
548
|
-
object: {
|
|
549
|
-
id: "cs_test_abc",
|
|
550
|
-
amount_total: 4999,
|
|
551
|
-
currency: "usd",
|
|
552
|
-
customer: customerId,
|
|
553
|
-
customer_details: { email: "bob@example.com" },
|
|
554
|
-
metadata: { productId: "pro" },
|
|
555
|
-
},
|
|
556
|
-
},
|
|
557
|
-
};
|
|
558
|
-
const updatedUser = {
|
|
559
|
-
id: "usr_2",
|
|
560
|
-
email: "bob@example.com",
|
|
561
|
-
billingProvider: "stripe",
|
|
562
|
-
billingCustomerId: customerId,
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
// Typed composite lookup misses (no prior link)
|
|
566
|
-
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
567
|
-
// Email fallback finds user
|
|
568
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
569
|
-
id: "usr_2",
|
|
570
|
-
email: "bob@example.com",
|
|
571
|
-
billingProvider: null,
|
|
572
|
-
billingCustomerId: null,
|
|
573
|
-
});
|
|
574
|
-
(prisma.user.update as jest.Mock).mockResolvedValueOnce(updatedUser);
|
|
575
|
-
|
|
576
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeCheckout);
|
|
577
|
-
const response = await server.inject({
|
|
578
|
-
method: "POST",
|
|
579
|
-
url: "/v1/webhooks/stripe",
|
|
580
|
-
payload: VALID_BODY,
|
|
581
|
-
headers: {
|
|
582
|
-
"content-type": "application/json",
|
|
583
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
expect(response.statusCode).toBe(200);
|
|
588
|
-
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
589
|
-
expect.objectContaining({
|
|
590
|
-
where: { id: "usr_2" },
|
|
591
|
-
data: expect.objectContaining({
|
|
592
|
-
billingProvider: "stripe",
|
|
593
|
-
billingCustomerId: customerId,
|
|
594
|
-
}),
|
|
595
|
-
}),
|
|
596
|
-
);
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
// AC bullet 6: event with neither typed match nor email match → 200 + warning, no User row created
|
|
600
|
-
it("returns 200 and logs warning when no user match found — no row created", async () => {
|
|
601
|
-
const fakeOrphan = {
|
|
602
|
-
id: "evt_orphan_003",
|
|
603
|
-
type: "customer.created",
|
|
604
|
-
data: {
|
|
605
|
-
object: {
|
|
606
|
-
id: "cus_test_orphan",
|
|
607
|
-
email: null, // no email — can't even do email fallback
|
|
608
|
-
},
|
|
609
|
-
},
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
// Typed composite lookup misses
|
|
613
|
-
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
614
|
-
// No email fallback possible (email is null)
|
|
615
|
-
|
|
616
|
-
mockVerifyWebhookSignature.mockReturnValue(fakeOrphan);
|
|
617
|
-
const response = await server.inject({
|
|
618
|
-
method: "POST",
|
|
619
|
-
url: "/v1/webhooks/stripe",
|
|
620
|
-
payload: VALID_BODY,
|
|
621
|
-
headers: {
|
|
622
|
-
"content-type": "application/json",
|
|
623
|
-
"stripe-signature": VALID_SIGNATURE,
|
|
624
|
-
},
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
expect(response.statusCode).toBe(200);
|
|
628
|
-
expect(JSON.parse(response.body)).toEqual({ received: true });
|
|
629
|
-
// No user row created
|
|
630
|
-
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
631
|
-
expect(prisma.user.create).not.toHaveBeenCalled();
|
|
632
|
-
});
|
|
633
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Webhooks route group.
|
|
3
|
-
*
|
|
4
|
-
* Routes:
|
|
5
|
-
* POST /v1/webhooks/stripe — verify Stripe signature, dispatch event,
|
|
6
|
-
* fire telemetry, respond 200.
|
|
7
|
-
*
|
|
8
|
-
* Rate limit: 100 req/min/IP applied at the route level. Above Stripe's
|
|
9
|
-
* actual retry cadence (a healthy account sees a few events per hour) but
|
|
10
|
-
* tight enough to make replay-flood DoS unattractive. Encapsulated to the
|
|
11
|
-
* webhooks scope so the global content limit (60/min) doesn't bleed in.
|
|
12
|
-
*/
|
|
13
|
-
import rateLimit from "@fastify/rate-limit";
|
|
14
|
-
import type { FastifyPluginAsync } from "fastify";
|
|
15
|
-
|
|
16
|
-
import { stripeWebhookHandler } from "./stripe.js";
|
|
17
|
-
|
|
18
|
-
export const STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN = 100;
|
|
19
|
-
|
|
20
|
-
export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
|
|
21
|
-
// Register rate-limit at this scope so the webhook gets its own bucket
|
|
22
|
-
// distinct from the public content API's global limiter.
|
|
23
|
-
await fastify.register(rateLimit, {
|
|
24
|
-
global: false,
|
|
25
|
-
max: STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN,
|
|
26
|
-
timeWindow: "1 minute",
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
fastify.post(
|
|
30
|
-
"/v1/webhooks/stripe",
|
|
31
|
-
{
|
|
32
|
-
config: {
|
|
33
|
-
rateLimit: {
|
|
34
|
-
max: STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN,
|
|
35
|
-
timeWindow: "1 minute",
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
stripeWebhookHandler,
|
|
40
|
-
);
|
|
41
|
-
};
|