diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..bd9b2fc2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + merge_group: + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: ${{ github.workflow }}-lint-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Lint + run: bun lint + + - name: Format + run: bun format:check + + typecheck: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: ${{ github.workflow }}-typecheck-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun typecheck + + unit: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-unit-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install + run: bun install --frozen-lockfile + + - name: Unit tests + run: bun test:unit diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5e51c5cb..bb65d30c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,14 +2,84 @@ name: E2E on: workflow_dispatch: + inputs: + target: + description: E2E target to run + required: true + type: choice + default: all + options: + - all + - cli + - stripe + - polar schedule: - cron: "0 6 * * *" permissions: {} jobs: + cli: + name: CLI E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'cli' }} + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-stripe + cancel-in-progress: false + timeout-minutes: 30 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} + E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Validate CLI E2E configuration + run: | + set -euo pipefail + for name in E2E_STRIPE_SK E2E_STRIPE_WHSEC TEST_DATABASE_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required configuration: $name" + exit 1 + fi + done + + - name: Run CLI E2E suite + run: bun --filter e2e test:cli + stripe: name: Stripe E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'stripe' }} runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-stripe @@ -32,7 +102,6 @@ jobs: --health-timeout 5s --health-retries 5 env: - CF_TUNNEL_HOST: ${{ vars.CF_TUNNEL_HOST_STRIPE || 't1.paykit.sh' }} E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres @@ -91,15 +160,32 @@ jobs: trap cleanup EXIT - sleep 5 + readiness_timeout_s=30 + readiness_deadline=$((SECONDS + readiness_timeout_s)) + + while true; do + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi - if ! kill -0 "$cloudflared_pid" 2>/dev/null; then - echo "::error::cloudflared exited before tests started" - if [ -f cloudflared.log ]; then - cat cloudflared.log + if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then + break fi - exit 1 - fi + + if [ "$SECONDS" -ge "$readiness_deadline" ]; then + echo "::error::Timed out waiting for cloudflared readiness" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + sleep 0.5 + done bun --filter e2e test:stripe @@ -110,3 +196,123 @@ jobs: if-no-files-found: ignore name: stripe-e2e-cloudflared-log path: cloudflared.log + + polar: + name: Polar E2E + if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'polar' }} + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-polar + cancel-in-progress: false + timeout-minutes: 60 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + E2E_POLAR_ACCESS_TOKEN: ${{ secrets.E2E_POLAR_ACCESS_TOKEN }} + E2E_POLAR_WHSEC: ${{ secrets.E2E_POLAR_WHSEC }} + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Validate Polar E2E configuration + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_POLAR }} + run: | + set -euo pipefail + for name in CF_TUNNEL_TOKEN E2E_POLAR_ACCESS_TOKEN E2E_POLAR_WHSEC TEST_DATABASE_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required configuration: $name" + exit 1 + fi + done + + - name: Setup cloudflared + uses: AnimMouse/setup-cloudflared@v2 + + - name: Check cloudflared version + run: cloudflared --version + + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + + - name: Run Polar E2E suite + env: + CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_POLAR }} + run: | + set -euo pipefail + cloudflared tunnel --url http://127.0.0.1:4567 run --token "$CF_TUNNEL_TOKEN" > cloudflared.log 2>&1 & + cloudflared_pid=$! + + cleanup() { + if kill -0 "$cloudflared_pid" 2>/dev/null; then + kill "$cloudflared_pid" || true + wait "$cloudflared_pid" || true + fi + } + + trap cleanup EXIT + + readiness_timeout_s=30 + readiness_deadline=$((SECONDS + readiness_timeout_s)) + + while true; do + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "::error::cloudflared exited before tests started" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then + break + fi + + if [ "$SECONDS" -ge "$readiness_deadline" ]; then + echo "::error::Timed out waiting for cloudflared readiness" + if [ -f cloudflared.log ]; then + cat cloudflared.log + fi + exit 1 + fi + + sleep 0.5 + done + + bun --filter e2e test:polar + + - name: Upload cloudflared log + if: failure() + uses: actions/upload-artifact@v4 + with: + if-no-files-found: ignore + name: polar-e2e-cloudflared-log + path: cloudflared.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd98a166..75fde211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ jobs: release: runs-on: ubuntu-latest permissions: + actions: read + checks: read contents: write id-token: write steps: @@ -19,6 +21,24 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Verify E2E gate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + required_checks=("CLI E2E" "Stripe E2E" "Polar E2E") + + for check_name in "${required_checks[@]}"; do + conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --jq ".check_runs | map(select(.app.slug == \"github-actions\" and .name == \"${check_name}\")) | sort_by(.completed_at) | last | .conclusion // \"missing\"") + + if [ "$conclusion" != "success" ]; then + echo "::error::Required E2E check '$check_name' is '$conclusion' for commit ${GITHUB_SHA}. Run the full E2E workflow on this exact SHA before releasing." + exit 1 + fi + done + + echo "Found successful CLI, Stripe, and Polar E2E checks for ${GITHUB_SHA}" + - uses: oven-sh/setup-bun@v2 - uses: actions/setup-node@v4 diff --git a/bun.lock b/bun.lock index bd29c466..1945ef97 100644 --- a/bun.lock +++ b/bun.lock @@ -11,11 +11,11 @@ "lint-staged": "^16.2.7", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", - "simple-git-hooks": "^2.13.1", "tinyglobby": "^0.2.16", "tsx": "^4.21.0", "turbo": "^2.8.10", "typescript": "catalog:", + "vitest": "^4.0.18", }, }, "apps/demo": { @@ -2214,8 +2214,6 @@ "signal-exit": ["signal-exit@4.1.0", "https://registry.better-npm.dev/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-git-hooks": ["simple-git-hooks@2.13.1", "https://registry.better-npm.dev/simple-git-hooks/-/simple-git-hooks-2.13.1.tgz", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="], - "sisteransi": ["sisteransi@1.0.5", "https://registry.better-npm.dev/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slice-ansi": ["slice-ansi@8.0.0", "https://registry.better-npm.dev/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index 8b74b8fe..632ab478 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { Pool } from "pg"; import { default as Stripe } from "stripe"; -import { env } from "../env"; +import { env } from "../test-utils/env"; process.env.PAYKIT_CLI = "1"; @@ -32,7 +32,7 @@ export async function createCliFixture(_globalKey: string): Promise { let t: TestPayKit; diff --git a/e2e/smoke/cancel/downgrade-change-target.test.ts b/e2e/core/cancel/downgrade-change-target.test.ts similarity index 98% rename from e2e/smoke/cancel/downgrade-change-target.test.ts rename to e2e/core/cancel/downgrade-change-target.test.ts index 5f047a10..261b8fb4 100644 --- a/e2e/smoke/cancel/downgrade-change-target.test.ts +++ b/e2e/core/cancel/downgrade-change-target.test.ts @@ -10,7 +10,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-change-target: ultra → pro (scheduled) → free (change target)", () => { let t: TestPayKit; diff --git a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts b/e2e/core/checkout/resubscribe-after-cancel.test.ts similarity index 96% rename from e2e/smoke/checkout/resubscribe-after-cancel.test.ts rename to e2e/core/checkout/resubscribe-after-cancel.test.ts index 548ef0d4..05f497e6 100644 --- a/e2e/smoke/checkout/resubscribe-after-cancel.test.ts +++ b/e2e/core/checkout/resubscribe-after-cancel.test.ts @@ -11,7 +11,7 @@ import { expectSingleActivePlanInGroup, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("resubscribe-after-cancel: checkout after full cancellation", () => { let t: TestPayKit; @@ -98,9 +98,8 @@ describe("resubscribe-after-cancel: checkout after full cancellation", () => { throw new Error("Expected checkout URL but got direct subscription"); } - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl); - // Wait for manual checkout completion await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts b/e2e/core/checkout/subscribe-paid-checkout.test.ts similarity index 87% rename from e2e/smoke/checkout/subscribe-paid-checkout.test.ts rename to e2e/core/checkout/subscribe-paid-checkout.test.ts index 77db99d9..17ea1d66 100644 --- a/e2e/smoke/checkout/subscribe-paid-checkout.test.ts +++ b/e2e/core/checkout/subscribe-paid-checkout.test.ts @@ -9,9 +9,9 @@ import { expectSubscription, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; -describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { +describe("subscribe-paid-checkout: free → pro via checkout", () => { let t: TestPayKit; let customerId: string; @@ -33,7 +33,7 @@ describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { await t?.cleanup(); }); - it("subscribing without a payment method returns a checkout URL, completing it activates the plan", async () => { + it("subscribing without a payment method returns a checkout URL; completing it activates the plan", async () => { try { const beforeCheckout = new Date(); @@ -48,9 +48,8 @@ describe("subscribe-paid-checkout: free → pro via checkout (manual)", () => { throw new Error("Expected checkout URL but got direct subscription"); } - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl); - // Wait for checkout.completed webhook (manual completion required) await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/customer/default-free.test.ts b/e2e/core/customer/default-free.test.ts similarity index 98% rename from e2e/smoke/customer/default-free.test.ts rename to e2e/core/customer/default-free.test.ts index defd3023..b3555106 100644 --- a/e2e/smoke/customer/default-free.test.ts +++ b/e2e/core/customer/default-free.test.ts @@ -9,7 +9,7 @@ import { expectProduct, expectSingleActivePlanInGroup, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("default-free: customer creation", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/check-boolean.test.ts b/e2e/core/entitlements/check-boolean.test.ts similarity index 98% rename from e2e/smoke/entitlements/check-boolean.test.ts rename to e2e/core/entitlements/check-boolean.test.ts index 9eb3ddd6..75a8a85b 100644 --- a/e2e/smoke/entitlements/check-boolean.test.ts +++ b/e2e/core/entitlements/check-boolean.test.ts @@ -6,7 +6,7 @@ import { dumpStateOnFailure, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("check-boolean: boolean feature access", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/check-metered.test.ts b/e2e/core/entitlements/check-metered.test.ts similarity index 98% rename from e2e/smoke/entitlements/check-metered.test.ts rename to e2e/core/entitlements/check-metered.test.ts index 6ce5280a..ce4e8b47 100644 --- a/e2e/smoke/entitlements/check-metered.test.ts +++ b/e2e/core/entitlements/check-metered.test.ts @@ -7,7 +7,7 @@ import { expectExactMeteredBalance, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("check-metered: metered feature balance and usage reporting", () => { let t: TestPayKit; diff --git a/e2e/smoke/entitlements/stacked-metered.test.ts b/e2e/core/entitlements/stacked-metered.test.ts similarity index 98% rename from e2e/smoke/entitlements/stacked-metered.test.ts rename to e2e/core/entitlements/stacked-metered.test.ts index 58504160..3279a1e4 100644 --- a/e2e/smoke/entitlements/stacked-metered.test.ts +++ b/e2e/core/entitlements/stacked-metered.test.ts @@ -7,7 +7,7 @@ import { expectExactMeteredBalance, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("stacked-metered: cross-group entitlement aggregation", () => { let t: TestPayKit; diff --git a/e2e/smoke/lifecycle/subscription.test.ts b/e2e/core/lifecycle/subscription.test.ts similarity index 97% rename from e2e/smoke/lifecycle/subscription.test.ts rename to e2e/core/lifecycle/subscription.test.ts index 2a521409..e66b4d40 100644 --- a/e2e/smoke/lifecycle/subscription.test.ts +++ b/e2e/core/lifecycle/subscription.test.ts @@ -18,7 +18,7 @@ import { expectSingleScheduledPlanInGroup, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("subscription lifecycle", () => { let t: TestPayKit; @@ -169,10 +169,8 @@ describe("subscription lifecycle", () => { // No payment method → should return checkout URL expect(result.paymentUrl).not.toBeNull(); - // Log the checkout URL for manual completion - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl!); - // Wait for checkout.completed webhook after manual completion await waitForWebhook({ database: t.database, eventType: "checkout.completed", @@ -386,7 +384,7 @@ describe("subscription lifecycle", () => { // After full cancellation, Stripe clears the payment method. // Customer must go through checkout again. expect(result.paymentUrl).not.toBeNull(); - console.log("\n\n ▶ Complete checkout at:\n " + result.paymentUrl + "\n"); + await t.harness.completeCheckout(result.paymentUrl!); await waitForWebhook({ database: t.database, eventType: "checkout.completed", diff --git a/e2e/smoke/subscribe/cancel-end-of-cycle.test.ts b/e2e/core/subscribe/cancel-end-of-cycle.test.ts similarity index 99% rename from e2e/smoke/subscribe/cancel-end-of-cycle.test.ts rename to e2e/core/subscribe/cancel-end-of-cycle.test.ts index 3745a781..4bedf016 100644 --- a/e2e/smoke/subscribe/cancel-end-of-cycle.test.ts +++ b/e2e/core/subscribe/cancel-end-of-cycle.test.ts @@ -15,7 +15,7 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe.skipIf(!harness.capabilities.testClocks)( "cancel-end-of-cycle: pro → free + clock advance", diff --git a/e2e/smoke/subscribe/cancel-resume.test.ts b/e2e/core/subscribe/cancel-resume.test.ts similarity index 98% rename from e2e/smoke/subscribe/cancel-resume.test.ts rename to e2e/core/subscribe/cancel-resume.test.ts index 63b6c92a..d77fa1e2 100644 --- a/e2e/smoke/subscribe/cancel-resume.test.ts +++ b/e2e/core/subscribe/cancel-resume.test.ts @@ -11,7 +11,7 @@ import { expectSubscription, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("cancel-resume: pro → free → pro (resume)", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/downgrade-scheduled.test.ts b/e2e/core/subscribe/downgrade-scheduled.test.ts similarity index 98% rename from e2e/smoke/subscribe/downgrade-scheduled.test.ts rename to e2e/core/subscribe/downgrade-scheduled.test.ts index a1c12b2f..2b8a8c12 100644 --- a/e2e/smoke/subscribe/downgrade-scheduled.test.ts +++ b/e2e/core/subscribe/downgrade-scheduled.test.ts @@ -9,7 +9,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-scheduled: ultra → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/downgrade-to-free.test.ts b/e2e/core/subscribe/downgrade-to-free.test.ts similarity index 98% rename from e2e/smoke/subscribe/downgrade-to-free.test.ts rename to e2e/core/subscribe/downgrade-to-free.test.ts index 17a9faf1..589e44ff 100644 --- a/e2e/smoke/subscribe/downgrade-to-free.test.ts +++ b/e2e/core/subscribe/downgrade-to-free.test.ts @@ -10,7 +10,7 @@ import { expectSingleScheduledPlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("downgrade-to-free: pro → free", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/renewal.test.ts b/e2e/core/subscribe/renewal.test.ts similarity index 99% rename from e2e/smoke/subscribe/renewal.test.ts rename to e2e/core/subscribe/renewal.test.ts index adb33a62..fd6af866 100644 --- a/e2e/smoke/subscribe/renewal.test.ts +++ b/e2e/core/subscribe/renewal.test.ts @@ -14,7 +14,7 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe.skipIf(!harness.capabilities.testClocks)( "renewal: pro subscription renews after 1 month", diff --git a/e2e/smoke/subscribe/same-plan-noop.test.ts b/e2e/core/subscribe/same-plan-noop.test.ts similarity index 99% rename from e2e/smoke/subscribe/same-plan-noop.test.ts rename to e2e/core/subscribe/same-plan-noop.test.ts index bd557450..1a21e5da 100644 --- a/e2e/smoke/subscribe/same-plan-noop.test.ts +++ b/e2e/core/subscribe/same-plan-noop.test.ts @@ -11,7 +11,7 @@ import { expectSingleActivePlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("same-plan-noop: pro → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/subscribe-paid.test.ts b/e2e/core/subscribe/subscribe-paid.test.ts similarity index 98% rename from e2e/smoke/subscribe/subscribe-paid.test.ts rename to e2e/core/subscribe/subscribe-paid.test.ts index 3a310dbf..699047d9 100644 --- a/e2e/smoke/subscribe/subscribe-paid.test.ts +++ b/e2e/core/subscribe/subscribe-paid.test.ts @@ -11,7 +11,7 @@ import { expectSubscription, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("subscribe-paid: free → pro", () => { let t: TestPayKit; diff --git a/e2e/smoke/subscribe/upgrade-immediate.test.ts b/e2e/core/subscribe/upgrade-immediate.test.ts similarity index 98% rename from e2e/smoke/subscribe/upgrade-immediate.test.ts rename to e2e/core/subscribe/upgrade-immediate.test.ts index c6345682..07b10446 100644 --- a/e2e/smoke/subscribe/upgrade-immediate.test.ts +++ b/e2e/core/subscribe/upgrade-immediate.test.ts @@ -10,7 +10,7 @@ import { expectSingleActivePlanInGroup, subscribeCustomer, type TestPayKit, -} from "../setup"; +} from "../../test-utils"; describe("upgrade-immediate: pro → ultra", () => { let t: TestPayKit; diff --git a/e2e/smoke/webhook/duplicate-webhook.test.ts b/e2e/core/webhook/duplicate-webhook.test.ts similarity index 99% rename from e2e/smoke/webhook/duplicate-webhook.test.ts rename to e2e/core/webhook/duplicate-webhook.test.ts index d919ee6c..a614bba3 100644 --- a/e2e/smoke/webhook/duplicate-webhook.test.ts +++ b/e2e/core/webhook/duplicate-webhook.test.ts @@ -15,7 +15,7 @@ import { type TestPayKit, waitForForwardedWebhookRequest, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; describe("duplicate-webhook: same event delivered twice", () => { let t: TestPayKit; diff --git a/e2e/smoke/webhook/subscription-deleted.test.ts b/e2e/core/webhook/subscription-deleted.test.ts similarity index 95% rename from e2e/smoke/webhook/subscription-deleted.test.ts rename to e2e/core/webhook/subscription-deleted.test.ts index b78b9a61..fb1a7036 100644 --- a/e2e/smoke/webhook/subscription-deleted.test.ts +++ b/e2e/core/webhook/subscription-deleted.test.ts @@ -3,7 +3,6 @@ import { default as Stripe } from "stripe"; import { afterAll, beforeAll, describe, it } from "vitest"; import { subscription } from "../../../packages/paykit/src/database/schema"; -import { env } from "../../env"; import { createTestCustomerWithPM, createTestPayKit, @@ -14,7 +13,8 @@ import { subscribeCustomer, type TestPayKit, waitForWebhook, -} from "../setup"; +} from "../../test-utils"; +import { env } from "../../test-utils/env"; describe.skipIf(harness.id !== "stripe")( "subscription-deleted: Stripe cancels subscription directly", @@ -22,7 +22,7 @@ describe.skipIf(harness.id !== "stripe")( let t: TestPayKit; let customerId: string; let providerSubscriptionId: string; - const stripeClient = new Stripe(env.E2E_STRIPE_SK!); + const stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 }); beforeAll(async () => { t = await createTestPayKit(); diff --git a/e2e/package.json b/e2e/package.json index aa2a95c7..df4ada7e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,14 +3,12 @@ "private": true, "type": "module", "scripts": { - "test": "vitest run --config smoke/vitest.automated.config.ts", - "test:stripe": "PROVIDER=stripe vitest run --config smoke/vitest.automated.config.ts", - "test:polar": "PROVIDER=polar vitest run --config smoke/vitest.automated.config.ts", - "test:watch": "vitest --config smoke/vitest.automated.config.ts", - "test:manual": "vitest run --config smoke/vitest.manual.config.ts", - "test:all": "vitest run --config smoke/vitest.config.ts", - "test:cli": "vitest run --config cli/vitest.config.ts", - "test:cli:watch": "vitest --config cli/vitest.config.ts", + "test:stripe": "PROVIDER=stripe vitest run --project=core", + "test:stripe:watch": "PROVIDER=stripe vitest --project=core", + "test:polar": "PROVIDER=polar vitest run --project=core", + "test:polar:watch": "PROVIDER=polar vitest --project=core", + "test:cli": "vitest run --project=cli", + "test:cli:watch": "vitest --project=cli", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/e2e/smoke/harness/stripe.ts b/e2e/smoke/harness/stripe.ts deleted file mode 100644 index 0e830e23..00000000 --- a/e2e/smoke/harness/stripe.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { stripe } from "@paykitjs/stripe"; -import { default as Stripe } from "stripe"; - -import type { PayKitDatabase } from "../../../packages/paykit/src/database/index"; -import { syncPaymentMethodByProviderCustomer } from "../../../packages/paykit/src/payment-method/payment-method.service"; -import { env } from "../../env"; -import type { ProviderHarness } from "./types"; - -export function createStripeHarness(): ProviderHarness { - const secretKey = env.E2E_STRIPE_SK; - const webhookSecret = env.E2E_STRIPE_WHSEC; - if (!secretKey || !webhookSecret) { - throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); - } - - const stripeClient = new Stripe(secretKey); - - return { - id: "stripe", - capabilities: { - testClocks: true, - directSubscription: true, - }, - - createProviderConfig() { - return stripe({ secretKey, webhookSecret }); - }, - - async setupCustomerForDirectSubscription(providerCustomerId: string) { - const pm = await stripeClient.paymentMethods.attach("pm_card_visa", { - customer: providerCustomerId, - }); - await stripeClient.customers.update(providerCustomerId, { - invoice_settings: { default_payment_method: pm.id }, - }); - }, - - async completeCheckout(_url: string) { - throw new Error("Stripe direct-subscription tests should not need checkout completion"); - }, - - async cleanup(ctx) { - // Delete test clocks for all customers - for (const providerCustomerId of ctx.providerCustomerIds) { - try { - const customer = await stripeClient.customers.retrieve(providerCustomerId); - if ("deleted" in customer && customer.deleted) continue; - const testClockId = (customer as Stripe.Customer).test_clock; - if (testClockId && typeof testClockId === "string") { - await stripeClient.testHelpers.testClocks.del(testClockId).catch(() => {}); - } - } catch { - // Customer may already be deleted - } - } - }, - - validateEnv() { - if (!env.E2E_STRIPE_SK || !env.E2E_STRIPE_WHSEC) { - throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); - } - }, - }; -} - -/** Sync a Stripe payment method into the PayKit database. */ -export async function syncStripePaymentMethod(input: { - database: PayKitDatabase; - providerCustomerId: string; - providerId: string; - stripeClient: Stripe; -}): Promise { - const pm = await input.stripeClient.paymentMethods.list({ - customer: input.providerCustomerId, - type: "card", - limit: 1, - }); - const method = pm.data[0]; - if (!method) return; - - await syncPaymentMethodByProviderCustomer(input.database, { - paymentMethod: { - providerMethodId: method.id, - type: method.type, - last4: method.card?.last4, - expiryMonth: method.card?.exp_month, - expiryYear: method.card?.exp_year, - isDefault: true, - }, - providerCustomerId: input.providerCustomerId, - providerId: input.providerId, - }); -} diff --git a/e2e/smoke/vitest.automated.config.ts b/e2e/smoke/vitest.automated.config.ts deleted file mode 100644 index 9a423132..00000000 --- a/e2e/smoke/vitest.automated.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: ["smoke/**/*.test.ts"], - exclude: [ - "**/smoke/checkout/resubscribe-after-cancel.test.ts", - "**/smoke/checkout/subscribe-paid-checkout.test.ts", - "**/smoke/lifecycle/subscription.test.ts", - ], - }, -}); diff --git a/e2e/smoke/vitest.config.ts b/e2e/smoke/vitest.config.ts deleted file mode 100644 index 54ddcf90..00000000 --- a/e2e/smoke/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: ["smoke/**/*.test.ts"], - }, -}); diff --git a/e2e/smoke/vitest.manual.config.ts b/e2e/smoke/vitest.manual.config.ts deleted file mode 100644 index 2f9a51a9..00000000 --- a/e2e/smoke/vitest.manual.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "vitest/config"; - -import { smokeVitestTestConfig } from "./vitest.shared"; - -export default defineConfig({ - test: { - ...smokeVitestTestConfig, - include: [ - "smoke/checkout/resubscribe-after-cancel.test.ts", - "smoke/checkout/subscribe-paid-checkout.test.ts", - "smoke/lifecycle/subscription.test.ts", - ], - }, -}); diff --git a/e2e/smoke/vitest.shared.ts b/e2e/smoke/vitest.shared.ts deleted file mode 100644 index 541117b3..00000000 --- a/e2e/smoke/vitest.shared.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const smokeVitestTestConfig = { - env: { NODE_ENV: "production" }, - fileParallelism: false, - hookTimeout: 180_000, - maxWorkers: 1, - minWorkers: 1, - sequence: { concurrent: false }, - testTimeout: 600_000, -} as const; diff --git a/e2e/env.ts b/e2e/test-utils/env.ts similarity index 76% rename from e2e/env.ts rename to e2e/test-utils/env.ts index 477395cd..bf644fec 100644 --- a/e2e/env.ts +++ b/e2e/test-utils/env.ts @@ -4,8 +4,12 @@ import { createEnv } from "@t3-oss/env-core"; import { config } from "dotenv"; import * as z from "zod"; -config({ path: path.resolve(import.meta.dirname, "../.env"), quiet: true }); -config({ path: path.resolve(import.meta.dirname, "../.env.local"), override: true, quiet: true }); +config({ path: path.resolve(import.meta.dirname, "../../.env"), quiet: true }); +config({ + path: path.resolve(import.meta.dirname, "../../.env.local"), + override: true, + quiet: true, +}); export const env = createEnv({ server: { diff --git a/e2e/smoke/harness/index.ts b/e2e/test-utils/harness/index.ts similarity index 94% rename from e2e/smoke/harness/index.ts rename to e2e/test-utils/harness/index.ts index 806313a4..9b3424e6 100644 --- a/e2e/smoke/harness/index.ts +++ b/e2e/test-utils/harness/index.ts @@ -1,4 +1,4 @@ -import { env } from "../../env"; +import { env } from "../env"; import { createPolarHarness } from "./polar"; import { createStripeHarness } from "./stripe"; import type { ProviderHarness } from "./types"; diff --git a/e2e/smoke/harness/polar.ts b/e2e/test-utils/harness/polar.ts similarity index 98% rename from e2e/smoke/harness/polar.ts rename to e2e/test-utils/harness/polar.ts index ceb3f483..86185cfa 100644 --- a/e2e/smoke/harness/polar.ts +++ b/e2e/test-utils/harness/polar.ts @@ -1,7 +1,7 @@ import { polar } from "@paykitjs/polar"; import { chromium } from "playwright"; -import { env } from "../../env"; +import { env } from "../env"; import type { ProviderHarness } from "./types"; export function createPolarHarness(): ProviderHarness { diff --git a/e2e/test-utils/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts new file mode 100644 index 00000000..2c0c5ff6 --- /dev/null +++ b/e2e/test-utils/harness/stripe.ts @@ -0,0 +1,157 @@ +import { stripe } from "@paykitjs/stripe"; +import { chromium } from "playwright"; +import { default as Stripe } from "stripe"; + +import type { PaymentProvider } from "../../../packages/paykit/src/providers/provider"; +import { env } from "../env"; +import type { ProviderHarness } from "./types"; + +export function createStripeHarness(): ProviderHarness { + validateStripeEnv(); + const secretKey = env.E2E_STRIPE_SK; + const webhookSecret = env.E2E_STRIPE_WHSEC; + + const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); + + return { + id: "stripe", + capabilities: { + testClocks: true, + directSubscription: true, + }, + + createProviderConfig() { + return stripe({ secretKey, webhookSecret }); + }, + + applyTestingOverrides(ctx) { + // Stripe's real createSubscription uses payment_behavior: "default_incomplete", + // which requires client-side confirmation via Stripe.js. In tests we want the + // subscription to activate straight away from the server after a PM is attached. + const provider = ctx.provider as PaymentProvider; + provider.createSubscription = async ( + data: Parameters[0], + ) => { + const sub = await stripeClient.subscriptions.create({ + customer: data.providerCustomerId, + items: [{ price: data.providerProduct.priceId }], + payment_behavior: "allow_incomplete", + expand: ["latest_invoice"], + }); + + const firstItem = sub.items.data[0]; + const periodStart = firstItem?.current_period_start ?? null; + const periodEnd = firstItem?.current_period_end ?? null; + const latestInvoice = sub.latest_invoice; + const inv = + latestInvoice && typeof latestInvoice !== "string" + ? { + currency: latestInvoice.currency, + hostedUrl: latestInvoice.hosted_invoice_url ?? null, + periodEndAt: latestInvoice.period_end + ? new Date(latestInvoice.period_end * 1000) + : null, + periodStartAt: latestInvoice.period_start + ? new Date(latestInvoice.period_start * 1000) + : null, + providerInvoiceId: latestInvoice.id, + status: latestInvoice.status, + totalAmount: latestInvoice.total, + } + : null; + + return { + invoice: inv, + paymentUrl: null, + subscription: { + cancelAtPeriodEnd: sub.cancel_at_period_end, + canceledAt: sub.canceled_at != null ? new Date(sub.canceled_at * 1000) : null, + currentPeriodEndAt: periodEnd != null ? new Date(periodEnd * 1000) : null, + currentPeriodStartAt: periodStart != null ? new Date(periodStart * 1000) : null, + endedAt: sub.ended_at != null ? new Date(sub.ended_at * 1000) : null, + providerSubscriptionId: sub.id, + providerSubscriptionScheduleId: null, + status: sub.status, + }, + }; + }; + }, + + async setupCustomerForDirectSubscription(providerCustomerId: string) { + const pm = await stripeClient.paymentMethods.attach("pm_card_visa", { + customer: providerCustomerId, + }); + await stripeClient.customers.update(providerCustomerId, { + invoice_settings: { default_payment_method: pm.id }, + }); + }, + + async completeCheckout(url: string) { + const browser = await chromium.launch({ headless: true }); + + try { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: "domcontentloaded" }); + + // Stripe's hosted checkout uses custom inputs that require per-key events; + // fill() does not dispatch them correctly, so use pressSequentially. + const cardNumber = page.locator("#cardNumber"); + await cardNumber.waitFor({ timeout: 60_000 }); + await cardNumber.pressSequentially("4242424242424242"); + + const cardExpiry = page.locator("#cardExpiry"); + await cardExpiry.waitFor({ timeout: 30_000 }); + await cardExpiry.pressSequentially("1234"); + + const cardCvc = page.locator("#cardCvc"); + await cardCvc.waitFor({ timeout: 30_000 }); + await cardCvc.pressSequentially("123"); + + const billingName = page.locator("#billingName"); + if (await billingName.isVisible().catch(() => false)) { + await billingName.pressSequentially("Test Customer"); + } + + const submitBtn = page.locator(".SubmitButton-TextContainer").first(); + await submitBtn.click(); + + // Wait for Stripe to navigate away from the checkout page (success redirect + // or embedded confirmation). Don't fail the test if this times out — the + // webhook poll downstream is the real signal. + await page + .waitForURL((u) => !u.toString().includes("checkout.stripe.com"), { + timeout: 60_000, + }) + .catch(() => {}); + } finally { + await browser.close(); + } + }, + + async cleanup(ctx) { + // Delete test clocks for all customers + for (const providerCustomerId of ctx.providerCustomerIds) { + try { + const customer = await stripeClient.customers.retrieve(providerCustomerId); + if ("deleted" in customer && customer.deleted) continue; + const testClockId = (customer as Stripe.Customer).test_clock; + if (testClockId && typeof testClockId === "string") { + await stripeClient.testHelpers.testClocks.del(testClockId).catch(() => {}); + } + } catch { + // Customer may already be deleted + } + } + }, + + validateEnv() { + validateStripeEnv(); + }, + }; +} + +function validateStripeEnv(): void { + if (!env.E2E_STRIPE_SK || !env.E2E_STRIPE_WHSEC) { + throw new Error("E2E_STRIPE_SK and E2E_STRIPE_WHSEC must be set"); + } +} diff --git a/e2e/smoke/harness/types.ts b/e2e/test-utils/harness/types.ts similarity index 70% rename from e2e/smoke/harness/types.ts rename to e2e/test-utils/harness/types.ts index 68a94814..e249eef2 100644 --- a/e2e/smoke/harness/types.ts +++ b/e2e/test-utils/harness/types.ts @@ -1,5 +1,7 @@ import type { PayKitProviderConfig } from "paykitjs"; +import type { PayKitContext } from "../../../packages/paykit/src/core/context"; + export interface ProviderCapabilities { testClocks: boolean; directSubscription: boolean; @@ -11,6 +13,13 @@ export interface ProviderHarness { createProviderConfig(): PayKitProviderConfig; + /** + * Apply testing-only overrides to the PayKit provider (e.g., Stripe's + * allow_incomplete to bypass client-side confirmation). No-op if the + * provider doesn't need any test-mode tweaks. + */ + applyTestingOverrides?(ctx: PayKitContext): void; + /** * Make the customer ready to subscribe without checkout (e.g., attach PM for Stripe). * For providers that only support checkout, this is a no-op. diff --git a/e2e/test-utils/hub.ts b/e2e/test-utils/hub.ts new file mode 100644 index 00000000..05d68c3d --- /dev/null +++ b/e2e/test-utils/hub.ts @@ -0,0 +1,173 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +export const HUB_PORT = 4567; +export const HUB_REGISTER_URL = `http://127.0.0.1:${String(HUB_PORT)}/_hub/register`; +export const HUB_UNREGISTER_URL = `http://127.0.0.1:${String(HUB_PORT)}/_hub/unregister`; + +/** Events that arrive for an unknown customer are buffered for this long. */ +const BUFFER_TTL_MS = 60_000; + +interface BufferedEvent { + body: string; + headers: Record; + path: string; + receivedAt: number; +} + +/** + * Extract the provider customer ID from a Stripe event body. + * Returns null for events that aren't keyed by customer (e.g. product.created). + */ +function extractStripeCustomerId(body: string): string | null { + try { + const parsed = JSON.parse(body) as { + data?: { object?: { id?: string; customer?: string | null; object?: string } }; + }; + const obj = parsed.data?.object; + if (!obj) return null; + if (obj.object === "customer" && typeof obj.id === "string") return obj.id; + if (typeof obj.customer === "string") return obj.customer; + return null; + } catch { + return null; + } +} + +async function forwardEvent(workerUrl: string, event: BufferedEvent): Promise { + const url = new URL(event.path, workerUrl); + return fetch(url, { method: "POST", headers: event.headers, body: event.body }); +} + +export function startHub(): Promise { + const registry = new Map(); + const buffers = new Map(); + + function dropExpired(customerId: string): void { + const buf = buffers.get(customerId); + if (!buf) return; + const now = Date.now(); + const kept = buf.filter((e) => now - e.receivedAt < BUFFER_TTL_MS); + if (kept.length === 0) buffers.delete(customerId); + else buffers.set(customerId, kept); + } + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = Buffer.concat(chunks).toString(); + const path = req.url ?? "/"; + + if (path === "/_hub/register") { + const { providerCustomerId, workerUrl } = JSON.parse(body) as { + providerCustomerId: string; + workerUrl: string; + }; + registry.set(providerCustomerId, workerUrl); + // Drain any buffered events for this customer + dropExpired(providerCustomerId); + const pending = buffers.get(providerCustomerId) ?? []; + buffers.delete(providerCustomerId); + for (const event of pending) { + await forwardEvent(workerUrl, event).catch(() => {}); + } + res.writeHead(204); + res.end(); + return; + } + + if (path === "/_hub/unregister") { + const { providerCustomerIds } = JSON.parse(body) as { providerCustomerIds: string[] }; + for (const id of providerCustomerIds) { + registry.delete(id); + buffers.delete(id); + } + res.writeHead(204); + res.end(); + return; + } + + // Otherwise: route webhook by customer ID + const customerId = extractStripeCustomerId(body); + if (!customerId) { + // No customer → setup artifacts (product.created, price.created). Drop. + res.writeHead(204); + res.end(); + return; + } + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers[key] = value; + } + + const workerUrl = registry.get(customerId); + if (!workerUrl) { + // Unknown customer — buffer briefly in case a worker is about to register. + const buf = buffers.get(customerId) ?? []; + buf.push({ body, headers, path, receivedAt: Date.now() }); + buffers.set(customerId, buf); + res.writeHead(204); + res.end(); + return; + } + + try { + const response = await forwardEvent(workerUrl, { + body, + headers, + path, + receivedAt: Date.now(), + }); + res.writeHead(response.status); + res.end(await response.text()); + } catch (error) { + res.writeHead(500); + res.end(error instanceof Error ? error.message : "hub forward error"); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(HUB_PORT, "127.0.0.1", () => resolve(server)); + }); +} + +export async function registerCustomer( + providerCustomerId: string, + workerUrl: string, +): Promise { + const response = await fetch(HUB_REGISTER_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerCustomerId, workerUrl }), + }); + if (!response.ok) throw new Error(`hub register failed: ${String(response.status)}`); +} + +export async function unregisterCustomers(providerCustomerIds: string[]): Promise { + if (providerCustomerIds.length === 0) return; + await fetch(HUB_UNREGISTER_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerCustomerIds }), + }).catch(() => {}); +} + +/** Vitest globalSetup entry — starts the hub once and returns a teardown. */ +export default async function globalSetup(): Promise<() => Promise> { + let server: Server; + try { + server = await startHub(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") { + throw new Error( + `Hub port ${String(HUB_PORT)} already in use. Kill any stale webhook server before running tests.`, + { cause: error }, + ); + } + throw error; + } + return async () => { + await new Promise((resolve) => server.close(() => resolve())); + }; +} diff --git a/e2e/test-utils/index.ts b/e2e/test-utils/index.ts new file mode 100644 index 00000000..50cab521 --- /dev/null +++ b/e2e/test-utils/index.ts @@ -0,0 +1,2 @@ +export * from "./products"; +export * from "./setup"; diff --git a/e2e/test-utils/products.ts b/e2e/test-utils/products.ts new file mode 100644 index 00000000..568e3c1d --- /dev/null +++ b/e2e/test-utils/products.ts @@ -0,0 +1,51 @@ +import { feature, plan } from "paykitjs"; + +const messagesFeature = feature({ id: "messages", type: "metered" }); +const dashboardFeature = feature({ id: "dashboard", type: "boolean" }); +const adminFeature = feature({ id: "admin", type: "boolean" }); + +export const freePlan = plan({ + default: true, + group: "base", + id: "free", + name: "Free", + includes: [messagesFeature({ limit: 100, reset: "month" })], +}); + +export const proPlan = plan({ + group: "base", + id: "pro", + name: "Pro", + includes: [messagesFeature({ limit: 500, reset: "month" }), dashboardFeature()], + price: { amount: 20, interval: "month" }, +}); + +export const premiumPlan = plan({ + group: "base", + id: "premium", + name: "Premium", + includes: [messagesFeature({ limit: 1_000, reset: "month" }), dashboardFeature(), adminFeature()], + price: { amount: 50, interval: "month" }, +}); + +export const ultraPlan = plan({ + group: "base", + id: "ultra", + name: "Ultra", + includes: [ + messagesFeature({ limit: 10_000, reset: "month" }), + dashboardFeature(), + adminFeature(), + ], + price: { amount: 200, interval: "month" }, +}); + +export const extraMessagesPlan = plan({ + group: "addons", + id: "extra_messages", + name: "Extra Messages", + includes: [messagesFeature({ limit: 200, reset: "month" })], + price: { amount: 5, interval: "month" }, +}); + +export const allPlans = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const; diff --git a/e2e/smoke/setup.ts b/e2e/test-utils/setup.ts similarity index 82% rename from e2e/smoke/setup.ts rename to e2e/test-utils/setup.ts index 7c403411..dd52dfd8 100644 --- a/e2e/smoke/setup.ts +++ b/e2e/test-utils/setup.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { and, count, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; -import { createPayKit, feature, plan } from "paykitjs"; +import { createPayKit } from "paykitjs"; import { Pool } from "pg"; import { default as Stripe } from "stripe"; @@ -17,81 +17,32 @@ import { } from "../../packages/paykit/src/database/schema"; import { syncPaymentMethodByProviderCustomer } from "../../packages/paykit/src/payment-method/payment-method.service"; import { syncProducts } from "../../packages/paykit/src/product/product-sync.service"; -import { env } from "../env"; +import { env } from "./env"; import { loadHarness } from "./harness/index"; import type { ProviderHarness } from "./harness/types"; - -const WEBHOOK_PORT = 4567; +import { HUB_PORT, registerCustomer, unregisterCustomers } from "./hub"; +import { allPlans } from "./products"; // Provider harness — loaded once at module init based on PROVIDER env var export const harness: ProviderHarness = loadHarness(); -const messagesFeature = feature({ id: "messages", type: "metered" }); -const dashboardFeature = feature({ id: "dashboard", type: "boolean" }); -const adminFeature = feature({ id: "admin", type: "boolean" }); - -export const freePlan = plan({ - default: true, - group: "base", - id: "free", - name: "Free", - includes: [messagesFeature({ limit: 100, reset: "month" })], -}); - -export const proPlan = plan({ - group: "base", - id: "pro", - name: "Pro", - includes: [messagesFeature({ limit: 500, reset: "month" }), dashboardFeature()], - price: { amount: 20, interval: "month" }, -}); - -export const premiumPlan = plan({ - group: "base", - id: "premium", - name: "Premium", - includes: [messagesFeature({ limit: 1_000, reset: "month" }), dashboardFeature(), adminFeature()], - price: { amount: 50, interval: "month" }, -}); - -export const ultraPlan = plan({ - group: "base", - id: "ultra", - name: "Ultra", - includes: [ - messagesFeature({ limit: 10_000, reset: "month" }), - dashboardFeature(), - adminFeature(), - ], - price: { amount: 200, interval: "month" }, -}); - -export const extraMessagesPlan = plan({ - group: "addons", - id: "extra_messages", - name: "Extra Messages", - includes: [messagesFeature({ limit: 200, reset: "month" })], - price: { amount: 5, interval: "month" }, -}); - -const smokePlans = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const; - -type SmokePayKit = ReturnType< +type TestPayKitInstance = ReturnType< typeof createPayKit<{ database: Pool; - plans: typeof smokePlans; + plans: typeof allPlans; provider: ReturnType; testing: { enabled: true }; }> >; export interface TestPayKit { - paykit: SmokePayKit; + paykit: TestPayKitInstance; database: PayKitDatabase; ctx: PayKitContext; harness: ProviderHarness; dbPath: string; server: Server; + workerUrl: string; webhookRequests: CapturedWebhookRequest[]; cleanup: () => Promise; } @@ -110,7 +61,7 @@ export async function createTestPayKit(): Promise { harness.validateEnv(); // 1. Create a fresh test database - const dbName = `paykit_smoke_${String(Date.now())}`; + const dbName = `paykit_test_${String(Date.now())}_${Math.random().toString(36).slice(2, 8)}`; const adminPool = new Pool({ connectionString: env.TEST_DATABASE_URL, }); @@ -127,74 +78,24 @@ export async function createTestPayKit(): Promise { const providerConfig = harness.createProviderConfig(); const paykit = createPayKit({ database: pool, - plans: smokePlans, + plans: allPlans, provider: providerConfig, testing: { enabled: true }, }); const ctx = await paykit.$context; - // Stripe-specific: Override createSubscription to use allow_incomplete. - // This allows direct subscription without client-side payment confirmation. - if (harness.id === "stripe") { - const secretKey = env.E2E_STRIPE_SK!; - const stripeClient = new Stripe(secretKey); - - (ctx.provider as unknown as Record).createSubscription = async (data: { - providerCustomerId: string; - providerProduct: Record; - }) => { - const sub = await stripeClient.subscriptions.create({ - customer: data.providerCustomerId, - items: [{ price: data.providerProduct.priceId }], - payment_behavior: "allow_incomplete", - expand: ["latest_invoice"], - }); - - const firstItem = sub.items.data[0]; - const periodStart = firstItem?.current_period_start ?? null; - const periodEnd = firstItem?.current_period_end ?? null; - const latestInvoice = sub.latest_invoice; - const inv = - latestInvoice && typeof latestInvoice !== "string" - ? { - currency: latestInvoice.currency, - hostedUrl: latestInvoice.hosted_invoice_url ?? null, - periodEndAt: latestInvoice.period_end - ? new Date(latestInvoice.period_end * 1000) - : null, - periodStartAt: latestInvoice.period_start - ? new Date(latestInvoice.period_start * 1000) - : null, - providerInvoiceId: latestInvoice.id, - status: latestInvoice.status, - totalAmount: latestInvoice.total, - } - : null; - - return { - invoice: inv, - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancel_at_period_end, - canceledAt: sub.canceled_at != null ? new Date(sub.canceled_at * 1000) : null, - currentPeriodEndAt: periodEnd != null ? new Date(periodEnd * 1000) : null, - currentPeriodStartAt: periodStart != null ? new Date(periodStart * 1000) : null, - endedAt: sub.ended_at != null ? new Date(sub.ended_at * 1000) : null, - providerSubscriptionId: sub.id, - providerSubscriptionScheduleId: null, - status: sub.status, - }, - }; - }; - } + // Provider-specific testing overrides (e.g., Stripe allow_incomplete) + harness.applyTestingOverrides?.(ctx); - // 4. Start webhook server BEFORE syncing products — product sync - // creates provider products which fires webhooks immediately + // 4. Start per-test webhook server on a random free port. A shared hub on 4567 + // (started in globalSetup) forwards webhooks to this worker by provider customer ID. const webhookRequests: CapturedWebhookRequest[] = []; - const server = await startWebhookServer(paykit, webhookRequests); + const { server, workerUrl } = await startWebhookServer(paykit, webhookRequests); - // 5. Sync products to provider + // 5. Sync products to provider. These fire product.created/price.created events + // which the hub drops (they don't carry a customer ID) — safe to run before + // any customer is registered. await syncProducts(ctx); return { @@ -204,6 +105,7 @@ export async function createTestPayKit(): Promise { harness, dbPath: dbUrl, server, + workerUrl, webhookRequests, cleanup: async () => { const customerRows = await ctx.database.query.customer.findMany(); @@ -218,6 +120,7 @@ export async function createTestPayKit(): Promise { // Wait for cleanup webhooks to arrive and be processed await new Promise((resolve) => setTimeout(resolve, 10_000)); + await unregisterCustomers([...idSet]); await new Promise((resolve) => server.close(() => resolve())); await pool.end(); // Drop the test database @@ -261,6 +164,9 @@ export async function createTestCustomer(input: { ); } + // Route this customer's webhooks to this test's worker + await registerCustomer(providerCustomerId, input.t.workerUrl); + return { customerId: uniqueId, providerCustomerId }; } @@ -280,7 +186,7 @@ export async function createTestCustomerWithPM(input: { // For Stripe, sync the payment method into PayKit DB if (input.t.harness.id === "stripe") { const secretKey = env.E2E_STRIPE_SK!; - const stripeClient = new Stripe(secretKey); + const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 }); const pm = await stripeClient.paymentMethods.list({ customer: providerCustomerId, type: "card", @@ -314,7 +220,7 @@ export async function createTestCustomerWithPM(input: { export async function subscribeCustomer(input: { t: TestPayKit; customerId: string; - planId: Parameters[0]["planId"]; + planId: Parameters[0]["planId"]; }): Promise { const beforeSubscribe = new Date(); @@ -569,10 +475,10 @@ export async function expectNoScheduledPlanInGroup(input: { export async function expectExactMeteredBalance(input: { customerId: string; - featureId: Parameters[0]["featureId"]; + featureId: Parameters[0]["featureId"]; limit: number; remaining: number; - paykit: SmokePayKit; + paykit: TestPayKitInstance; }): Promise { const result = await input.paykit.check({ customerId: input.customerId, @@ -605,9 +511,9 @@ export async function expectExactMeteredBalance(input: { } async function startWebhookServer( - paykit: Pick, + paykit: Pick, webhookRequests: CapturedWebhookRequest[], -): Promise { +): Promise<{ server: Server; workerUrl: string }> { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const chunks: Buffer[] = []; for await (const chunk of req) { @@ -615,7 +521,9 @@ async function startWebhookServer( } const body = Buffer.concat(chunks).toString(); - const url = new URL(req.url ?? "/", `http://localhost:${String(WEBHOOK_PORT)}`); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const url = new URL(req.url ?? "/", `http://127.0.0.1:${String(port)}`); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (typeof value === "string") headers.set(key, value); @@ -646,9 +554,14 @@ async function startWebhookServer( await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(WEBHOOK_PORT, () => resolve()); + server.listen(0, "127.0.0.1", () => resolve()); }); - return server; + const address = server.address(); + if (typeof address !== "object" || !address) { + throw new Error("Failed to get webhook server address"); + } + const workerUrl = `http://127.0.0.1:${String(address.port)}/`; + return { server, workerUrl }; } export async function advanceTestClock(input: { @@ -737,7 +650,8 @@ export async function waitForForwardedWebhookRequest(input: { export async function replayWebhookRequest(input: { request: CapturedWebhookRequest; }): Promise { - const response = await fetch(`http://localhost:${String(WEBHOOK_PORT)}${input.request.path}`, { + // Replay goes through the hub, which routes to the owning worker by customer ID. + const response = await fetch(`http://127.0.0.1:${String(HUB_PORT)}${input.request.path}`, { body: input.request.body, headers: input.request.headers, method: "POST", diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 00000000..a51df0dd --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Cap parallel workers — Stripe test mode rate-limits at 25 ops/sec; too many + // workers starting syncProducts simultaneously trips it. Paired with Stripe + // SDK maxNetworkRetries for headroom. + maxWorkers: 6, + projects: [ + { + test: { + name: "core", + env: { NODE_ENV: "production" }, + globalSetup: ["./test-utils/hub.ts"], + hookTimeout: 180_000, + include: ["core/**/*.test.ts"], + testTimeout: 600_000, + }, + }, + { + test: { + name: "cli", + env: { NODE_ENV: "production" }, + hookTimeout: 60_000, + include: ["cli/**/*.test.ts"], + sequence: { concurrent: false }, + testTimeout: 120_000, + }, + }, + ], + }, +}); diff --git a/landing/src/lib/source.ts b/landing/src/lib/source.ts index 6613a08a..f563c77f 100644 --- a/landing/src/lib/source.ts +++ b/landing/src/lib/source.ts @@ -9,7 +9,5 @@ export const source = loader({ }); export type SourcePage = InferPageType & { - data: InferPageType["data"] & - DocData & - DocMethods & { full?: boolean }; + data: InferPageType["data"] & DocData & DocMethods & { full?: boolean }; }; diff --git a/landing/vercel.json b/landing/vercel.json index b216b298..f15e70c5 100644 --- a/landing/vercel.json +++ b/landing/vercel.json @@ -1,4 +1,4 @@ { - "ignoreCommand": "npx turbo-ignore landing", - "installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install" + "ignoreCommand": "bunx turbo-ignore landing", + "installCommand": "bun install" } diff --git a/package.json b/package.json index 522b2d3a..c8008240 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev": "turbo run dev", "dev:demo": "bun --filter demo dev", "dev:web": "bun --filter landing dev", + "test:unit": "node ./node_modules/vitest/vitest.mjs run --config vitest.unit.config.ts", "typecheck": "turbo run typecheck", "format": "oxfmt --write '**/*.{ts,tsx,js,jsx}'", "format:check": "oxfmt --check '**/*.{ts,tsx,js,jsx}'", @@ -26,7 +27,7 @@ "lint:fix": "oxlint --fix", "release": "turbo build --filter=./packages/* && bumpp", "release:canary": "turbo build --filter=./packages/* && bumpp --tag canary", - "prepare": "simple-git-hooks", + "prepare": "bunx simple-git-hooks || true", "worktree:setup": "bash scripts/worktree-setup.sh" }, "devDependencies": { @@ -36,18 +37,20 @@ "lint-staged": "^16.2.7", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", - "simple-git-hooks": "^2.13.1", "tinyglobby": "^0.2.16", "tsx": "^4.21.0", "turbo": "^2.8.10", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.18" }, "simple-git-hooks": { "pre-commit": "bun lint-staged" }, "lint-staged": { - "!(**/migrations/**)": "oxlint --fix", - "*.{ts,tsx,js,jsx}": "oxfmt --write" + "*.{ts,tsx,js,jsx}": [ + "oxlint --fix", + "oxfmt --write" + ] }, "engines": { "node": ">=22" diff --git a/packages/paykit/src/customer/__tests__/customer.service.test.ts b/packages/paykit/src/customer/__tests__/customer.service.test.ts index 4ae3d942..b96971c0 100644 --- a/packages/paykit/src/customer/__tests__/customer.service.test.ts +++ b/packages/paykit/src/customer/__tests__/customer.service.test.ts @@ -121,6 +121,7 @@ describe("customer/service", () => { const customer = await upsertCustomer(ctx, { email: "test@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(customer).toEqual(syncedCustomer); @@ -215,6 +216,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "prod@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(stripe.createCustomer).toHaveBeenCalledWith({ @@ -385,6 +387,7 @@ describe("customer/service", () => { const result = await upsertCustomer(ctx, { email: "same@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.createCustomer).not.toHaveBeenCalled(); @@ -437,6 +440,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "new@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.updateCustomer).toHaveBeenCalledWith( @@ -483,6 +487,7 @@ describe("customer/service", () => { await upsertCustomer(ctx, { email: "test@example.com", id: "customer_123", + upsertProviderCustomer: true, }); expect(providerMock.updateCustomer).toHaveBeenCalled(); diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index b7a4318b..dd907416 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -119,11 +119,24 @@ function normalizeStripeInvoice(invoice: StripeInvoiceWithExtras) { function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) { const firstItem = subscription.items.data[0]; - const providerPriceId = - typeof firstItem?.price === "string" ? firstItem.price : firstItem?.price.id; + const price = firstItem?.price; + const providerPriceId = typeof price === "string" ? price : price?.id; + const providerProductId = + price && typeof price !== "string" + ? typeof price.product === "string" + ? price.product + : (price.product?.id ?? null) + : null; const periodStart = getEarliestPeriodStart(subscription); const periodEnd = getLatestPeriodEnd(subscription); + let providerProduct: Record | null = null; + if (providerPriceId && providerProductId) { + providerProduct = { priceId: providerPriceId, productId: providerProductId }; + } else if (providerPriceId) { + providerProduct = { priceId: providerPriceId }; + } + const cancelAt = (subscription as { cancel_at?: number | null }).cancel_at; return { cancelAtPeriodEnd: subscription.cancel_at_period_end || (cancelAt != null && cancelAt > 0), @@ -131,7 +144,7 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) currentPeriodEndAt: toDate(periodEnd), currentPeriodStartAt: toDate(periodStart), endedAt: toDate(subscription.ended_at), - providerProduct: providerPriceId ? { priceId: providerPriceId } : null, + providerProduct, providerSubscriptionId: subscription.id, providerSubscriptionScheduleId: (typeof subscription.schedule === "string" @@ -966,6 +979,7 @@ export function stripe(options: StripeOptions): PayKitProviderConfig { } const client = new StripeSdk(options.secretKey, { apiVersion: apiVersion as StripeSdk.LatestApiVersion, + maxNetworkRetries: 3, }); return { diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts new file mode 100644 index 00000000..7485b6a4 --- /dev/null +++ b/vitest.unit.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/dist/**", "**/node_modules/**", "apps/**", "e2e/**", "landing/**"], + include: ["packages/**/__tests__/**/*.test.ts"], + }, +});