test(stripe): add real-Stripe regression coverage and complete the opt-in TEST-mode check
e2e-phase9.js: one new deterministic regression test proving the webhook-correlation fix — a test event referencing a transaction's own UUID (the exact shape a real Stripe payment_intent.payment_failed/charge.refunded event now produces) is still matched. 20/20. stripe-integration-test.js: configurable price/currency (STRIPE_TEST_PRICE_CENTS/ STRIPE_TEST_CURRENCY) and a STRIPE_TEST_MINOR=true guardian+minor path. Once a real payment lands, now fully automates deep post-payment verification: exactly-one payment_transactions record, registration/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery proof (re-signing the REAL event fetched via stripe.events.retrieve — never a fabricated one), real student activation + program-visibility, and a real refund with the non-cascade rule reconfirmed — plus a fully automated, browser-free abandoned-checkout proof via Stripe's own sessions.expire() API. Still opt-in, still a guaranteed no-op by default, still refuses live-mode keys.
This commit is contained in:
@@ -175,6 +175,29 @@ async function main() {
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
|
||||
});
|
||||
|
||||
// Phase 9.1 real-Stripe-TEST regression — a real Stripe payment_intent.payment_failed/
|
||||
// charge.refunded event references the PaymentIntent/Charge, not the Checkout Session, so
|
||||
// StripeProvider.normalizeEvent() resolves those back to OUR OWN transaction_id (never the
|
||||
// stored provider_reference) via payment_intent_data.metadata. This deterministic test
|
||||
// simulates exactly that shape — a webhook event whose providerReference is the transaction's
|
||||
// own UUID, not its provider_reference — proving commerce.js's lookup matches on either
|
||||
// column. Found and fixed only by testing against real Stripe (the deterministic
|
||||
// provider previously always reused one caller-supplied reference, which never exposed this).
|
||||
await test('a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched', async () => {
|
||||
const idOffering = `off-p9-txidref-${RUN_ID}`, idSlug = `p9-txidref-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: idOffering, slug: idSlug, capacity: 2, requireAgreement: false });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-txidref-evt-${RUN_ID}`, offering_slug: idSlug, registrant: { display_name: 'TxIdRef', email: `txidref-${RUN_ID}@example.com` } });
|
||||
await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const transactionId = txRow.body[0].transaction_id;
|
||||
assert.notEqual(transactionId, txRow.body[0].provider_reference, 'sanity check — these must be genuinely different values for this test to mean anything');
|
||||
const event = provider.buildTestEvent({ providerReference: transactionId, eventType: 'payment_intent.payment_failed' });
|
||||
const result = await webhookPost(event.body, event.signature);
|
||||
assert.equal(result.body.result, 'processed', 'the transaction must be found via transaction_id, not just provider_reference');
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment (matched via transaction_id) still leaves the registration retriable, not cancelled');
|
||||
});
|
||||
|
||||
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
|
||||
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
|
||||
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
|
||||
|
||||
@@ -24,14 +24,22 @@
|
||||
// side effect — this does not require an actual completed payment to test honestly.
|
||||
//
|
||||
// What this script deliberately does NOT fake: actually completing Stripe's hosted Checkout
|
||||
// page (filling in a test card) is a real human/browser action (or a Stripe CLI `stripe
|
||||
// trigger` against Stripe's own backend) — there is no supported pure-API way to "complete" a
|
||||
// Checkout Session without one of those, and fabricating a signed "payment succeeded" event for
|
||||
// a session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was
|
||||
// told never to produce. When enabled, this script prints the real checkout_url and pauses,
|
||||
// polling RootNode's own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90)
|
||||
// while a human completes it in a browser (test card 4242 4242 4242 4242) or a parallel
|
||||
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relays Stripe's real event — see
|
||||
// page (filling in a test card) is a real human/browser action — there is no supported pure-API
|
||||
// way to "pay" a Checkout Session, and fabricating a signed "payment succeeded" event for a
|
||||
// session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was told
|
||||
// never to produce. When enabled, this script prints the real checkout_url and polls RootNode's
|
||||
// own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90) while a human
|
||||
// completes it in a browser (test card 4242 4242 4242 4242) with a parallel
|
||||
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relaying Stripe's real event.
|
||||
//
|
||||
// Once that real payment lands, everything else IS fully automated via real Stripe API calls:
|
||||
// payment-transaction/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery
|
||||
// proof (re-signing the REAL event Stripe recorded, fetched fresh via stripe.events.retrieve —
|
||||
// never a fabricated one), real student activation + program-visibility proof, a real Stripe
|
||||
// TEST refund with the non-cascade rule reconfirmed, and — needing no browser at all — a real
|
||||
// abandoned/expired-checkout proof via Stripe's own `sessions.expire()` API. Configurable via
|
||||
// STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY (defaults to a cheap $1 generic check) and
|
||||
// STRIPE_TEST_MINOR=true (guardian+minor path instead of adult). See
|
||||
// docs/STRIPE_INTEGRATION_RUNBOOK.md and docs/PHASE9_1_STRIPE_TEST_VALIDATION.md.
|
||||
|
||||
const assert = require('assert');
|
||||
@@ -72,7 +80,12 @@ const { makeSession } = require('./session-helper');
|
||||
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
const TEST_PRICE_CENTS = 100; // $1.00 — small, non-zero, clearly a TEST amount
|
||||
// Phase 9.1 resume — overridable so a real business price (e.g. the $399/39900 Founding Cohort
|
||||
// tuition) can be validated directly against Stripe's own recorded amount, instead of always
|
||||
// testing an arbitrary $1. Defaults stay cheap/generic for a routine opt-in regression run.
|
||||
const TEST_PRICE_CENTS = Number(process.env.STRIPE_TEST_PRICE_CENTS) || 100;
|
||||
const TEST_CURRENCY = process.env.STRIPE_TEST_CURRENCY || 'usd';
|
||||
const TEST_MINOR = process.env.STRIPE_TEST_MINOR === 'true'; // guardian+minor path vs. adult
|
||||
const TAMPERED_AMOUNT_CENTS = 1; // what a malicious caller will try to substitute
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
@@ -85,25 +98,34 @@ async function integrationPost(path, body) {
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
function psql(sql) {
|
||||
const { execSync } = require('child_process');
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\n');
|
||||
|
||||
console.log(`Price under test: ${TEST_PRICE_CENTS} ${TEST_CURRENCY} (${(TEST_PRICE_CENTS / 100).toFixed(2)}) — ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration path.\n`);
|
||||
|
||||
const owner = makeSession(GW);
|
||||
const offeringId = `stripe-it-${RUN_ID}`, slug = `stripe-integration-test-${RUN_ID}`;
|
||||
const agreementId = `stripe-it-agreement-${RUN_ID}`;
|
||||
let registrationId, sessionId, checkoutUrl;
|
||||
let registrationId, sessionId, checkoutUrl, cohortId;
|
||||
|
||||
await test('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
});
|
||||
|
||||
await test('a disposable TEST offering is created with a real, small, non-zero TEST price', async () => {
|
||||
await test('a disposable TEST offering is created with the real TEST price and a cohort', async () => {
|
||||
cohortId = `stripe-it-cohort-${RUN_ID}`;
|
||||
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: `[TEST] Stripe Integration Cohort ${RUN_ID}`, tier: 'root' });
|
||||
assert.equal(cohort.status, 201, JSON.stringify(cohort.body));
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID}`,
|
||||
programId: PROGRAM, deliveryMode: 'self_paced',
|
||||
priceAmount: TEST_PRICE_CENTS, priceCurrency: 'usd',
|
||||
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID} — NOT the real Winter Break offering`,
|
||||
programId: PROGRAM, deliveryMode: 'cohort', cohortId,
|
||||
priceAmount: TEST_PRICE_CENTS, priceCurrency: TEST_CURRENCY,
|
||||
});
|
||||
assert.equal(create.status, 201, JSON.stringify(create.body));
|
||||
const agreement = await owner.request('POST', '/api/admin/agreements', { agreementId, title: '[TEST] Stripe Integration Waiver', bodySummary: 'Disposable test agreement — Phase 9.1.' });
|
||||
@@ -115,11 +137,14 @@ async function main() {
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
await test('a real registration is created through the public integration contract', async () => {
|
||||
const reg = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` },
|
||||
});
|
||||
await test(`a real ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration is created through the public integration contract`, async () => {
|
||||
const body = TEST_MINOR
|
||||
? { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Test Camper', is_minor: true },
|
||||
guardian: { display_name: 'Stripe IT Test Guardian', email: `stripe-it-guardian-${RUN_ID}@example.com`, phone: '555-0199' } }
|
||||
: { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` } };
|
||||
const reg = await integrationPost('/api/integrations/registrations', body);
|
||||
assert.equal(reg.status, 201, JSON.stringify(reg.body));
|
||||
registrationId = reg.body.registration_id;
|
||||
});
|
||||
@@ -154,8 +179,9 @@ async function main() {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
assert.equal(session.mode, 'payment');
|
||||
assert.equal(session.amount_total, TEST_PRICE_CENTS, 'Stripe must have recorded the OFFERING price, never the tampered caller amount');
|
||||
assert.equal(session.currency, 'usd', 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
|
||||
assert.equal(session.currency, TEST_CURRENCY, 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
|
||||
assert.equal(session.livemode, false, 'this must be a TEST-mode session — refusing to proceed otherwise');
|
||||
console.log(` Stripe session ${sessionId}: amount_total=${session.amount_total} ${session.currency}, livemode=${session.livemode}`);
|
||||
});
|
||||
|
||||
await test('an invalid webhook signature is rejected with no domain side effect', async () => {
|
||||
@@ -188,13 +214,139 @@ async function main() {
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
let realEventId = null, realPaymentIntentId = null, enrollmentId = null, studentId = null;
|
||||
|
||||
if (finalStatus) {
|
||||
console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`);
|
||||
passed++;
|
||||
|
||||
await test('exactly one payment_transactions record, paid, reconciled against Stripe — no card data anywhere', async () => {
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
assert.equal(tx.body.length, 1, `expected exactly one transaction, got ${tx.body.length}`);
|
||||
const t = tx.body[0];
|
||||
assert.equal(t.status, 'paid');
|
||||
assert.equal(t.provider_reference, sessionId);
|
||||
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
realPaymentIntentId = session.payment_intent;
|
||||
assert.ok(realPaymentIntentId, 'a paid session must have a real PaymentIntent');
|
||||
realEventId = psql(`SELECT webhook_event_id FROM payment_webhook_events WHERE provider = 'stripe' AND transaction_id = (SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}') ORDER BY received_at DESC LIMIT 1`);
|
||||
assert.ok(realEventId, 'the real Stripe event id must be recorded');
|
||||
});
|
||||
|
||||
await test('registration confirmed, enrollment converted exactly once, cohort membership created, guardian preserved', async () => {
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
enrollmentId = reg.body.enrollment_id;
|
||||
studentId = reg.body.student_id;
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'exactly one enrollment must exist for this student/program');
|
||||
const cohortCount = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${studentId}'`);
|
||||
assert.equal(cohortCount.trim(), '1', 'exactly one cohort membership must exist');
|
||||
if (TEST_MINOR) {
|
||||
const guardianLink = psql(`SELECT count(*) FROM student_guardians WHERE student_id = '${studentId}'`);
|
||||
assert.equal(guardianLink.trim(), '1', 'the minor student must have exactly one real guardian relationship');
|
||||
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${studentId}'`);
|
||||
assert.equal(studentEmail.trim(), '', 'the guardian email must never be written to the minor student\'s own identity record');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a REAL, Stripe-issued duplicate delivery of the same event is idempotent — no double conversion', async () => {
|
||||
// Fetches the REAL event Stripe recorded (not fabricated) and re-signs it with the REAL
|
||||
// webhook secret to prove a genuine Stripe retry-delivery scenario is handled correctly —
|
||||
// never inventing a "payment succeeded" event for a session nobody paid (that would be
|
||||
// exactly the faked validation this phase was told never to produce). This mirrors what
|
||||
// `stripe events resend` does server-side; done locally here because a `stripe listen`
|
||||
// ephemeral forwarding session has no persistent dashboard endpoint id to target.
|
||||
const realEvent = await stripe.events.retrieve(realEventId);
|
||||
const payload = JSON.stringify(realEvent);
|
||||
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET });
|
||||
const replay = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload });
|
||||
const replayBody = await replay.json();
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replayBody.result, 'duplicate');
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'a duplicate delivery must never create a second enrollment');
|
||||
const notifCount = psql(`SELECT count(*) FROM notification_events WHERE enrollment_id = '${enrollmentId}' AND notification_type IN ('activation','enrollment_confirmation')`);
|
||||
assert.equal(notifCount.trim(), '1', 'a duplicate delivery must never queue a second activation/confirmation email');
|
||||
});
|
||||
|
||||
await test('student access: the real activation token works and the enrolled program is visible', async () => {
|
||||
require('child_process').execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await sleep(3000);
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
|
||||
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation' || n.notification_type === 'enrollment_confirmation');
|
||||
assert.ok(activationNotif, `expected a real activation/confirmation notification, got: ${JSON.stringify(notifs.body)}`);
|
||||
if (activationNotif.notification_type === 'activation') {
|
||||
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
const s = makeSession(GW);
|
||||
const activate = await s.request('POST', '/api/student/activate', { token, password: 'StripeIT!2026Test' });
|
||||
assert.equal(activate.status, 200, JSON.stringify(activate.body));
|
||||
const programs = await s.request('GET', '/api/student/programs');
|
||||
assert.ok(programs.body.some((p) => p.program_id === PROGRAM), 'the enrolled program must be visible to the newly-activated student');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a Stripe TEST refund is recorded without cascading into registration/enrollment status', async () => {
|
||||
const refund = await stripe.refunds.create({ payment_intent: realPaymentIntentId });
|
||||
assert.equal(refund.status, 'succeeded');
|
||||
// Give the (not-yet-forwarded) refund webhook a moment if stripe listen also relays
|
||||
// charge.refunded; either way, prove RootNode's OWN non-cascade rule holds regardless —
|
||||
// record it via the admin route directly if the webhook hasn't landed yet.
|
||||
await sleep(2000);
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
const t = tx.body[0];
|
||||
if (t.status !== 'refunded') {
|
||||
const transactionId = psql(`SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}'`).trim();
|
||||
await owner.request('POST', `/api/admin/payments/${transactionId}/refund`, {});
|
||||
}
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
|
||||
const enrollmentAfter = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${enrollmentId}'`);
|
||||
assert.equal(enrollmentAfter.trim(), 'active', 'a refund must never auto-revoke the enrollment — this is a business-policy question, not something to decide in code');
|
||||
console.log(` Stripe refund ${refund.id}: status=${refund.status}, amount=${refund.amount} ${refund.currency}`);
|
||||
});
|
||||
|
||||
console.log(`\n${'─'.repeat(78)}`);
|
||||
console.log('Stripe <-> RootNode reconciliation for this run:');
|
||||
console.log(` RootNode registration: ${registrationId}`);
|
||||
console.log(` RootNode enrollment: ${enrollmentId}`);
|
||||
console.log(` RootNode student: ${studentId}`);
|
||||
console.log(` Stripe Checkout Session: ${sessionId}`);
|
||||
console.log(` Stripe PaymentIntent: ${realPaymentIntentId}`);
|
||||
console.log(` Stripe event (webhook): ${realEventId}`);
|
||||
console.log(` Amount / currency: ${TEST_PRICE_CENTS} ${TEST_CURRENCY}`);
|
||||
} else {
|
||||
console.log(' (not completed within the wait window — this is expected in an unattended/CI run; rerun interactively to complete the manual step, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
|
||||
console.log(' (not completed within the wait window — go complete the checkout in a browser and rerun, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
|
||||
}
|
||||
|
||||
await test('abandoned/expired checkout (pure API, no browser needed): the seat is released exactly once', async () => {
|
||||
const abReg = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `stripe-it-abandon-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Abandon Tester', email: `stripe-it-abandon-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(abReg.status, 201, JSON.stringify(abReg.body));
|
||||
const abAccept = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(abAccept.status, 201);
|
||||
const abCheckout = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(abCheckout.status, 201, JSON.stringify(abCheckout.body));
|
||||
const abTx = await owner.request('GET', `/api/admin/registrations/${abReg.body.registration_id}/payments`);
|
||||
const abSessionId = abTx.body[0].provider_reference;
|
||||
// A REAL Stripe API call — no browser needed — asking Stripe to expire an open session,
|
||||
// which makes Stripe itself fire a genuine checkout.session.expired webhook.
|
||||
await stripe.checkout.sessions.expire(abSessionId);
|
||||
const deadline2 = Date.now() + 30000;
|
||||
let released = false;
|
||||
while (Date.now() < deadline2) {
|
||||
const r = await fetch(`${GW}/api/integrations/registrations/${abReg.body.registration_id}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||
const s = await r.json();
|
||||
if (s.status === 'cancelled') { released = true; break; }
|
||||
await sleep(2000);
|
||||
}
|
||||
assert.ok(released, 'the abandoned/expired checkout must release the seat within 30s of the real Stripe webhook');
|
||||
});
|
||||
|
||||
// Best-effort cleanup — never fatal.
|
||||
try { await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'cancelled' }); } catch { /* ponytail: best-effort cleanup only */ }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user