test(payments): the mint chain runs end to end against the real Stripe test API - #238
Conversation
…e test API `mintForProposal -> createExpressAccount -> recordConnectAccount -> the fields a registry entry carries` was unit- and mutation-tested and each Stripe call had been probed by hand, but the CHAIN had never executed in one piece anywhere: staging cannot rehearse it (`provisioningConfigured()` is `Boolean(PROVISION_GITHUB_TOKEN)` and that variable is absent there), so the first run of the hand-offs would have been a real restaurant's account. tests/e2e/connect-mint.spec.ts runs everything below `openProvisioningPr` against the REAL Stripe API on an `sk_test_` key and the suite's throwaway Postgres. Nothing is mocked (CLAUDE.md §7). It asserts the minted `acct_`, the locale-free `payments_link_url:` (and drives the middleware in French and in German to prove one stored URL serves both), the StripeConnectAccount row, what Stripe itself holds, that a cash-only tenant mints nothing, and that EUR is refused BEFORE the call — proven with a deliberately broken key whose 401 the CHF control does surface. The assertion the file exists for is idempotency, in both forms: a re-run reads our own row, and a run whose row is DELETED (the crash between the Stripe POST and the write) recovers the SAME account from the idempotency key rather than minting a twin. Stripe's own account list, filtered on `metadata[sofra_tenant]`, is the only oracle that can see that failure — the unique slug column cannot. Cleanup is asserted, not best-effort: every account is deleted (`deleted: true`, then GET -> 403) from an inventory taken from the database AND from Stripe, so an account created by a test that then failed is still removed. Guards mirror billing-mollie.spec.ts: the suite and CI refuse anything but `sk_test_`, and with no key the spec SKIPS with a stated reason. prisma/schema.prisma: `moduleFormat = "cjs"`. The generator was emitting an ESM-only client (`import.meta.url`) into a package that declares no `type`, so only a bundler could load `lib/db.ts` — which is why this chain had no way to be exercised outside a browser request. Output format only; lib/generated/ is gitignored and rebuilt on every build. Measured: the full unmocked suite is 83/83 green on this change (including the real Mollie first payment), and the Stripe test platform holds no `e2e-` account afterwards.
|
|
SonarCloud triage — quality gate OK (6 conditions populated; verified with a negative control: a bogus project key answers Three issues were raised, all
Open Sonar issues on this PR: 0. |

Summary
mintForProposal → createExpressAccount → recordConnectAccount → the fields a registry entry carrieswas unit-tested, mutation-tested and hand-probed call by call, but had never executed in one piece in any environment — staging cannot rehearse it (provisioningConfigured()isBoolean(process.env.PROVISION_GITHUB_TOKEN), absent there), so the first ever run of the hand-offs would have been a real restaurant's account.tests/e2e/connect-mint.spec.tsexercises everything belowopenProvisioningPragainst the real Stripe API on ansk_test_key and the suite's throwaway Postgres. The GitHub half stays out: it writes to the deploy repo, which is not a throwaway.prisma/schema.prismagainsmoduleFormat = "cjs", because the generator was emitting an ESM-only client into a package that declares notype— so only a bundler could loadlib/db.ts, which is precisely why this chain had no way to be exercised outside a browser request.Issue / plan link
docs/plans/BACKLOG.md— "The mint path has NEVER run end to end anywhere — staging cannot rehearse it"; ADR-011 amendment slices E1–E4.Type
test(+chorefor the generator format)What it asserts (all through the real functions, no mocks)
online-paymentsgets anacct_-shaped account and apaymentsLinkUrlthat is absolute and carries no locale prefix — and the claim is then measured, not just asserted about a string: the same URL is fetched withaccept-language: franddeand must produce two different locale redirects. A stored URL has to serve a French-reading room and a German-reading owner.StripeConnectAccountrow exists for that slug with the returned account id, a non-nullonboardingTokenthat the URL actually addresses,country: CH, and the<slug>-connect-express-v1key.type: express,country: CH,metadata[sofra_tenant], the tenant's ownbusiness_profile[url], mcc 5812, and all three capabilities requested at creation (they cannot be added by update).acct_from Stripe's idempotency key, not mint a twin. Stripe's own account list, filtered onmetadata[sofra_tenant], is the only oracle that can see a second live account; the unique slug column cannot.online-paymentsmints nothing ({}, no note, no row, no account).Every "nothing was created" claim is paired with a positive control (a slug that must be found in the same listing), because an empty result and a blind instrument read identically.
Cleanup
Asserted, not best-effort — unlike the Mollie teardown, where a missed cancel leaves a test charge into a dead sink. A leaked connected account is a live-shaped KYC object with a bearer onboarding link. So
afterAllbuilds its inventory from the database AND Stripe (not from what a test remembered to report — the account that most needs deleting is the one created by a test that then failed), deletes each, and assertsdeleted: trueand a follow-upGET → 403. Failures are collected before any is reported, so a throw on the first account cannot leak the second.The file header states why this is test-mode only: a test-mode account is deletable, a live one belongs to a real business, and Stripe refuses to delete live accounts with the standard dashboard where it carries the negative-balance risk.
NFR triage (DEV-PHASES-PLAN P1)
DELETE /v1/accountsin the test process only — deliberately not added tolib/stripe.ts, because nothing in this app may destroy a restaurant's payment account.scripts/e2e-suite.shand CI both hard-refuse anything that is notsk_test_; the CI secret is scoped to one step and fork PRs never receive it.billing-mollie.spec.tsexactly — real API, refuses a live key, and skips with a stated reason rather than passing quietly.Database / migrations
No schema change.
moduleFormatis a generator output format;lib/generated/is gitignored and rebuilt byprisma generateon every build, so nothing here is a reviewable artifact andprisma_driftis untouched.Standard checklist
npm run typecheck·npm run lint·npm run test:coverage·node scripts/check-single-file.mjs --allgreen locally.env.examplegains a documented placeholder only)develop, PR todevelopTest plan — what actually ran
bash scripts/e2e-suite.sh(full, unmocked, with the real Mollietest_key): 83/83 passed, so the generator change is exercised by every DB-touching spec and by a real Mollie first payment, not only by the new file.bash scripts/e2e-suite.sh connect-mint: 3/3, twice. Each run created one real Stripe test account and deleted it — logged ase2e cleanup: 1 connected account(s) — [{"id":"acct_…","deleted":true,"afterGet":403}].e2e-slug; only the two pre-existing untagged ones remain.Deploy notes
STRIPE_API_KEY_TESTis read byscripts/e2e-suite.shonly (with a fallback toSTRIPE_SECRET_KEYwhen that is itself ansk_test_key, so the spec does not skip on a machine that already has a good test key).secrets.STRIPE_API_KEY_TESTbut the secret does not exist yet, so the spec skips on GitHub exactly as the Mollie one does. Until it is added, the chain is proven by a localscripts/e2e-suite.shrun and nothing more.