diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 8bd1d2f..ba7571e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,49 +1,45 @@ -name: playwright +name: e2e on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] - + branches: ["main"] workflow_dispatch: jobs: - test: + playwright: + name: Playwright example app e2e timeout-minutes: 60 runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.40.1-jammy - steps: - - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - node-version-file: .nvmrc - - - name: Install fc dependencies - run: npm i - - name: Install example app npm dependencies - run: npm i - working-directory: ./example_app/server - - - name: Install playwright dependencies - run: npm i - working-directory: ./example_app/tests-e2e - - - name: Run Server - run: PORT=9000 FULLCIRCLE_HOST=http://localhost:8000 npm run start-with-fc & - working-directory: ./example_app/server - - - name: Run Playwright tests - working-directory: ./example_app/tests-e2e - run: npx playwright test - - - uses: actions/upload-artifact@v3 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install FullCircle dependencies + run: npm ci + + - name: Install example app dependencies + run: npm ci + working-directory: ./example_app/server + + - name: Install Playwright e2e dependencies + run: npm ci + working-directory: ./example_app/tests-e2e + + - name: Run Playwright e2e tests + run: npm test + working-directory: ./example_app/tests-e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: example_app/tests-e2e/playwright-report/ + retention-days: 30 diff --git a/docs/case-studies/soundspace-provider-plan.md b/docs/case-studies/soundspace-provider-plan.md new file mode 100644 index 0000000..4c9da76 --- /dev/null +++ b/docs/case-studies/soundspace-provider-plan.md @@ -0,0 +1,399 @@ + + +# Soundspace FullCircle provider and fixture plan + +Status: case-study plan for `~/repos/soundspace` based on source inspection on 2026-06-11. + +Soundspace is a good stress case for FullCircle because it already routes some SDK traffic through FullCircle in legacy Playwright tests, while newer app code mixes injectable SDK clients, direct `fetch()` calls, local Supabase, and browser-only third-party scripts. + +## Current integration map + +| Service | Source locations | Current seam | Test priority | +| --- | --- | --- | --- | +| Stripe API | `src/utils/stripe/*`, `src/app/(header-footer-layout)/checkout/createCheckoutSession.ts`, `src/utils/checkout/handleCheckoutSessionComplete.ts`, `src/app/stripe-webhook/*`, subscription management pages | `StripeClient` accepts a mock base URL via `src/utils/stripe/StripeMock.ts`; `APP_ENV=test` points to `http://localhost:3005` | Critical | +| Acuity API | `src/utils/acuity/AcuityClient.ts`, `src/external/acuity/*.ts`, `src/app/(header-footer-layout)/book/**`, legacy booking utilities | Legacy `deps.acuity()` uses injectable base URL, but newer `src/external/acuity/Acuity.ts` hardcodes `https://acuityscheduling.com/api/v1` | Critical | +| Acuity iframe | `src/app/(header-footer-layout)/legacy-book/iframe-proxy/route.ts`, `src/app/(header-footer-layout)/legacy-book/components/BookingMain.tsx` | `deps.data.acuityProxyUrl` points to `http://localhost:3002` in test, but `BookingMain.tsx` still contains a hardcoded iframe URL | Critical for legacy flow | +| Google Sheets | `src/utils/google-sheets/*`, legacy booking pages | Already replaced by `MockGoogleSheetsClient` in `APP_ENV=test`; no HTTP harness today | Medium | +| Supabase auth/storage | `src/utils/supabase/*`, auth routes/pages, image upload URL route | Local Supabase URLs in `tests-e2e/testdata/env_vars.ts`; service-role client for storage signed upload URLs | Critical for accounts, medium for storage | +| Prisma/Postgres | `src/utils/prisma/prisma.ts`, `prisma/schema/schema.prisma`, Docker Supabase Postgres | Real local DB in e2e; reset scripts exist | Critical | +| HubSpot | `src/utils/hubspot/createHubspotServerClient.ts`, `auth/complete-login/setUpAccount.ts`, HubSpot form components | Server SDK has no injectable base URL; browser forms load external script | Medium | +| Attio | `auth/complete-login/setUpAccount.ts` | Direct `fetch()` to `https://api.attio.com/v2/...`; no seam | Medium | +| Google Places/Maps | `my-listings/locations/create/googlePlacesSearch.ts`, location details map component | Direct `fetch()` to Places API; browser Maps API key prop | Medium | +| PostHog | `src/app/providers/PostHogProvider.tsx` | Can be disabled via `NEXT_PUBLIC_POSTHOG_OPT_OUT=true` | Low | +| Rollbar | `src/utils/rollbar/*`, `src/instrumentation.ts`, error pages | Frontend endpoint points back to app; server Rollbar direct SDK | Low | +| Intercom | `src/app/(header-footer-layout)/components/IntercomWrapper.tsx` | Browser SDK only; no server dependency | Low | + +Existing legacy FullCircle usage lives in `tests-e2e/run_full_circle.ts` and `tests-e2e/tests/acuity_form.spec.ts`. The lockfile dependency is `fullcircle` from `fullcircle-testing/fullcircle.git#7f03209704305a9f7b25513e89b77a84f1237db8` (`tests-e2e/package.json`, `tests-e2e/pnpm-lock.yaml`). It currently starts three FullCircle servers: Acuity API on port 3001, Acuity iframe on 3002, and Stripe API on 3005. + +The old Stripe mock hook is still present: `src/utils/stripe/StripeMock.ts` defines `newMockStripeHttpClient(url)`, and `src/utils/stripe/StripeClient.ts` passes it into Stripe's SDK `httpClient` option. `git grep --all` shows the same artifact on `main`, `staging`, and `debug/tender-network-auth`; no older separate mock client appears necessary. + +## Recommended app harness shape + +Soundspace should keep one app-local dependency module, but the seam should cover every network boundary, including direct `fetch()` code: + +```ts +export type SoundspaceHarnessUrls = { + stripeApi: string; + stripeJs?: string; + acuityApi: string; + acuityIframe: string; + hubspotApi: string; + hubspotFormsJs?: string; + attioApi: string; + googlePlacesApi: string; + googleSheetsApi?: string; + rollbarApi?: string; + posthogApi?: string; + intercomJs?: string; +}; + +export type SoundspaceExternalClients = { + stripe: () => StripeClient; + acuity: () => AcuityGateway; + googleSheets: () => IGoogleSheetsClient; + hubspot: () => HubspotGateway; + attio: () => AttioGateway; + places: () => GooglePlacesGateway; + telemetry: () => TelemetryGateway; + urls: SoundspaceHarnessUrls; +}; +``` + +Actionable app changes before FullCircle can own the whole suite: + +1. Change `src/external/acuity/Acuity.ts` from a hardcoded `ACUITY_BASE_URL` to the same configured `acuityApi` URL used by `deps.acuity()`. +2. Route `BookingMain.tsx` iframe URL through `deps.data.acuityProxyUrl` instead of hardcoding `https://app.acuityscheduling.com/schedule.php?...`. +3. Wrap HubSpot SDK construction behind a `HubspotGateway` whose transport/base URL can be injected. If the official SDK cannot reliably accept a base URL, keep the SDK for production and use an app-local gateway interface in tests. +4. Wrap Attio and Google Places direct `fetch()` calls behind small gateways that accept `baseUrl`. +5. Treat analytics/error-reporting/browser widgets as sinks by default in e2e; only capture them in tests that explicitly assert observability. + +## FullCircle provider set + +These provider APIs are deliberately higher-level than raw route registration. The intent is for an agent to write readable tests that model business resources, and for the provider to translate those resources into HTTP fixtures plus request assertions. + +### 1. Stripe Soundspace provider + +Build on the existing FullCircle Stripe provider, but add Soundspace-specific resources around products, prices, subscriptions, invoices, customer portal, and checkout. + +```ts +const stripe = stripeProvider(harness, { + webhookEndpoint: `${appUrl}/stripe-webhook`, + webhookSigningSecret: env.STRIPE_WEBHOOK_SECRET, +}); + +stripe.customers.listByEmail({ email, reply: [customer] }); +stripe.customers.create({ match: { email }, reply: customer }); + +stripe.products.list({ reply: [bookingProduct, legacyBookingProduct, nonBookingProduct] }); +stripe.products.retrieve({ id: 'prod_booking_1', reply: bookingProduct }); +stripe.products.create({ match: { name: /Piano Room/ }, reply: bookingProduct }); +stripe.products.update({ id: 'prod_booking_1', match: { metadata: {} }, reply: bookingProduct }); +stripe.products.search({ query: 'metadata["soundspace_product_type"]:"pause"', reply: [pauseProduct] }); + +stripe.prices.retrieve({ id: 'price_monthly_1', reply: monthlyPrice }); +stripe.prices.create({ match: { product: 'prod_booking_1', recurringInterval: 'month' }, reply: monthlyPrice }); +stripe.prices.update({ id: 'price_monthly_1', match: { active: false }, reply: inactivePrice }); +stripe.prices.list({ match: { product: 'prod_pause' }, reply: [pausePrice] }); + +stripe.checkout.sessions.createSubscription({ + match: { + customer: 'cus_soundspace_user', + uiMode: 'embedded', + returnUrlContains: '/checkout/return', + metadata: { user_id: userId, plan_id: planId }, + lineItems: [{ price: 'price_booking_month', quantity: 2 }], + }, + reply: checkoutSession, +}); +stripe.checkout.sessions.retrieveCompleted({ id: 'cs_soundspace_1', subscription }); +stripe.checkout.sessions.lineItems({ sessionId: 'cs_soundspace_1', reply: checkoutLineItems }); + +stripe.subscriptions.retrieve({ id: 'sub_soundspace_1', reply: subscription }); +stripe.subscriptions.list({ customer: 'cus_soundspace_user', reply: [subscription] }); +stripe.subscriptions.update({ id: 'sub_soundspace_1', match: { cancel_at_period_end: true }, reply: canceledAtPeriodEndSubscription }); + +stripe.invoices.retrieve({ id: 'in_soundspace_1', reply: draftInvoice }); +stripe.invoices.finalize({ id: 'in_soundspace_1', reply: openInvoice }); +stripe.invoices.list({ subscription: 'sub_soundspace_1', reply: [priorInvoice, currentInvoice] }); + +await stripe.webhooks.sendSequence(stripe.sequences.checkoutSubscriptionPaid({ + checkoutSession, + subscription, + invoice, +})); +``` + +Estimated fixtures: + +- `customer.none.json`: list-by-email returns `data: []`. +- `customer.existing.json`: list-by-email returns existing `cus_*`. +- `checkout.embedded.subscription.create.json`: `POST /v1/checkout/sessions` response with `client_secret`, `ui_mode=embedded`, `mode=subscription`, and Soundspace metadata. +- `checkout.completed.retrieve.json`: `GET /v1/checkout/sessions/:id?expand[]=subscription` with expanded subscription. +- `checkout.line_items.booking-products.json`: line items with expanded `price.product` IDs matching Prisma plan products. +- `webhook.checkout.session.completed.json`: event that triggers `handleCheckoutSessionComplete()`. +- `webhook.invoice.created.json`: draft invoice path plus `invoices.finalizeInvoice` follow-up. +- `webhook.invoice.paid.json`: period rollover and balance renewal. +- `webhook.customer.subscription.updated.json`: pause/resume/cancel-at-period-end updates. +- `webhook.customer.subscription.deleted.json`: cancellation and balance removal. +- `products.admin-sync.json`: product/price create, update, deactivate, and search responses for my-listings tender plan admin flows. + +Resource usage injection: + +```ts +stripe.resources.subscriptionPlan({ + planId, + customerId, + products: [ + { productId: 'prod_room_hours', priceId: 'price_room_hours_month', quantity: 2, usage: { kind: 'minutes', perUnit: 60 } }, + { productId: 'prod_credits', priceId: 'price_credits_month', quantity: 1, usage: { kind: 'credits', perUnit: 10 } }, + ], +}); +``` + +The provider should expose the same resource object to DB seed helpers so the Stripe line items, Prisma plan rows, and expected balance rows cannot drift. + +### 2. Acuity API provider + +Soundspace needs both legacy credit-booking endpoints and newer booking-page endpoints. + +```ts +const acuity = acuityProvider(harness); + +acuity.appointmentTypes.list({ calendarId: 7034881, reply: [halfHour, twoHour, threeHour] }); +acuity.appointments.listByEmail({ email, minDate: today, reply: bookedAppointments }); +acuity.calendars.list({ reply: [calendarHq, calendarBrooklyn] }); +acuity.availability.dates({ calendarId, appointmentTypeId, month: '2026-06', reply: ['2026-06-12'] }); +acuity.availability.times({ calendarId, appointmentTypeId, date: '2026-06-12', reply: [{ time: '2026-06-12T10:00:00-0400' }] }); +acuity.availability.checkTimes({ match: { calendarId, appointmentTypeId, datetime }, reply: { valid: true } }); +acuity.forms.list({ appointmentTypeId, reply: [bookingForm] }); +acuity.appointments.create({ match: { calendarId, appointmentTypeId, email, datetime }, reply: createdAppointment }); +acuity.appointments.cancel({ appointmentId: 'apt_1', reply: { ok: true } }); +``` + +Estimated fixtures: + +- Existing legacy fixtures already cover `GET /api/v1/appointment-types` and `GET /api/v1/appointments?email&minDate`. +- Add `calendars.list.json`, `availability.dates.available.json`, `availability.times.available.json`, `availability.check-times.valid.json`, `forms.booking.json`, `appointments.create.success.json`, `appointments.cancel.success.json`. +- Failure fixtures: no availability, invalid selected time, create conflict, cancel failure. + +Resource usage injection: + +```ts +acuity.resources.calendarUsage({ + email, + calendars: [ + { calendarId: 7034881, minutesBooked: 90, appointments: [{ durationMinutes: 60 }, { durationMinutes: 30 }] }, + ], +}); +``` + +The provider can derive `GET /appointments` responses from `calendarUsage()` and let tests assert Soundspace's computed booked minutes without hand-editing raw Acuity JSON. + +### 3. Acuity iframe provider + +The existing legacy iframe fixture is a large captured HTML page. Keep that as a captured session, but layer a small editor on top for high-signal test inputs. + +```ts +const iframe = acuityIframeProvider(harness); + +iframe.schedulePage({ + owner: '18362646', + calendarId: '7034881', + user: { firstName, lastName, email, phone }, + appointmentTypes: [twoHour, threeHour], + hiddenSelectors: ['#phone', '#email'], +}); +``` + +Estimated fixtures: + +- Existing `schedule.php/GET.json` as baseline. +- Variants for “too little credit”, “max hours reached”, “multi-calendar chooser”, and “Acuity unavailable”. + +The long-term provider should support DOM/HTML patching against a captured page rather than creating entire static copies for every variant. + +### 4. Google Sheets provider / fixture facade + +Today this is already a mock object in `APP_ENV=test`. FullCircle should still define the resource model because the Acuity provider and legacy booking calculations depend on it. + +```ts +googleSheets.resources.acuityModifiers([ + { CalendarId: '7034881', TenderModifier: '1', CreditModifier: '1' }, + { CalendarId: '3287474', TenderModifier: '2', CreditModifier: '0.5' }, +]); +``` + +Estimated fixtures: + +- `acuity-modifiers.default.csv`. +- `acuity-modifiers.empty.csv`. +- `acuity-modifiers.multi-calendar.csv`. +- `acuity-modifiers.malformed.csv`. + +Implementation options: + +- Short term: keep using `MockGoogleSheetsClient` and have FullCircle emit a typed fixture file consumed by that mock. +- Later: add a Google Sheets HTTP provider only if the production code stops using an injectable client. + +### 5. Supabase auth/storage plus database provider + +For routine user authentication, do not mock Google OAuth. Prefer local Supabase plus direct account/session helpers. Soundspace already has local Supabase URLs in `tests-e2e/testdata/env_vars.ts`. + +```ts +const db = databaseProvider({ url: process.env.DIRECT_URL }); +const auth = supabaseAuthProvider({ supabaseUrl, serviceRoleKey }); + +const snap = await db.snapshot('before checkout'); +const user = await auth.users.createPasswordUser({ email, password, confirmed: true }); +await auth.sessions.loginInBrowser(page, user); + +// Run flow... +await expect(db.diff(snap)).toContainRows({ table: 'subscriptions', where: { users_id: user.soundspaceUserId } }); +``` + +Estimated fixtures/seeds: + +- `auth.confirmed-basic-user`: Supabase auth user plus Soundspace `users` row. +- `auth.new-user-complete-login`: Supabase user only; e2e hits `/auth/complete-login` and fixtures Stripe/HubSpot/Attio customer creation. +- `auth.google-oauth-callback`: only for testing OAuth callback wiring; otherwise skip real OAuth. +- `storage.upload-url.success`: Supabase storage signed upload URL flow for listing images. + +Database resource usage and diffs should be first-class: + +```ts +await db.seed.soundspacePlan(stripe.resources.subscriptionPlan(...)); +const before = await db.snapshot(); +// exercise browser flow +expect(await db.diff(before)).toMatchObject({ + inserted: { + subscriptions: [{ stripe_subscription_id: 'sub_soundspace_1' }], + balances: expect.arrayContaining([{ balance: 120 }]), + }, +}); +``` + +Even though the product direction prefers SQLite, Soundspace itself currently uses Prisma/Postgres through local Supabase. The generic FullCircle DB API should support SQLite first and Postgres through an adapter so this case study can still dogfood snapshot/diff behavior. + +### 6. HubSpot provider + +Server setup calls `crm.contacts.searchApi.doSearch()` and `crm.contacts.basicApi.create()` during account completion. + +```ts +const hubspot = hubspotProvider(harness); +hubspot.contacts.searchByEmail({ email, reply: [] }); +hubspot.contacts.create({ match: { email }, reply: { id: 'hs_contact_1', properties: { email } } }); +``` + +Estimated fixtures: + +- `contacts.search.none.json`. +- `contacts.search.existing.json`. +- `contacts.create.success.json`. +- `contacts.create.failure.json`. + +Browser HubSpot form embeds should be treated as a script sink unless a test explicitly verifies form loading. + +### 7. Attio provider + +Account setup uses direct fetch calls to query or upsert people records. + +```ts +const attio = attioProvider(harness); +attio.people.queryByEmail({ email, reply: [] }); +attio.people.upsertByEmail({ email, reply: { id: { record_id: 'attio_person_1' } } }); +``` + +Estimated fixtures: + +- `people.query.none.json`. +- `people.query.existing.json`. +- `people.upsert.success.json`. +- `people.query.failure.json`. + +### 8. Google Places provider + +Location creation calls `POST https://places.googleapis.com/v1/places:searchText`. + +```ts +const places = googlePlacesProvider(harness); +places.searchText({ + match: { textQuery: /Soundspace/i }, + reply: [{ id: 'places/soundspace-hq', displayName: { text: 'Soundspace HQ' } }], +}); +``` + +Estimated fixtures: + +- `places.searchText.single.json`. +- `places.searchText.empty.json`. +- `places.searchText.error.json`. + +### 9. Telemetry/widget sinks + +PostHog, Rollbar, Intercom, and third-party browser scripts should default to “blocked but recorded” sinks for deterministic e2e. + +```ts +telemetry.sink.posthog({ assertNoUnexpectedIdentify: false }); +telemetry.sink.rollbar({ failOnServerErrorReport: true }); +telemetry.sink.intercom(); +``` + +Estimated fixtures: + +- Usually none. These providers primarily assert that no critical flow requires analytics to be reachable. +- Optional `rollbar.error-report.json` when testing error instrumentation. + +## Suggested Soundspace acceptance journeys + +### A. New user completes checkout subscription + +1. Seed local DB with a Soundspace plan and products generated from `stripe.resources.subscriptionPlan()`. +2. Create a confirmed Supabase user with no Soundspace profile. +3. Fixture Stripe customer list/create, HubSpot contact search/create, Attio person query/upsert. +4. Visit complete-login; assert `users` row created with Stripe/HubSpot/Attio IDs. +5. Visit checkout, create embedded Stripe Checkout session, return with `session_id`. +6. Fixture Checkout retrieve and line items; send signed `checkout.session.completed` webhook. +7. Assert DB diff: `subscriptions`, `subscription_items`, `balances`, and `stripe_webhook_events` rows inserted/updated. + +### B. Legacy Acuity booking respects subscription usage + +1. Seed user, subscription, balances, and Stripe product metadata. +2. Inject Google Sheets acuity modifiers. +3. Inject Acuity appointments via `acuity.resources.calendarUsage()`. +4. Visit legacy booking page through Playwright. +5. Assert the iframe/provider output hides or allows appointments based on remaining credits/hours. +6. Optionally create an appointment and assert DB balance consumption rows. + +### C. New booking page creates and cancels Acuity appointments + +1. Seed a user with available balance and a space with `acuity_calendar_id`. +2. Fixture Acuity calendars, appointment types, dates, times, forms, check-times, and create appointment. +3. Click through `/book` and submit booking. +4. Assert DB diff includes booking row with `acuity_appointment_id` and consumed balance. +5. Fixture cancel endpoint; cancel booking and assert balance restored / cancellation state. + +### D. Listing owner manages Stripe-backed plans + +1. Seed an owner account and managed listing/tender. +2. Fixture Stripe product/price create for a new managed plan. +3. Modify the plan; fixture product update, price deactivation, and price create. +4. Delete/archive the plan; fixture product/price inactive updates. +5. Assert DB diffs and request assertions agree on Stripe object IDs. + +## What this implies for FullCircle + +1. Providers should expose resource builders, not only raw HTTP route helpers. Tests should say “this user has 2 hours booked” or “this plan grants 120 minutes”, and providers should derive raw service fixtures. +2. A session file should contain: app resource seeds, provider fixtures, webhook sequence, browser capture metadata, request assertions, redaction rules, and DB snapshots/diffs. +3. FullCircle should support multiple targets per session (`stripe`, `acuity-api`, `acuity-iframe`, `hubspot`, `attio`, `places`) and make target URL injection explicit. +4. DB snapshot/diff should be a core harness capability. SQLite can ship first, but the API should not assume SQLite-only because Soundspace dogfoods Postgres/Supabase today. +5. Agents need an “external communications inventory” skill: scan imports and direct URLs, add seams for unowned network calls, then record each provider session before writing browser assertions. + +## Immediate implementation order + +1. Add Soundspace-specific fixture/resource builders to the existing Stripe provider: customers, products, prices, subscriptions, invoices, and checkout sequence helpers. +2. Add a generic DB snapshot/diff API with SQLite first and an adapter boundary for Postgres. +3. Add Acuity API provider for the endpoints listed above. +4. Add an Acuity iframe HTML-capture provider that can patch captured pages. +5. Add small JSON providers for HubSpot, Attio, and Google Places. +6. Add telemetry sinks. +7. Backport app seams in Soundspace: hardcoded Acuity API/iframe URLs, HubSpot gateway, Attio gateway, Places gateway. diff --git a/docs/reviews/stripe-provider-branch-review.md b/docs/reviews/stripe-provider-branch-review.md new file mode 100644 index 0000000..920fe13 --- /dev/null +++ b/docs/reviews/stripe-provider-branch-review.md @@ -0,0 +1,329 @@ + + +# Stripe provider branch review + +Review target: `vk/8f1c-fullcircle-harde` through commit `a3dd73f`. + +Validation run on 2026-06-11: + +- `npm test` passed. +- `npm run build` passed. + +This branch is a strong first slice: it adds a Stripe Checkout provider, signed webhook delivery, a dogfood acceptance test with SQLite diffs, and specs/case-study docs. The core direction is sound, but I would not call the provider product-ready for the intended Soundspace/agent workflow until the concerns below are resolved or explicitly scoped as follow-up work. + +## Concern 1: Checkout Session fixtures do not model Soundspace's actual embedded Checkout contract + +Soundspace's real `createCheckoutSession()` sends `customer`, `line_items`, `mode: 'subscription'`, `allow_promotion_codes`, optional `subscription_data`, `return_url`, `ui_mode: 'embedded'`, and metadata. It then requires `session.client_secret`; if the returned session lacks `client_secret`, Soundspace redirects to `/error`. + +The current `StripeCheckoutSessionFixture` does not include `client_secret`, `ui_mode`, or `return_url`, and `StripeCheckoutSessionCreateExpectation.match` cannot assert `customer`, `uiMode`, `returnUrl`, `allowPromotionCodes`, trial settings, or multiple line items. The dogfood acceptance test uses a simplified sample app with `success_url`/`cancel_url`, so it does not catch this gap. + +### Fix strategy A: Extend the generic Stripe provider now + +Add fields and matchers for `client_secret`, `customer`, `ui_mode`, `return_url`, `allow_promotion_codes`, `subscription_data.trial_*`, and an array of line-item matchers. Update tests to exercise Soundspace-like embedded Checkout payloads. + +Pros: + +- Makes the provider immediately useful for the intended Soundspace checkout flow. +- Keeps the API provider-general rather than Soundspace-specific. +- Prevents false confidence from the current simplified acceptance test. + +Cons: + +- Broadens the first Stripe provider API surface. +- Requires careful type design for nested Stripe form fields. + +### Fix strategy B: Add Soundspace-specific helpers on top of the current provider + +Keep the generic provider small, but add `stripe.checkout.sessions.createEmbeddedSubscription()` or a Soundspace fixture builder that fills in `client_secret`, `ui_mode`, `return_url`, and line-item expectations. + +Pros: + +- Faster and narrower than a generic nested-form matcher. +- Produces readable agent-facing APIs for the known first app. + +Cons: + +- Risks encoding Soundspace assumptions into the provider package. +- May duplicate logic once generic Stripe resource builders arrive. + +### Recommendation + +Use strategy A for the request/response contract and add a small helper as sugar only after the generic matcher supports Soundspace's real payload. + +## Concern 2: Stripe webhook type coverage is narrower than the documented Soundspace flow + +The provider supports `checkout.session.*`, `customer.subscription.*`, `invoice.paid`, and `invoice.payment_failed`. The branch spec and Soundspace code also need `invoice.created`, `invoice.finalization_failed`, and `invoice.payment_action_required`. Soundspace's `processStripeEvent()` explicitly handles those event types, including an `invoice.created` path that retrieves and finalizes draft invoices. + +### Fix strategy A: Expand `StripeWebhookType` and add fixture tests + +Add the missing invoice event types to the union and test sending each event with a valid signature. + +Pros: + +- Low-risk API expansion. +- Aligns provider types with the spec and Soundspace implementation. +- Prevents agents from dropping to untyped escape hatches for common events. + +Cons: + +- Still leaves resource-specific invoice helpers for later. + +### Fix strategy B: Replace the strict union with `string` plus known helper builders + +Let `webhooks.send(type: string, ...)` accept any Stripe event type, while separately exporting typed builders for known flows. + +Pros: + +- Avoids churn as Stripe adds event types. +- Lets advanced tests cover rare events immediately. + +Cons: + +- Loses compile-time typo protection on raw `send()` calls. +- Makes provider docs and generated skill guidance less precise. + +### Recommendation + +Use strategy A now and consider a separate `sendRaw()` escape hatch later if strict typing becomes burdensome. + +## Concern 3: The provider only covers Checkout Session endpoints, not the Stripe APIs Soundspace already uses + +Soundspace also calls Stripe customers, products, prices, subscriptions, invoices, customer portal, search/list endpoints, and product/price admin-sync flows. The new `docs/case-studies/soundspace-provider-plan.md` correctly inventories those, but the actual provider only implements: + +- `POST /v1/checkout/sessions` +- `GET /v1/checkout/sessions/:id` +- `GET /v1/checkout/sessions/:id/line_items` +- outbound webhook delivery + +### Fix strategy A: Incrementally implement provider methods by acceptance journey + +Implement the next endpoints only when a real acceptance journey needs them: account setup (`customers.list/create`, HubSpot/Attio), checkout completion (`checkout.retrieve`, `line_items`, webhooks), then subscription management (`subscriptions.*`, `invoices.*`). + +Pros: + +- Keeps the code TDD-driven and avoids speculative mocks. +- Produces real-world confidence per milestone. + +Cons: + +- Full provider completeness will take multiple iterations. +- Agents may hit unsupported endpoints during early dogfooding. + +### Fix strategy B: Generate broad raw Stripe endpoint responders from captured sessions + +Before writing typed helpers, make FullCircle capture/replay arbitrary Stripe routes from session files, then layer typed helpers over high-value endpoints. + +Pros: + +- Faster coverage for many APIs. +- Good fit for real sandbox-capture workflows. + +Cons: + +- Less readable and less intentional than resource builders. +- Harder to assert semantic correctness from e2e tests. + +### Recommendation + +Use both in order: add typed helpers for the checkout/account journeys first, and add capture/replay fallback for lower-value or rarely touched Stripe endpoints. + +## Concern 4: Request matching is too permissive in some places and too narrow in others + +The harness path match now ignores query strings, which lets `retrieve()` work with `expand[]=subscription` or `expand[]=line_items`. That is useful, but it also means the provider cannot assert required expand parameters. Checkout create matching is narrow in a different way: it only checks the first line item and selected metadata fields. + +### Fix strategy A: Add structured request expectations to provider methods + +Let provider methods accept `match.query`, `match.headers`, and richer `match.body`, including arrays and optional unordered matching for repeated Stripe fields. + +Pros: + +- Provider tests become more diagnostic. +- Agents can assert exact API contracts where it matters. + +Cons: + +- More matcher API design work. +- Overly strict tests could become brittle if users assert everything. + +### Fix strategy B: Keep default matching permissive but add `strict: true` + +Use today’s permissive defaults, but allow strict mode per fixture. + +Pros: + +- Good beginner ergonomics. +- Lets productized provider evolve without breaking easy tests. + +Cons: + +- Important assertions may be skipped unless agents know to opt in. + +### Recommendation + +Use strategy B at the public API level, implemented by strategy A internally. Default to helpful minimal assertions, but make strict matching easy and well-documented. + +## Concern 5: Harness mocks are single-use and ordered only by registration + +`TestHarness` marks a mock as called and will not match it again. That catches missing expected calls, but Stripe SDKs and app code may retry idempotent requests, retrieve the same object more than once, or call a list endpoint after a create endpoint in a flow not known up front. + +### Fix strategy A: Add invocation cardinality + +Allow `{times: 1}`, `{times: 2}`, `{times: 'any'}`, and `{min/max}` on `harness.mock()` and provider methods. + +Pros: + +- Maintains strong assertions while supporting retries/repeats. +- Clear failure messages can report actual call counts. + +Cons: + +- Requires changes to core harness bookkeeping. + +### Fix strategy B: Add explicit repeat helpers only in providers + +Keep core `harness.mock()` as single-use but let providers register repeated handlers internally. + +Pros: + +- Smaller core change. +- Provider APIs can stay business-oriented. + +Cons: + +- Repetition behavior becomes inconsistent between raw and provider mocks. + +### Recommendation + +Use strategy A. Cardinality belongs in the core harness because every provider will need it. + +## Concern 6: Native `better-sqlite3` is now a root dev dependency for one acceptance test + +The SQLite acceptance test is valuable because it dogfoods DB snapshots/diffs. However, `better-sqlite3` is a native module and increases install/CI risk, especially for a CLI/library that may otherwise be mostly TypeScript and HTTP tooling. + +### Fix strategy A: Keep `better-sqlite3` and commit to SQLite as a core dev/test dependency + +Pros: + +- Fast and reliable locally in this workspace. +- Aligns with the product direction of first-class SQLite support. +- Lets us build real snapshot/diff APIs without toy abstractions. + +Cons: + +- Native install friction on some platforms. +- Adds a large lockfile change for a single test before DB APIs are productized. + +### Fix strategy B: Move the SQLite dogfood app into a separate optional package/example + +Pros: + +- Keeps the root package lighter. +- Lets the DB harness have its own dependency lifecycle. + +Cons: + +- Makes the acceptance test less central. +- More workspace/package plumbing now. + +### Fix strategy C: Replace `better-sqlite3` in this test with an in-memory JS object until DB harness work starts + +Pros: + +- Lowest dependency risk. +- Still tests Stripe API/webhook flow. + +Cons: + +- Loses the strongest part of the acceptance test: real SQL row diffs. +- Defers database correctness concerns. + +### Recommendation + +Keep `better-sqlite3` for now, but make the next DB milestone extract snapshot/diff behind an adapter so the dependency is justified by product code, not just one test. + +## Concern 7: Explicit resource management (`await using`) is good in tests but should not be the only documented consumer pattern + +The `await using` tests are clean and passed under this repo’s TypeScript/Jest setup. They are a good fit for harness lifetime management. The risk is consumer friction: agents and downstream projects may not have TypeScript 5.2+ explicit resource management configured, and JavaScript consumers may prefer `try/finally`. + +### Fix strategy A: Keep `await using` as the preferred modern pattern and document prerequisites + +Pros: + +- Very readable lifecycle semantics. +- Prevents forgotten `close()`/assertion cleanup. + +Cons: + +- Requires modern TS/toolchain support. +- May confuse agents in older projects. + +### Fix strategy B: Provide `withFullCircle()` / `withHarness()` helpers + +Example: + +```ts +await withFullCircle(options, async (fc) => { + await withHarness(fc, 'api.stripe.com', async (harness) => { + // test body + }); +}); +``` + +Pros: + +- Works in older JS/TS projects. +- Still guarantees cleanup and assertion execution. + +Cons: + +- Slightly more nested than `await using`. +- Another API to document and support. + +### Recommendation + +Support both. Keep `await using` internally and in modern examples, but ship `withFullCircle()`/`withHarness()` as the agent-safe default. + +## Concern 8: Minor polish items should be cleaned before broad reuse + +Small issues observed during review: + +- `buildCheckoutSessionFixture()` has a duplicate `customer: 'cus_fullcircle_123'` property. It is harmless at runtime but noisy. +- The default webhook `api_version` is `2025-xx-xx.basil`, which is intentionally fake-looking. Tests should either omit it, require the caller to set it, or default to a real captured/configured API version. +- Some helper error messages stringify function matchers as source code or generic function text, which may be noisy for agents. + +### Fix strategy A: Fix polish immediately + +Pros: + +- Cheap cleanup. +- Reduces distraction in future reviews. + +Cons: + +- Does not materially change product readiness. + +### Fix strategy B: Fold into the next implementation milestone + +Pros: + +- Avoids a tiny standalone change. +- Lets changes happen alongside matcher/API improvements. + +Cons: + +- Easy to forget. + +### Recommendation + +Fix the duplicate `customer` immediately; handle `api_version` and matcher messages with the next provider API expansion. + +## Conclusion and succinct recommendations + +1. Extend Checkout Session create/retrieve fixtures to match Soundspace’s real embedded Checkout contract: `client_secret`, `ui_mode`, `return_url`, `customer`, promotion codes, trial data, and multiple line items. +2. Add missing Soundspace webhook event types now: `invoice.created`, `invoice.finalization_failed`, and `invoice.payment_action_required`. +3. Implement additional Stripe endpoints by acceptance journey, starting with customers, checkout completion, subscriptions, and invoices; add capture/replay fallback later. +4. Add structured request matching with permissive defaults and an easy strict mode for query/body/header assertions. +5. Add core harness cardinality (`times`, `min`, `max`, `any`) so providers can model retries and repeated retrieves. +6. Keep `better-sqlite3` for now, but extract real DB snapshot/diff product APIs next so the native dependency is justified. +7. Keep `await using`, but add `withFullCircle()`/`withHarness()` helpers for older projects and agent-generated tests. +8. Clean up minor polish: duplicate `customer`, fake default webhook `api_version`, and noisy matcher error text. diff --git a/docs/specs/stripe-checkout-subscriptions-fullcircle-spec.md b/docs/specs/stripe-checkout-subscriptions-fullcircle-spec.md new file mode 100644 index 0000000..3cde122 --- /dev/null +++ b/docs/specs/stripe-checkout-subscriptions-fullcircle-spec.md @@ -0,0 +1,522 @@ +# FullCircle Stripe Checkout Subscriptions Spec + +Status: discussion/specification draft +Bead: `fullcircle-ktt — Research Stripe Checkout flow spec` +Research date: 2026-06-11 + +## Goal + +Implement first-class FullCircle support for Stripe Checkout subscription flows so e2e tests can: + +1. Control Stripe API responses from the test. +2. Exercise app code that uses the real Stripe SDK/client boundary. +3. Simulate Stripe-hosted Checkout completion without requiring real browser interaction with Stripe. +4. Deliver Stripe-like webhook events with valid signatures. +5. Record, sanitize, and replay the provider API calls, webhook events, browser actions, and SQLite DB effects. + +This spec is scoped to **Checkout Sessions in `subscription` mode**. One-time payments, embedded Checkout, customer portal, metered billing, and Connect are future expansions. + +## Primary Stripe lifecycle to model + +For hosted Checkout, Stripe describes this lifecycle: + +1. App creates a Checkout Session. +2. Checkout Session returns a URL for a Stripe-hosted payment page. +3. Customer completes payment on the hosted page. +4. Stripe sends webhook events, especially `checkout.session.completed`, so the app can fulfill/provision access. + +FullCircle should model this lifecycle without requiring a real Stripe checkout page during replay. + +## App integration contract + +A FullCircle-compatible app should keep production Stripe code intact while allowing test/dev override of the Stripe API origin. + +```ts +import Stripe from 'stripe'; + +export function createStripeClient() { + return new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2025-xx-xx.basil', + // exact option depends on stripe-node version; provider adapter should document it + // and/or provide a factory wrapper for apps that opt in. + apiBase: process.env.FULLCIRCLE_STRIPE_API_BASE_URL, + }); +} +``` + +Minimum app env for FullCircle tests: + +```bash +STRIPE_SECRET_KEY=sk_test_fullcircle +STRIPE_WEBHOOK_SECRET=whsec_fullcircle_test_secret +FULLCIRCLE_STRIPE_API_BASE_URL=http://localhost:/provider/stripe +``` + +The webhook endpoint must verify signatures against `STRIPE_WEBHOOK_SECRET` and must consume the **raw request body**. + +## Required outgoing Stripe API calls + +### 1. Create Checkout Session + +HTTP request: + +```http +POST /v1/checkout/sessions +Authorization: Bearer sk_test_... +Content-Type: application/x-www-form-urlencoded +``` + +Minimum body for subscription Checkout: + +```txt +mode=subscription +success_url=https://app.example/billing/success?session_id={CHECKOUT_SESSION_ID} +cancel_url=https://app.example/billing/cancel +line_items[0][price]=price_... +line_items[0][quantity]=1 +``` + +Recommended reconciliation fields: + +```txt +client_reference_id= +metadata[userId]= +subscription_data[metadata][userId]= +``` + +Important Stripe semantics to model: + +- `mode=subscription` is required when the Checkout Session includes recurring items. +- `line_items` is required for `payment` and `subscription` mode. +- In `subscription` mode, Checkout creates/reuses a Customer and saves the payment method by default. +- A Checkout Session response contains `id`, `object=checkout.session`, `mode`, `status`, `payment_status`, `customer`, `subscription`, `success_url`, `cancel_url`, and `url`. +- `url` is the browser redirect target for hosted Checkout. + +FullCircle deterministic response: + +```json +{ + "id": "cs_test_fullcircle_123", + "object": "checkout.session", + "mode": "subscription", + "status": "open", + "payment_status": "unpaid", + "customer": "cus_fullcircle_123", + "subscription": null, + "client_reference_id": "user_test_123", + "metadata": { + "userId": "user_test_123" + }, + "success_url": "http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}", + "cancel_url": "http://localhost:3000/billing/cancel", + "url": "http://localhost:/stripe/checkout/cs_test_fullcircle_123" +} +``` + +### 2. Retrieve Checkout Session, optional but common + +Apps often retrieve the session on a success page or fulfillment path: + +```http +GET /v1/checkout/sessions/:id +``` + +Common expansions: + +```txt +expand[]=line_items +expand[]=subscription +expand[]=customer +``` + +FullCircle should support both: + +```ts +stripe.checkout.sessions.retrieve('cs_test_fullcircle_123') +``` + +and: + +```ts +stripe.checkout.sessions.retrieve('cs_test_fullcircle_123', { + expand: ['line_items', 'subscription', 'customer'], +}); +``` + +### 3. Retrieve line items, optional + +If the app needs product/price details, it may call: + +```http +GET /v1/checkout/sessions/:id/line_items +``` + +FullCircle should provide a deterministic `list` response matching the line items from the create request or session fixture. + +## Required webhook events + +FullCircle must be able to deliver these events to the app's webhook endpoint with a Stripe-compatible `Stripe-Signature` header. + +### Event signing + +Stripe signs webhook payloads with `Stripe-Signature`. The signature is HMAC-SHA256 over: + +```txt +. +``` + +Header format: + +```txt +Stripe-Signature: t=,v1= +``` + +FullCircle must: + +- preserve the exact raw JSON bytes it signs, +- send those same bytes to the app, +- support configurable timestamp, +- default to a fixed signing secret for deterministic tests, +- support invalid-signature scenarios. + +### 1. `checkout.session.completed` + +Purpose: customer completed Checkout; fulfill/provision the order. + +Canonical event fixture: + +```json +{ + "id": "evt_fullcircle_checkout_completed_123", + "object": "event", + "api_version": "2025-xx-xx.basil", + "created": 1760000000, + "livemode": false, + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_test_fullcircle_123", + "object": "checkout.session", + "mode": "subscription", + "status": "complete", + "payment_status": "paid", + "customer": "cus_fullcircle_123", + "subscription": "sub_fullcircle_123", + "client_reference_id": "user_test_123", + "metadata": { + "userId": "user_test_123" + } + } + } +} +``` + +FullCircle expected app behavior: + +- webhook returns 2xx quickly, +- app records event ID for idempotency, +- app either provisions immediately from the session or retrieves missing objects, +- app creates/updates internal subscription row. + +### 2. `customer.subscription.created` + +Purpose: subscription object was created. Stripe recommends using `customer.subscription` events to track subscription state. + +Canonical event fixture: + +```json +{ + "id": "evt_fullcircle_subscription_created_123", + "object": "event", + "type": "customer.subscription.created", + "data": { + "object": { + "id": "sub_fullcircle_123", + "object": "subscription", + "customer": "cus_fullcircle_123", + "status": "active", + "metadata": { + "userId": "user_test_123" + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_fullcircle_123", + "object": "subscription_item", + "price": { + "id": "price_fullcircle_pro_monthly", + "object": "price", + "recurring": { + "interval": "month" + } + } + } + ] + } + } + } +} +``` + +### 3. `invoice.paid` + +Purpose: invoice was paid. For subscriptions, Stripe docs describe provisioning access on `invoice.paid` when the subscription status is active. + +Canonical event fixture: + +```json +{ + "id": "evt_fullcircle_invoice_paid_123", + "object": "event", + "type": "invoice.paid", + "data": { + "object": { + "id": "in_fullcircle_123", + "object": "invoice", + "customer": "cus_fullcircle_123", + "subscription": "sub_fullcircle_123", + "status": "paid", + "paid": true, + "amount_paid": 2000, + "currency": "usd" + } + } +} +``` + +### 4. Failure/cancellation events to support in v1 test matrix + +FullCircle v1 should include fixtures for: + +- `checkout.session.expired` — user never completed Checkout. +- `checkout.session.async_payment_failed` — delayed payment failed. +- `checkout.session.async_payment_succeeded` — delayed payment later succeeded. +- `invoice.payment_failed` — payment failed; app should notify user and not grant/revoke access according to product rules. +- `customer.subscription.updated` — subscription status/price changed. +- `customer.subscription.deleted` — subscription canceled/ended; app should deprovision access. + +## Webhook delivery realities FullCircle must model + +Stripe webhook behavior has test implications: + +- Webhooks are the reliable source of payment/subscription completion; client redirects alone are not sufficient. +- Webhook event delivery is not guaranteed to be ordered. +- Duplicate deliveries can happen; apps should dedupe by event ID, or by event type plus `data.object.id` for duplicate Event objects. +- Live mode retries for up to three days; sandbox retries three times over a few hours. +- Handlers should respond quickly and move heavy work to a queue if needed. + +FullCircle should support: + +```ts +await stripe.webhooks.send('checkout.session.completed', fixture); +await stripe.webhooks.sendOutOfOrder([...events]); +await stripe.webhooks.sendDuplicate('evt_fullcircle_checkout_completed_123'); +await stripe.webhooks.sendInvalidSignature('checkout.session.completed', fixture); +``` + +## FullCircle Stripe provider API proposal + +```ts +export interface StripeProviderDsl { + checkout: { + sessions: { + create(expectation: StripeCheckoutSessionCreateExpectation): StripeExpectationBuilder; + retrieve(expectation: StripeCheckoutSessionRetrieveExpectation): StripeExpectationBuilder; + lineItems(expectation: StripeCheckoutSessionLineItemsExpectation): StripeExpectationBuilder; + }; + }; + + webhooks: StripeWebhookController; +} + +export interface StripeCheckoutSessionCreateExpectation { + match?: { + mode?: 'subscription'; + successUrl?: Matcher; + cancelUrl?: Matcher; + priceId?: Matcher; + quantity?: Matcher; + clientReferenceId?: Matcher; + metadata?: Record>; + subscriptionMetadata?: Record>; + }; + + reply?: Partial; +} + +export interface StripeWebhookController { + send( + type: TType, + event: StripeWebhookFixture, + options?: StripeWebhookSendOptions, + ): Promise; + + sendSequence( + events: StripeWebhookSequenceItem[], + options?: StripeWebhookSequenceOptions, + ): Promise; +} + +export interface StripeWebhookSendOptions { + to?: string; + signingSecret?: string; + timestamp?: number; + signatureMode?: 'valid' | 'invalid' | 'missing'; + idempotencyKey?: string; +} +``` + +Example authored session: + +```ts +await fullcircle.provider('stripe').session('checkout-subscription-success', stripe => { + stripe.checkout.sessions.create({ + match: { + mode: 'subscription', + priceId: 'price_pro_monthly', + clientReferenceId: 'user_test_123', + metadata: { userId: 'user_test_123' }, + }, + reply: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + }, + }); +}); +``` + +Example test webhook send: + +```ts +await fullcircle.provider('stripe').webhooks.send('checkout.session.completed', { + id: 'evt_fullcircle_checkout_completed_123', + data: { + object: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + subscription: 'sub_fullcircle_123', + client_reference_id: user.id, + metadata: { userId: user.id }, + }, + }, +}); +``` + +## Recording format requirements + +Canonical JSON session should store: + +```json +{ + "schemaVersion": "fullcircle.session.v1", + "provider": "stripe", + "name": "checkout-subscription-success", + "expectations": [ + { + "name": "create checkout session", + "request": { + "method": "POST", + "path": "/v1/checkout/sessions", + "bodyEncoding": "form-urlencoded", + "body": { + "mode": "subscription", + "line_items[0][price]": "price_pro_monthly", + "line_items[0][quantity]": "1" + } + }, + "response": { + "status": 200, + "json": { + "id": "cs_test_fullcircle_123", + "object": "checkout.session", + "url": "http://localhost:7331/stripe/checkout/cs_test_fullcircle_123" + } + } + } + ], + "webhooks": [ + { + "type": "checkout.session.completed", + "eventId": "evt_fullcircle_checkout_completed_123", + "signatureMode": "valid" + } + ] +} +``` + +## Redaction rules + +Always redact: + +- `Authorization` header, +- `Stripe-Signature` header in recorded fixtures unless intentionally testing signature parsing, +- `STRIPE_SECRET_KEY`, +- `STRIPE_WEBHOOK_SECRET`, +- customer email/name/address/phone unless explicitly kept, +- card/payment method details, +- idempotency keys if they include app secrets. + +Preserve deterministic synthetic IDs: + +- `cs_test_fullcircle_*` +- `cus_fullcircle_*` +- `sub_fullcircle_*` +- `evt_fullcircle_*` +- `price_fullcircle_*` + +## SQLite acceptance assertions for subscription checkout + +After successful replay, DB diff should show: + +- one inserted or updated user billing/customer mapping, +- one inserted subscription row or updated subscription status, +- one processed webhook event ID for idempotency. + +Example expected diff: + +```json +{ + "inserted": { + "subscriptions": [ + { + "user_id": "user_test_123", + "provider": "stripe", + "provider_customer_id": "cus_fullcircle_123", + "provider_subscription_id": "sub_fullcircle_123", + "status": "active" + } + ], + "processed_webhook_events": [ + { + "provider": "stripe", + "event_id": "evt_fullcircle_checkout_completed_123" + } + ] + } +} +``` + +## Minimum implementation checklist + +1. Parse Stripe SDK form-encoded requests to `/v1/checkout/sessions`. +2. Match subscription Checkout create requests by `mode`, price, quantity, success/cancel URLs, metadata, and client reference ID. +3. Return deterministic Checkout Session with hosted `url` pointing back into FullCircle. +4. Provide a synthetic hosted checkout page route that can simulate success/cancel. +5. Generate/send valid `Stripe-Signature` headers for webhook payloads. +6. Support `checkout.session.completed`, `customer.subscription.created`, and `invoice.paid` fixtures. +7. Record and replay webhook deliveries. +8. Add duplicate/out-of-order/invalid-signature test helpers. +9. Produce JSON session fixtures plus optional generated TypeScript helpers. +10. Add SQLite snapshot/diff assertions in the dogfood acceptance test. + +## Sources + +- Stripe Checkout lifecycle and hosted session flow: https://docs.stripe.com/payments/checkout/how-checkout-works.md?payment-ui=stripe-hosted +- Create Checkout Session API: https://docs.stripe.com/api/checkout/sessions/create +- Checkout Session object/API overview: https://docs.stripe.com/api/checkout/sessions +- Checkout fulfillment and `checkout.session.completed`: https://docs.stripe.com/checkout/fulfillment.md?payment-ui=stripe-hosted +- Webhook signature verification and raw body requirement: https://docs.stripe.com/webhooks.md#verify-events +- Webhook signature troubleshooting: https://docs.stripe.com/webhooks/signature.md +- Subscription webhook event guidance: https://docs.stripe.com/billing/subscriptions/webhooks.md diff --git a/example_app/server/package.json b/example_app/server/package.json index f35e090..276c3e7 100644 --- a/example_app/server/package.json +++ b/example_app/server/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "ts-node src/entrypoints/main_entrypoint.ts", - "start-with-fc": "PORT=9000 FULLCIRCLE_HOST=http://localhost:8000 ts-node src/entrypoints/fc_entrypoint.ts", + "start-with-fc": "ts-node src/entrypoints/fc_entrypoint.ts", "test-curl": "curl http://localhost:9000/api/todos/1", "fc-recorder": "PORT=8000 DESTINATION=jsonplaceholder.typicode.com DATA_LOG_OUT_DIR=\"$PWD/data_logs\" npm start --prefix ../../packages/recorder", "test": "jest", diff --git a/example_app/tests-e2e/playwright.config.ts b/example_app/tests-e2e/playwright.config.ts index b618565..c494eeb 100644 --- a/example_app/tests-e2e/playwright.config.ts +++ b/example_app/tests-e2e/playwright.config.ts @@ -67,11 +67,4 @@ export default defineConfig({ // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // }, ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, }); diff --git a/example_app/tests-e2e/tests/todos-view.spec.ts b/example_app/tests-e2e/tests/todos-view.spec.ts index e7d18ec..25829f1 100644 --- a/example_app/tests-e2e/tests/todos-view.spec.ts +++ b/example_app/tests-e2e/tests/todos-view.spec.ts @@ -1,39 +1,138 @@ +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); + +import {ChildProcess, spawn} from 'node:child_process'; +import net from 'node:net'; +import path from 'node:path'; + import {test, expect} from '@playwright/test'; -import {fullcircle} from '../../../packages/harness/src/fullcircle'; +import {fullcircle} from '../../../packages/harness/src/fullcircle'; import {Todo} from '../../server/src/types/model'; +const getFreePort = async (): Promise => new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Expected TCP address')); + return; + } + + const port = address.port; + server.close(() => resolve(port)); + }); +}); + +const waitForPort = async (port: number, timeoutMs = 10_000): Promise => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await canConnect(port)) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out waiting for example app to listen on port ${port}`); +}; + +const canConnect = (port: number): Promise => new Promise(resolve => { + const socket = net.connect(port, '127.0.0.1'); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); +}); + +const startExampleApp = async (externalUrl: string) => { + const port = await getFreePort(); + const serverDir = path.resolve(__dirname, '../../server'); + const child = spawn('npm', ['run', 'start-with-fc', '--prefix', serverDir], { + env: { + ...process.env, + PORT: String(port), + FULLCIRCLE_HOST: externalUrl, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const output: string[] = []; + child.stdout?.on('data', chunk => output.push(chunk.toString())); + child.stderr?.on('data', chunk => output.push(chunk.toString())); + + await waitForPort(port); + + return { + url: `http://127.0.0.1:${port}`, + close: () => stopProcess(child, output), + }; +}; + +const stopProcess = async (child: ChildProcess, output: string[]) => { + if (child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Timed out stopping example app:\n${output.join('')}`)); + }, 5_000); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); +}; + test('shows button', async ({page}) => { - await page.goto('http://localhost:9000'); + const app = await startExampleApp('http://127.0.0.1:1'); + try { + await page.goto(app.url); - await expect(page.locator('button')).toBeVisible(); + await expect(page.locator('button')).toBeVisible(); + } finally { + await app.close(); + } }); test('shows blank todos container', async ({page}) => { - await page.goto('http://localhost:9000'); + const app = await startExampleApp('http://127.0.0.1:1'); + try { + await page.goto(app.url); - await expect(page.locator('#todos-container')).toHaveText(''); + await expect(page.locator('#todos-container')).toHaveText(''); + } finally { + await app.close(); + } }); -test('shows todos from mocked response', async ({page}) => { - await using fc = await fullcircle({listenAddress: '8000', defaultDestination: 'jsonplaceholder.typicode.com'}); +test('shows todos from a FullCircle-controlled external service response', async ({page}) => { + const fullcirclePort = await getFreePort(); + await using fc = await fullcircle({listenAddress: fullcirclePort, defaultDestination: 'jsonplaceholder.typicode.com'}); await using harness = fc.harness('jsonplaceholder.typicode.com'); + const app = await startExampleApp(`http://127.0.0.1:${fullcirclePort}`); - await page.goto('http://localhost:9000'); + try { + await page.goto(app.url); - await expect(page.locator('#todos-container')).toHaveText(''); + await expect(page.locator('#todos-container')).toHaveText(''); - harness.mock('/todos', async (req, res) => { - const todos: Todo[] = [{ - id: 1, - userId: 1, - title: 'my todo', - completed: false, - }] - res.json(todos); - }); + harness.mock('/todos', async (req, res) => { + const todos: Todo[] = [{ + id: 1, + userId: 1, + title: 'my todo', + completed: false, + }]; + res.json(todos); + }); - await page.locator('button').click(); + await page.locator('button').click(); - await expect(page.locator('#todos-container')).toHaveText('my todo'); + await expect(page.locator('#todos-container')).toHaveText('my todo'); + } finally { + await app.close(); + } }); diff --git a/package-lock.json b/package-lock.json index 8a72655..acf2a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "fc-record": "dist/recorder.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.8", + "better-sqlite3": "^12.10.0", "jest": "^29.7.0", "jest-mock-server": "^0.1.0", "node-fetch": "^2.7.0", @@ -1797,6 +1799,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -2312,6 +2324,42 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2320,6 +2368,28 @@ "node": ">=8" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -2481,6 +2551,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2632,6 +2727,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2864,6 +2966,22 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -2884,6 +3002,16 @@ "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", "dev": true }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2937,6 +3065,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3035,6 +3173,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3161,6 +3309,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -3346,6 +3504,13 @@ "bser": "2.1.1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3478,6 +3643,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3557,6 +3729,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3773,6 +3952,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -3833,6 +4033,13 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5063,6 +5270,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5104,6 +5324,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5119,6 +5346,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5133,6 +5367,32 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5457,6 +5717,34 @@ } } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5508,6 +5796,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5611,12 +5910,53 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5932,6 +6272,53 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -5992,6 +6379,16 @@ "node": ">= 0.6" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6239,6 +6636,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6605,6 +7032,19 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6693,6 +7133,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8199,6 +8646,15 @@ "@babel/types": "^7.20.7" } }, + "@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -8659,11 +9115,47 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "dev": true, + "requires": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -8782,6 +9274,16 @@ "node-int64": "^0.4.0" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8875,6 +9377,12 @@ "readdirp": "~3.6.0" } }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -9050,6 +9558,15 @@ "ms": "2.1.2" } }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, "dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -9063,6 +9580,12 @@ "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", "dev": true }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, "deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -9100,6 +9623,12 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -9177,6 +9706,15 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, + "end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -9271,6 +9809,12 @@ "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true + }, "expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -9434,6 +9978,12 @@ "bser": "2.1.1" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -9534,6 +10084,12 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9585,6 +10141,12 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9737,6 +10299,12 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, "ignore": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -9779,6 +10347,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10712,6 +11286,12 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10738,6 +11318,12 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -10753,6 +11339,12 @@ "thenify-all": "^1.0.0" } }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10764,6 +11356,23 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "requires": { + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true + } + } + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10975,6 +11584,26 @@ "yaml": "^2.3.4" } }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + } + }, "pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -11013,6 +11642,16 @@ "ipaddr.js": "1.9.1" } }, + "pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11073,12 +11712,43 @@ } } }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + } + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -11302,6 +11972,23 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11350,6 +12037,15 @@ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -11526,6 +12222,31 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11767,6 +12488,15 @@ } } }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -11813,6 +12543,12 @@ "picocolors": "^1.0.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 5c5f8c9..418a05b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "recorder": "DATA_LOG_OUT_DIR=\"$PWD/data_logs\" npm start --prefix packages/recorder", "build": "tsup --entry.harness packages/harness/src/index.ts --entry.recorder packages/recorder/src/index.ts --format esm,cjs --dts", "prepare": "npm run build", - "test": "jest && npm test --prefix example_app/server" + "test": "jest --runInBand && npm test --prefix example_app/server" }, "bin": { "fc-record": "./dist/recorder.js" @@ -25,7 +25,9 @@ "typescript": "^5.2.2" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.8", + "better-sqlite3": "^12.10.0", "jest": "^29.7.0", "jest-mock-server": "^0.1.0", "node-fetch": "^2.7.0", diff --git a/packages/harness/src/fullcircle.ts b/packages/harness/src/fullcircle.ts index 1adfec9..703195a 100644 --- a/packages/harness/src/fullcircle.ts +++ b/packages/harness/src/fullcircle.ts @@ -25,6 +25,8 @@ export class FullCircleInstance { } initialize = async () => { + this.expressApp.use(express.urlencoded({extended: false})); + this.expressApp.use(express.json()); this.expressApp.use(this.initializeSubscriptionRouter()); this.expressApp.use(this.initializeNotFoundRouter()); diff --git a/packages/harness/src/harness.ts b/packages/harness/src/harness.ts index 13bded6..15966b1 100644 --- a/packages/harness/src/harness.ts +++ b/packages/harness/src/harness.ts @@ -22,6 +22,7 @@ export class TestHarness { private onRequest: SubscriptionFunc = async (req, res, next): Promise => { const path = req.originalUrl; + const pathWithoutQuery = req.path; let destinationHost = this.fc.options.defaultDestination; @@ -43,7 +44,7 @@ export class TestHarness { } // gets first registered mock that hasn't been called - const mock = this.registeredMocks.find(m => m.path === path && !m.called); + const mock = this.registeredMocks.find(m => (m.path === path || m.path === pathWithoutQuery) && !m.called); if (mock) { mock.called = true; @@ -51,7 +52,7 @@ export class TestHarness { return true; } - const passthrough = this.registeredMocks.find(m => m.path === path && !m.called); + const passthrough = this.registeredPassthroughs.find(m => (m.path === path || m.path === pathWithoutQuery) && !m.called); if (passthrough) { passthrough.called = true; diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index a0546c9..5d914a0 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -1,2 +1,3 @@ export * from './fullcircle'; export * from './harness'; +export * from './providers/stripe'; diff --git a/packages/harness/src/providers/stripe.ts b/packages/harness/src/providers/stripe.ts new file mode 100644 index 0000000..ca12291 --- /dev/null +++ b/packages/harness/src/providers/stripe.ts @@ -0,0 +1,388 @@ +import crypto from 'node:crypto'; + +import type express from 'express'; + +import type {TestHarness} from '../harness'; + +type MaybeMatcher = T | ((actual: T | undefined) => boolean); + +type StripeFormBody = Record; + +export type StripeCheckoutSessionFixture = { + id: string; + object: 'checkout.session'; + mode: 'subscription'; + status: 'open' | 'complete' | 'expired'; + payment_status: 'paid' | 'unpaid' | 'no_payment_required'; + customer: string | null; + subscription: string | null; + client_reference_id: string | null; + metadata: Record; + success_url: string | null; + cancel_url: string | null; + url: string | null; + livemode: boolean; +}; + +export type StripeCheckoutSessionCreateExpectation = { + match?: { + mode?: MaybeMatcher; + successUrl?: MaybeMatcher; + cancelUrl?: MaybeMatcher; + priceId?: MaybeMatcher; + quantity?: MaybeMatcher; + clientReferenceId?: MaybeMatcher; + metadata?: Record>; + subscriptionMetadata?: Record>; + }; + reply?: Partial; +}; + +export type StripeCheckoutSessionRetrieveExpectation = { + id: string; + reply?: Partial; +}; + +export type StripeCheckoutSessionLineItemsExpectation = { + sessionId: string; + reply?: { + data: object[]; + }; +}; + +export type StripeWebhookType = + | 'checkout.session.completed' + | 'checkout.session.async_payment_succeeded' + | 'checkout.session.async_payment_failed' + | 'checkout.session.expired' + | 'customer.subscription.created' + | 'customer.subscription.updated' + | 'customer.subscription.deleted' + | 'invoice.paid' + | 'invoice.payment_failed'; + +export type StripeWebhookFixture = Partial<{ + id: string; + object: 'event'; + api_version: string; + created: number; + livemode: boolean; + type: TType; + data: { + object: Record; + }; +}> & { + data: { + object: Record; + }; +}; + +export type StripeWebhookSendOptions = { + to?: string; + signingSecret?: string; + timestamp?: number; + signatureMode?: 'valid' | 'invalid' | 'missing'; +}; + +export type StripeWebhookSequenceItem = { + type: StripeWebhookType; + event: StripeWebhookFixture; + options?: StripeWebhookSendOptions; +}; + +export type WebhookDeliveryResult = { + ok: boolean; + status: number; + body: string; +}; + +export type StripeProviderOptions = { + webhookEndpoint?: string; + webhookSigningSecret?: string; + apiVersion?: string; +}; + +export type StripeProviderHarness = { + checkout: { + sessions: { + create: (expectation: StripeCheckoutSessionCreateExpectation) => void; + retrieve: (expectation: StripeCheckoutSessionRetrieveExpectation) => void; + lineItems: (expectation: StripeCheckoutSessionLineItemsExpectation) => void; + }; + }; + webhooks: { + send: ( + type: TType, + event: StripeWebhookFixture, + options?: StripeWebhookSendOptions, + ) => Promise; + sendSequence: ( + events: StripeWebhookSequenceItem[], + options?: StripeWebhookSendOptions, + ) => Promise; + }; +}; + +const CHECKOUT_SESSIONS_PATH = '/v1/checkout/sessions'; + +export const stripeProvider = (harness: TestHarness, options: StripeProviderOptions = {}): StripeProviderHarness => ({ + checkout: { + sessions: { + create: (expectation) => { + harness.mock(CHECKOUT_SESSIONS_PATH, makeCheckoutSessionCreateHandler(expectation)); + }, + retrieve: (expectation) => { + harness.mock(`${CHECKOUT_SESSIONS_PATH}/${expectation.id}`, (req, res) => { + if (req.method !== 'GET') { + res.status(405).json({error: 'Expected GET for Stripe Checkout Session retrieve'}); + return; + } + + res.json(buildCheckoutSessionFixture({}, {id: expectation.id, ...expectation.reply})); + }); + }, + lineItems: (expectation) => { + harness.mock(`${CHECKOUT_SESSIONS_PATH}/${expectation.sessionId}/line_items`, (req, res) => { + if (req.method !== 'GET') { + res.status(405).json({error: 'Expected GET for Stripe Checkout Session line_items'}); + return; + } + + res.json({ + object: 'list', + data: expectation.reply?.data || [], + has_more: false, + url: `${CHECKOUT_SESSIONS_PATH}/${expectation.sessionId}/line_items`, + }); + }); + }, + }, + }, + webhooks: { + send: (type, event, sendOptions) => sendStripeWebhook(type, event, options, sendOptions), + sendSequence: async (events, sendOptions) => { + const results: WebhookDeliveryResult[] = []; + for (const item of events) { + results.push(await sendStripeWebhook( + item.type, + item.event, + options, + {...sendOptions, ...item.options}, + )); + } + return results; + }, + }, +}); + +const makeCheckoutSessionCreateHandler = (expectation: StripeCheckoutSessionCreateExpectation): express.Handler => { + return (req, res) => { + if (req.method !== 'POST') { + res.status(405).json({error: 'Expected POST for Stripe Checkout Session create'}); + return; + } + + const body = normalizeStripeFormBody(req.body); + const mismatches = collectCheckoutSessionCreateMismatches(body, expectation); + + if (mismatches.length) { + res.status(422).json({ + error: 'Stripe Checkout Session create request did not match expectations', + mismatches, + }); + return; + } + + res.json(buildCheckoutSessionFixture(body, expectation.reply)); + }; +}; + +const collectCheckoutSessionCreateMismatches = ( + body: StripeFormBody, + expectation: StripeCheckoutSessionCreateExpectation, +): string[] => { + const match = expectation.match; + if (!match) { + return []; + } + + const mismatches: string[] = []; + + assertMatch(mismatches, 'mode', getString(body, 'mode'), match.mode); + assertMatch(mismatches, 'success_url', getString(body, 'success_url'), match.successUrl); + assertMatch(mismatches, 'cancel_url', getString(body, 'cancel_url'), match.cancelUrl); + assertMatch(mismatches, 'line_items[0][price]', getString(body, 'line_items[0][price]'), match.priceId); + assertMatch(mismatches, 'line_items[0][quantity]', getNumber(body, 'line_items[0][quantity]'), match.quantity); + assertMatch(mismatches, 'client_reference_id', getString(body, 'client_reference_id'), match.clientReferenceId); + + for (const [key, matcher] of Object.entries(match.metadata || {})) { + assertMatch(mismatches, `metadata[${key}]`, getString(body, `metadata[${key}]`), matcher); + } + + for (const [key, matcher] of Object.entries(match.subscriptionMetadata || {})) { + assertMatch(mismatches, `subscription_data[metadata][${key}]`, getString(body, `subscription_data[metadata][${key}]`), matcher); + } + + return mismatches; +}; + +const assertMatch = ( + mismatches: string[], + field: string, + actual: T | undefined, + matcher: MaybeMatcher | undefined, +) => { + if (matcher === undefined) { + return; + } + + const matched = typeof matcher === 'function' + ? matcher(actual) + : actual === matcher; + + if (!matched) { + mismatches.push(`Expected ${field} to match ${String(matcher)} but received ${String(actual)}`); + } +}; + +const buildCheckoutSessionFixture = ( + body: StripeFormBody, + reply: Partial = {}, +): StripeCheckoutSessionFixture => { + const metadata = collectMetadata(body, 'metadata'); + + return { + id: 'cs_test_fullcircle_123', + object: 'checkout.session', + mode: 'subscription', + status: 'open', + payment_status: 'unpaid', + customer: 'cus_fullcircle_123', + subscription: null, + client_reference_id: getString(body, 'client_reference_id') || null, + metadata, + success_url: getString(body, 'success_url') || null, + cancel_url: getString(body, 'cancel_url') || null, + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + livemode: false, + ...reply, + }; +}; + +const normalizeStripeFormBody = (body: unknown): StripeFormBody => { + if (!body || typeof body !== 'object') { + return {}; + } + + return body as StripeFormBody; +}; + +const getString = (body: StripeFormBody, key: string): string | undefined => { + const value = body[key]; + if (Array.isArray(value)) { + return typeof value[0] === 'string' ? value[0] : undefined; + } + + return typeof value === 'string' ? value : undefined; +}; + +const getNumber = (body: StripeFormBody, key: string): number | undefined => { + const value = getString(body, key); + if (value === undefined) { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const collectMetadata = (body: StripeFormBody, prefix: string): Record => { + const metadata: Record = {}; + const startsWith = `${prefix}[`; + + for (const [key, value] of Object.entries(body)) { + if (!key.startsWith(startsWith) || !key.endsWith(']')) { + continue; + } + + if (typeof value !== 'string') { + continue; + } + + const metadataKey = key.slice(startsWith.length, -1); + metadata[metadataKey] = value; + } + + return metadata; +}; + + +const sendStripeWebhook = async ( + type: TType, + event: StripeWebhookFixture, + providerOptions: StripeProviderOptions, + sendOptions: StripeWebhookSendOptions = {}, +): Promise => { + const endpoint = sendOptions.to || providerOptions.webhookEndpoint; + if (!endpoint) { + throw new Error('Stripe webhook endpoint is required. Pass webhookEndpoint to stripeProvider or to webhooks.send().'); + } + + const signingSecret = sendOptions.signingSecret || providerOptions.webhookSigningSecret || 'whsec_fullcircle_test_secret'; + const timestamp = sendOptions.timestamp || Math.floor(Date.now() / 1000); + const payload = JSON.stringify(buildStripeWebhookEvent(type, event, providerOptions)); + const headers: Record = { + 'content-type': 'application/json', + }; + + const signatureMode = sendOptions.signatureMode || 'valid'; + if (signatureMode !== 'missing') { + headers['stripe-signature'] = makeStripeSignatureHeader( + payload, + signingSecret, + timestamp, + signatureMode, + ); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: payload, + }); + + return { + ok: response.ok, + status: response.status, + body: await response.text(), + }; +}; + +const buildStripeWebhookEvent = ( + type: TType, + event: StripeWebhookFixture, + providerOptions: StripeProviderOptions, +) => ({ + id: event.id || `evt_fullcircle_${type.replaceAll('.', '_')}`, + object: 'event' as const, + api_version: event.api_version || providerOptions.apiVersion || '2025-xx-xx.basil', + created: event.created || Math.floor(Date.now() / 1000), + livemode: event.livemode ?? false, + type, + data: event.data, +}); + +const makeStripeSignatureHeader = ( + payload: string, + signingSecret: string, + timestamp: number, + signatureMode: 'valid' | 'invalid', +): string => { + const secret = signatureMode === 'valid' ? signingSecret : `${signingSecret}_invalid`; + const signature = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${payload}`) + .digest('hex'); + + return `t=${timestamp},v1=${signature}`; +}; diff --git a/packages/harness/tests/harness.test.ts b/packages/harness/tests/harness.test.ts index 5fb8925..44a2b78 100644 --- a/packages/harness/tests/harness.test.ts +++ b/packages/harness/tests/harness.test.ts @@ -187,6 +187,30 @@ describe('Harness tests', () => { expect(blockedFinished).toBe(true); }); + + it('harness.passthrough - fake fetch - should route registered passthrough path', async () => { + await using fc = await fullcircle({ + listenAddress: null, + }); + + const app = fc.expressApp; + + { + await using th = fc.harness('api.github.com'); + + th.passthrough('/api/repos', (req, res) => { + res.json({data: 'My passthrough data'}); + }); + + const response = await request(app) + .get('/api/repos') + .set('original_host', 'api.github.com') + .expect(200); + + expect(response.body).toEqual({data: 'My passthrough data'}); + } + }); + }); const logError = (err: any) => { diff --git a/packages/harness/tests/providers/stripe.test.ts b/packages/harness/tests/providers/stripe.test.ts new file mode 100644 index 0000000..33fd80b --- /dev/null +++ b/packages/harness/tests/providers/stripe.test.ts @@ -0,0 +1,183 @@ +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); + +import request from 'supertest'; + +import {fullcircle} from '../../src/fullcircle'; +import {stripeProvider} from '../../src/providers/stripe'; + +describe('Stripe provider harness', () => { + it('matches subscription Checkout Session create requests and returns deterministic session fixtures', async () => { + await using fc = await fullcircle({ + listenAddress: null, + defaultDestination: 'api.stripe.com', + }); + + await using th = fc.harness('api.stripe.com'); + stripeProvider(th).checkout.sessions.create({ + match: { + mode: 'subscription', + priceId: 'price_pro_monthly', + quantity: 1, + clientReferenceId: 'user_test_123', + metadata: { + userId: 'user_test_123', + }, + subscriptionMetadata: { + userId: 'user_test_123', + }, + }, + reply: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + }, + }); + + const response = await request(fc.expressApp) + .post('/v1/checkout/sessions') + .type('form') + .send({ + mode: 'subscription', + success_url: 'http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'http://localhost:3000/billing/cancel', + client_reference_id: 'user_test_123', + 'line_items[0][price]': 'price_pro_monthly', + 'line_items[0][quantity]': '1', + 'metadata[userId]': 'user_test_123', + 'subscription_data[metadata][userId]': 'user_test_123', + }) + .expect(200); + + expect(response.body).toMatchObject({ + id: 'cs_test_fullcircle_123', + object: 'checkout.session', + mode: 'subscription', + status: 'open', + payment_status: 'unpaid', + customer: 'cus_fullcircle_123', + subscription: null, + client_reference_id: 'user_test_123', + metadata: { + userId: 'user_test_123', + }, + success_url: 'http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'http://localhost:3000/billing/cancel', + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + }); + }); + + it('rejects Checkout Session create requests that do not match expected Stripe parameters', async () => { + await using fc = await fullcircle({ + listenAddress: null, + defaultDestination: 'api.stripe.com', + }); + + await using th = fc.harness('api.stripe.com'); + stripeProvider(th).checkout.sessions.create({ + match: { + mode: 'subscription', + priceId: 'price_pro_monthly', + quantity: 1, + }, + }); + + const response = await request(fc.expressApp) + .post('/v1/checkout/sessions') + .type('form') + .send({ + mode: 'subscription', + 'line_items[0][price]': 'price_wrong', + 'line_items[0][quantity]': '1', + }) + .expect(422); + + expect(response.body).toEqual({ + error: 'Stripe Checkout Session create request did not match expectations', + mismatches: ['Expected line_items[0][price] to match price_pro_monthly but received price_wrong'], + }); + }); + + it('returns deterministic retrieve and line item fixtures for recorded Checkout Sessions', async () => { + await using fc = await fullcircle({ + listenAddress: null, + defaultDestination: 'api.stripe.com', + }); + + await using th = fc.harness('api.stripe.com'); + const stripe = stripeProvider(th); + stripe.checkout.sessions.retrieve({ + id: 'cs_test_fullcircle_123', + reply: { + id: 'cs_test_fullcircle_123', + status: 'complete', + payment_status: 'paid', + subscription: 'sub_fullcircle_123', + }, + }); + stripe.checkout.sessions.lineItems({ + sessionId: 'cs_test_fullcircle_123', + reply: { + data: [{ + id: 'li_fullcircle_123', + object: 'item', + price: { + id: 'price_pro_monthly', + }, + quantity: 1, + }], + }, + }); + + const sessionResponse = await request(fc.expressApp) + .get('/v1/checkout/sessions/cs_test_fullcircle_123?expand[]=line_items') + .expect(200); + + expect(sessionResponse.body).toMatchObject({ + id: 'cs_test_fullcircle_123', + object: 'checkout.session', + status: 'complete', + payment_status: 'paid', + subscription: 'sub_fullcircle_123', + }); + + const lineItemsResponse = await request(fc.expressApp) + .get('/v1/checkout/sessions/cs_test_fullcircle_123/line_items') + .expect(200); + + expect(lineItemsResponse.body).toEqual({ + object: 'list', + data: [{ + id: 'li_fullcircle_123', + object: 'item', + price: { + id: 'price_pro_monthly', + }, + quantity: 1, + }], + has_more: false, + url: '/v1/checkout/sessions/cs_test_fullcircle_123/line_items', + }); + }); + + it('uses the requested Checkout Session id as the default retrieve fixture id', async () => { + await using fc = await fullcircle({ + listenAddress: null, + defaultDestination: 'api.stripe.com', + }); + + await using th = fc.harness('api.stripe.com'); + stripeProvider(th).checkout.sessions.retrieve({ + id: 'cs_test_custom_session', + }); + + const sessionResponse = await request(fc.expressApp) + .get('/v1/checkout/sessions/cs_test_custom_session') + .expect(200); + + expect(sessionResponse.body).toMatchObject({ + id: 'cs_test_custom_session', + object: 'checkout.session', + }); + }); + +}); diff --git a/packages/harness/tests/providers/stripe_checkout_acceptance.test.ts b/packages/harness/tests/providers/stripe_checkout_acceptance.test.ts new file mode 100644 index 0000000..5e85bdb --- /dev/null +++ b/packages/harness/tests/providers/stripe_checkout_acceptance.test.ts @@ -0,0 +1,268 @@ +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); + +import crypto from 'node:crypto'; +import http from 'node:http'; +import net from 'node:net'; + +import Database from 'better-sqlite3'; +import express from 'express'; +import fetch from 'node-fetch'; +import request from 'supertest'; + +import {fullcircle} from '../../src/fullcircle'; +import {stripeProvider} from '../../src/providers/stripe'; + +const STRIPE_WEBHOOK_SECRET = 'whsec_fullcircle_test_secret'; + +type Snapshot = Record>>; + +const getFreePort = async (): Promise => new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Expected TCP address')); + return; + } + + const port = address.port; + server.close(() => resolve(port)); + }); +}); + +const readRawBody = (req: express.Request): Promise => new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); +}); + +const verifyStripeSignature = (payload: Buffer, signatureHeader: string | undefined) => { + if (!signatureHeader) { + return false; + } + + const parts = Object.fromEntries(signatureHeader.split(',').map(part => part.split('='))); + if (!parts.t || !parts.v1) { + return false; + } + + const expected = crypto + .createHmac('sha256', STRIPE_WEBHOOK_SECRET) + .update(`${parts.t}.${payload.toString('utf8')}`) + .digest('hex'); + + return expected === parts.v1; +}; + +const createDatabase = () => { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL + ); + CREATE TABLE subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_customer_id TEXT NOT NULL, + provider_subscription_id TEXT NOT NULL, + status TEXT NOT NULL + ); + CREATE TABLE processed_webhook_events ( + event_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + type TEXT NOT NULL + ); + `); + + return db; +}; + +const snapshotDatabase = (db: Database.Database): Snapshot => ({ + users: db.prepare('SELECT * FROM users ORDER BY id').all() as Array>, + subscriptions: db.prepare('SELECT * FROM subscriptions ORDER BY id').all() as Array>, + processed_webhook_events: db.prepare('SELECT * FROM processed_webhook_events ORDER BY event_id').all() as Array>, +}); + +const diffInsertedRows = (before: Snapshot, after: Snapshot, table: keyof Snapshot, key: string) => { + const beforeKeys = new Set(before[table].map(row => row[key])); + return after[table].filter(row => !beforeKeys.has(row[key])); +}; + +const initBillingApp = (input: {db: Database.Database; stripeBaseUrl: string}) => { + const app = express(); + + app.post('/stripe/webhook', async (req, res) => { + const rawBody = await readRawBody(req); + if (!verifyStripeSignature(rawBody, req.header('stripe-signature'))) { + res.status(400).json({error: 'invalid stripe signature'}); + return; + } + + const event = JSON.parse(rawBody.toString('utf8')); + const session = event.data.object; + const userId = session.metadata?.userId || session.client_reference_id; + + const insertEvent = input.db.prepare('INSERT OR IGNORE INTO processed_webhook_events (event_id, provider, type) VALUES (?, ?, ?)'); + const eventResult = insertEvent.run(event.id, 'stripe', event.type); + if (eventResult.changes === 0) { + res.status(204).end(); + return; + } + + if (event.type === 'checkout.session.completed') { + input.db.prepare(` + INSERT INTO subscriptions (user_id, provider, provider_customer_id, provider_subscription_id, status) + VALUES (?, ?, ?, ?, ?) + `).run(userId, 'stripe', session.customer, session.subscription, 'active'); + } + + res.status(204).end(); + }); + + app.use(express.json()); + + app.post('/api/test/login', (req, res) => { + input.db.prepare('INSERT OR IGNORE INTO users (id, email) VALUES (?, ?)') + .run('user_test_123', 'customer@example.com'); + res.json({id: 'user_test_123', email: 'customer@example.com'}); + }); + + app.get('/billing', (req, res) => { + const subscription = input.db.prepare('SELECT * FROM subscriptions WHERE user_id = ? AND status = ?') + .get('user_test_123', 'active'); + const plan = subscription ? 'Pro' : 'Free'; + res.type('html').send(`

Billing

Current plan: ${plan}

`); + }); + + app.post('/api/billing/checkout', async (req, res) => { + const stripeResponse = await fetch(`${input.stripeBaseUrl}/v1/checkout/sessions`, { + method: 'POST', + headers: { + authorization: 'Bearer sk_test_fullcircle', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + mode: 'subscription', + success_url: 'http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'http://localhost:3000/billing/cancel', + client_reference_id: 'user_test_123', + 'line_items[0][price]': 'price_pro_monthly', + 'line_items[0][quantity]': '1', + 'metadata[userId]': 'user_test_123', + 'subscription_data[metadata][userId]': 'user_test_123', + }).toString(), + }); + + res.status(stripeResponse.status).json(await stripeResponse.json()); + }); + + return app; +}; + +describe('Stripe Checkout subscription dogfood acceptance', () => { + it('upgrades a user from Free to Pro through FullCircle-controlled Stripe API and signed webhook with SQLite diffs', async () => { + const fullcirclePort = await getFreePort(); + await using fc = await fullcircle({listenAddress: fullcirclePort, defaultDestination: 'api.stripe.com'}); + await using th = fc.harness('api.stripe.com'); + + const stripe = stripeProvider(th, { + webhookSigningSecret: STRIPE_WEBHOOK_SECRET, + }); + stripe.checkout.sessions.create({ + match: { + mode: 'subscription', + priceId: 'price_pro_monthly', + quantity: 1, + clientReferenceId: 'user_test_123', + metadata: {userId: 'user_test_123'}, + subscriptionMetadata: {userId: 'user_test_123'}, + }, + reply: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + }, + }); + + const db = createDatabase(); + const app = initBillingApp({ + db, + stripeBaseUrl: `http://127.0.0.1:${fullcirclePort}`, + }); + const appServer = await new Promise(resolve => { + const listener = app.listen(0, () => resolve(listener)); + }); + + try { + const address = appServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected app server TCP address'); + } + const webhookUrl = `http://127.0.0.1:${address.port}/stripe/webhook`; + + await request(app).post('/api/test/login').expect(200); + const before = snapshotDatabase(db); + + await request(app) + .get('/billing') + .expect(200) + .expect(response => expect(response.text).toContain('Current plan: Free')); + + const checkoutResponse = await request(app) + .post('/api/billing/checkout') + .expect(200); + + expect(checkoutResponse.body).toMatchObject({ + id: 'cs_test_fullcircle_123', + object: 'checkout.session', + mode: 'subscription', + customer: 'cus_fullcircle_123', + url: 'http://localhost:7331/stripe/checkout/cs_test_fullcircle_123', + }); + + const webhookResult = await stripe.webhooks.send('checkout.session.completed', { + id: 'evt_fullcircle_checkout_completed_123', + data: { + object: { + id: 'cs_test_fullcircle_123', + object: 'checkout.session', + mode: 'subscription', + status: 'complete', + payment_status: 'paid', + customer: 'cus_fullcircle_123', + subscription: 'sub_fullcircle_123', + client_reference_id: 'user_test_123', + metadata: {userId: 'user_test_123'}, + }, + }, + }, {to: webhookUrl, timestamp: 1760000000}); + + expect(webhookResult).toMatchObject({ok: true, status: 204}); + + await request(app) + .get('/billing') + .expect(200) + .expect(response => expect(response.text).toContain('Current plan: Pro')); + + const after = snapshotDatabase(db); + expect(diffInsertedRows(before, after, 'subscriptions', 'id')).toMatchObject([{ + user_id: 'user_test_123', + provider: 'stripe', + provider_customer_id: 'cus_fullcircle_123', + provider_subscription_id: 'sub_fullcircle_123', + status: 'active', + }]); + expect(diffInsertedRows(before, after, 'processed_webhook_events', 'event_id')).toEqual([{ + event_id: 'evt_fullcircle_checkout_completed_123', + provider: 'stripe', + type: 'checkout.session.completed', + }]); + } finally { + await new Promise((resolve, reject) => appServer.close(err => err ? reject(err) : resolve())); + db.close(); + } + }); +}); diff --git a/packages/harness/tests/providers/stripe_webhooks.test.ts b/packages/harness/tests/providers/stripe_webhooks.test.ts new file mode 100644 index 0000000..0213239 --- /dev/null +++ b/packages/harness/tests/providers/stripe_webhooks.test.ts @@ -0,0 +1,152 @@ +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); + +import crypto from 'node:crypto'; +import http from 'node:http'; + +import express from 'express'; + +import {fullcircle} from '../../src/fullcircle'; +import {stripeProvider} from '../../src/providers/stripe'; + +const readRawBody = (req: express.Request): Promise => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +}; + +const verifyStripeSignature = (payload: Buffer, signatureHeader: string | undefined, secret: string) => { + if (!signatureHeader) { + return false; + } + + const parts = Object.fromEntries(signatureHeader.split(',').map(part => part.split('='))); + const timestamp = parts.t; + const signature = parts.v1; + if (!timestamp || !signature) { + return false; + } + + const expected = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${payload.toString('utf8')}`) + .digest('hex'); + + return expected === signature; +}; + +describe('Stripe provider webhooks', () => { + let server: http.Server | undefined; + let webhookUrl: string; + let receivedEvents: Array<{body: any; signature?: string; signatureValid: boolean}>; + + beforeEach(async () => { + receivedEvents = []; + const app = express(); + app.post('/stripe/webhook', async (req, res) => { + const rawBody = await readRawBody(req); + const signature = req.header('stripe-signature'); + receivedEvents.push({ + body: JSON.parse(rawBody.toString('utf8')), + signature, + signatureValid: verifyStripeSignature(rawBody, signature, 'whsec_fullcircle_test_secret'), + }); + res.status(204).end(); + }); + + server = await new Promise(resolve => { + const listener = app.listen(0, () => resolve(listener)); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected test webhook server to listen on a TCP address'); + } + webhookUrl = `http://127.0.0.1:${address.port}/stripe/webhook`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server?.close(err => err ? reject(err) : resolve()); + }); + }); + + it('sends checkout.session.completed with a valid Stripe-Signature header', async () => { + await using fc = await fullcircle({listenAddress: null}); + await using th = fc.harness('api.stripe.com'); + const stripe = stripeProvider(th, { + webhookEndpoint: webhookUrl, + webhookSigningSecret: 'whsec_fullcircle_test_secret', + }); + + const result = await stripe.webhooks.send('checkout.session.completed', { + id: 'evt_fullcircle_checkout_completed_123', + data: { + object: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + subscription: 'sub_fullcircle_123', + }, + }, + }, {timestamp: 1760000000}); + + expect(result).toMatchObject({status: 204, ok: true}); + expect(receivedEvents).toHaveLength(1); + expect(receivedEvents[0]).toMatchObject({ + signatureValid: true, + body: { + id: 'evt_fullcircle_checkout_completed_123', + object: 'event', + type: 'checkout.session.completed', + livemode: false, + data: { + object: { + id: 'cs_test_fullcircle_123', + customer: 'cus_fullcircle_123', + subscription: 'sub_fullcircle_123', + }, + }, + }, + }); + expect(receivedEvents[0]?.signature).toMatch(/^t=1760000000,v1=[a-f0-9]{64}$/); + }); + + it('supports invalid and missing webhook signature modes for negative tests', async () => { + await using fc = await fullcircle({listenAddress: null}); + await using th = fc.harness('api.stripe.com'); + const stripe = stripeProvider(th, { + webhookEndpoint: webhookUrl, + webhookSigningSecret: 'whsec_fullcircle_test_secret', + }); + + await stripe.webhooks.send('invoice.paid', {data: {object: {id: 'in_fullcircle_123'}}}, {signatureMode: 'invalid'}); + await stripe.webhooks.send('customer.subscription.created', {data: {object: {id: 'sub_fullcircle_123'}}}, {signatureMode: 'missing'}); + + expect(receivedEvents).toHaveLength(2); + expect(receivedEvents[0]?.signature).toBeTruthy(); + expect(receivedEvents[0]?.signatureValid).toBe(false); + expect(receivedEvents[1]?.signature).toBeUndefined(); + expect(receivedEvents[1]?.signatureValid).toBe(false); + }); + + it('delivers webhook sequences in caller-defined order', async () => { + await using fc = await fullcircle({listenAddress: null}); + await using th = fc.harness('api.stripe.com'); + const stripe = stripeProvider(th, { + webhookEndpoint: webhookUrl, + webhookSigningSecret: 'whsec_fullcircle_test_secret', + }); + + const results = await stripe.webhooks.sendSequence([ + {type: 'invoice.paid', event: {id: 'evt_invoice', data: {object: {id: 'in_123'}}}}, + {type: 'checkout.session.completed', event: {id: 'evt_checkout', data: {object: {id: 'cs_123'}}}}, + ]); + + expect(results.map(result => result.status)).toEqual([204, 204]); + expect(receivedEvents.map(event => event.body.type)).toEqual([ + 'invoice.paid', + 'checkout.session.completed', + ]); + }); +});