Skip to content

test(billing): checkout lifecycle contract tests against local Postgres#47

Merged
ElbertePlinio merged 1 commit into
mainfrom
test/checkout-lifecycle-postgres-contract
Jul 19, 2026
Merged

test(billing): checkout lifecycle contract tests against local Postgres#47
ElbertePlinio merged 1 commit into
mainfrom
test/checkout-lifecycle-postgres-contract

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

Summary

Implements CAND-1 of #37 (issue #37's PR 1 — checkout lifecycle local-Postgres interface): a contract-test lane that characterizes checkout/deletion lifecycle behavior against the real durable SQL rather than the in-memory billing test fake.

What changed

  • packages/billing/test/checkout-lifecycle.contract.test.ts — 20 cases exercising the production processStripeEvent/getCreditBalanceCents billing policy against the real checkout_lifecycle_* RPCs in supabase/migrations/20260712193633_checkout_deletion_lifecycle.sql, with only Stripe mocked. Covers:
    • concurrent completion vs. deletion-fence race (real dual-transaction race, advisory-lock serialized)
    • missing user, fenced user, deletion-finalized-before-completion
    • registered vs. unregistered checkout sessions blocking/allowing deletion finalization
    • refund states: partial, pending (async), failed, canceled, succeeded, charge_already_refunded
    • crash-window recovery (attached Refund recovered after a simulated crash before retrieval)
    • replayed/duplicate events (checkout completion, async payment events, refund events)
  • packages/billing/test/postgres-lifecycle-client.ts — a SupabaseClientLike adapter backed by a real bun:sql Postgres connection. Reimplements no lifecycle policy; every checkout_lifecycle_* decision is made by the durable SQL functions. Every query/RPC is wrapped in its own SAVEPOINT so a caught error on one call (e.g. a duplicate-key retry) doesn't poison the outer per-test transaction, matching how independent PostgREST requests behave in production.
  • packages/billing/test/lifecycle-fixtures.ts — connection/guard helpers restricted to the disposable local Supabase database (127.0.0.1:54322), rollback-per-test isolation, and non-transactional cleanup for the one true-concurrency test.
  • package.jsontest:supabase now also runs the contract lane via bun test.
  • vitest.config.ts — excludes *.contract.test.ts so plain vitest run/bun run test (CI's check job) stays green without Postgres; CI's database job runs it for real after supabase db start.
  • packages/billing/test/billing.test.ts — removed the checkout-lifecycle-policy cases now duplicated by the contract lane (fenced/missing-user refund flows, refund-state transitions, deletion-race scenarios, event replay/dedupe). Kept the MemorySupabase fake and its remaining fast non-lifecycle unit tests (webhook verification, checkout-session creation/idempotency-key generation, input validation, database-error resilience, balance/ledger reads).

Skip behavior

When local Postgres/Supabase isn't reachable, the lane logs a clear message and skips every test via describe.skipIf, so bun test/vitest run and CI's default check job stay green without Docker.

Validation

  • bun run typecheck — pass
  • bun run test (vitest, matches CI check job) — 346 pass, contract lane cleanly excluded
  • Verified skip path directly: pointed SUPABASE_DB_URL at an unreachable port and confirmed bun test packages/billing/test/checkout-lifecycle.contract.test.ts skips all 22 tests with the expected log message
  • Local environment has Docker + Supabase CLI, so ran it for real:
    • supabase db reset (fresh disposable DB, matching CI's supabase db start)
    • PICKFORGE_ALLOW_LOCAL_DB_TESTS=1 SUPABASE_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres bun run test:supabase (matches CI's database job) — all SQL fixture tests, the welcome-credits concurrency script, and all 20 contract tests pass
    • supabase db advisors --local --type security --fail-on warn — no issues
    • bun run build — pass
  • No new migrations added.

Refs #37 (CAND-1). Unblocks CAND-2 (consolidate checkout/deletion orchestration).

Adds a checkout-lifecycle contract lane that exercises the real
checkout_lifecycle_* SQL RPCs in
supabase/migrations/20260712193633_checkout_deletion_lifecycle.sql
through the production processStripeEvent/getCreditBalanceCents
billing policy, with only Stripe mocked. Covers concurrent
completion+deletion races, missing vs fenced users, registered/
unregistered sessions, refund states (partial/pending/failed/
canceled/succeeded), crash-window recovery, and replayed events.

The lane runs via `bun test` (needs bun:sql) against
127.0.0.1:54322, guarded to a disposable local Supabase database
only, and skips cleanly with a clear message when unreachable so
plain `bun test`/vitest and CI's default check job stay green.
CI's database job runs it for real via `bun run test:supabase`
after `supabase db start`.

Removes the checkout-lifecycle-policy cases duplicated in the
MemorySupabase billing.test.ts fake now that the contract lane
characterizes that behavior against the durable SQL directly,
keeping the fake and its remaining fast non-lifecycle unit tests
(webhook verification, checkout-session creation/idempotency,
validation, and database-error resilience).

Refs #37 (CAND-1).
@ElbertePlinio
ElbertePlinio merged commit 2595d26 into main Jul 19, 2026
2 checks passed
@ElbertePlinio
ElbertePlinio deleted the test/checkout-lifecycle-postgres-contract branch July 19, 2026 15:04
ElbertePlinio added a commit that referenced this pull request Jul 19, 2026
Replace the settings-sync last-writer-wins choreography (conditional
`.update()` guarded by `updated_at <`, falling back to an
`ignoreDuplicates` `.upsert()`, falling back to a third read to learn
the outcome) with one durable `settings_sync_lww_write` SQL function
that locks the (user_id, field_group) row, compares/writes/tombstones,
and always returns the authoritative row in the same call.

pushGroup/deleteGroup keep all validation/sanitization and now just
call the durable RPC; `written`/`stale` result semantics are
unchanged. The migration is additive only (#35): it adds the function,
nothing else.

Adds a local-Postgres contract lane (packages/sync/test/lww.contract.test.ts)
following PR #47's pattern (skips cleanly without Postgres, wired into
`test:supabase`), covering real concurrent push/push, first-insert/
first-insert, and push/delete races, equal-timestamp ties, and older
data racing in after a newer tombstone. pgTAP coverage
(supabase/tests/database/settings_sync_lww.test.sql) adds microsecond
canonicalization, the existing future-timestamp check constraint, and
per-user isolation.

Issue #37 CAND-3.
ElbertePlinio added a commit that referenced this pull request Jul 19, 2026
…cation (#51)

Issue #37 (CAND-4): operator-router's idempotency only became durable
AFTER provider work. The handler checked `credit_ledger` for a completed
route, invoked the OpenAI-compatible provider, and only then wrote a
debit row keyed by the idempotency key — two concurrent requests for the
same key could both find nothing completed, both call the provider, and
the losing response was simply discarded after the winner's debit
landed (wasted provider spend with no compensating record).

New `router_attempts` table and three service-role-only RPCs
(`router_attempt_claim` / `router_attempt_complete` / `router_attempt_fail`,
supabase/migrations/20260722000000_router_attempt_claim.sql) move the
durable decision in front of the provider call: at most one caller ever
owns "go call the provider" for a given (user_id, idempotency_key) at a
time. A claim that is neither completed nor explicitly failed is
recoverable once its lease elapses. This is additive only: existing
`credit_ledger` rows written before this migration remain authoritative
for old completed routes (`findDebitedRouteResult`), independent of
whether a `router_attempts` row exists for that key.

`createOperatorRouterHandler` now claims before invoking the provider,
completes the claim with the provider outcome, then debits — releasing
the claim immediately on a definitive provider failure, and skipping
straight to a debit retry (no re-invocation) when a claim is found
already completed.

- `packages/edge-shared/src/index.ts`: `claimRouteAttempt`,
  `completeRouteAttempt`, `failRouteAttempt`, `findDebitedRouteResult`
  own the RPC/route-metadata encode-decode boundary; `debitCredits` is
  unchanged. `createOperatorRouterHandler` takes `serviceSupabase`
  instead of separate `findCompletedRoute`/`debit` callbacks, and
  returns 409 `attempt_in_progress` while another claim is live.
- `supabase/functions/operator-router/index.ts`: wires the caller's
  service-role client straight through; drops the adapter-owned
  `findCompletedRoute` query and `debit` callback.
- `packages/edge-shared/test/edge-shared.test.ts`: replaces the
  `findCompletedRoute`/`debit` fakes with a `FakeRouterServiceSupabase`
  reimplementing the claim/complete/fail/debit RPC contract, covering
  the full claim -> provider -> complete -> debit choreography, live
  claims, lease recovery, and debit-retry-without-re-invoking-the-
  provider.
- `supabase/tests/database/router_attempt_claim.test.sql`: pgTAP
  coverage for claim/in_progress/completed outcomes, idempotent
  completion replay, failure recovery, lease-based recovery, per-user
  isolation, privilege grants, and input validation.
- `packages/edge-shared/test/router-attempt.contract.test.ts` (+
  `router-fixtures.ts`, `postgres-router-client.ts`): a local-Postgres
  contract lane, following #47/#50's convention, proving the durable
  claim/complete/fail/debit functions against real Postgres, including
  two genuinely concurrent claims for the same key resolving to
  exactly one claim, and a full `createOperatorRouterHandler` run
  proving the provider is invoked and the ledger is debited exactly
  once across two concurrent same-key requests. Wired into
  `bun run test:supabase`.
- Bumps `@pickforge/*` 0.11.0 -> 0.12.0 and re-pins every Deno
  function's `npm:` import, per the existing publish-then-pin
  convention.

Validated: `bun run typecheck`; `bun run test` (366/366); `bun run
build`; `supabase db reset` (all migrations apply cleanly); `bun run
test:supabase` against real local Postgres — pgTAP (178 tests,
including the new router_attempt_claim lane), welcome-credits
concurrency, the CAND-1 checkout-lifecycle contract lane (20/20) and
CAND-3 sync LWW contract lane (8/8) stay green, and the new router
attempt contract lane (8/8) all pass; `supabase db advisors --local
--type security --fail-on warn` clean. `deno check` against the real
npm registry isn't possible pre-publish (same documented gap as #48:
published `@pickforge/*` is still behind main) — confirmed the
pre-existing gap is unchanged (this diff's remaining `deno check`
errors are a strict subset of main's) and validated the adapter's
typing directly against local `src` via `tsc` instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant