Files
ced4568 fbf52d8334 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.
2026-08-28 09:47:01 -05:00

336 lines
26 KiB
JavaScript

#!/usr/bin/env node
// Phase 9 — Commerce Integration acceptance test. Price configuration, the public price
// serializer, checkout eligibility (agreements/registration-window/price), server-authoritative
// amount, verified webhook processing (signature, idempotency, success/failure/refund),
// registration confirmation -> enrollment conversion -> activation, guardian recipient routing,
// capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility,
// RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows
// are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials,
// no real money, deterministic.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase9.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const { getPaymentProvider } = require('../api/src/payment-provider');
const commerce = require('../api/src/commerce');
const { Pool } = require('../api/node_modules/pg');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function psql(sql) {
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 integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function integrationGet(path) {
const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function webhookPost(body, signature) {
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) {
await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' });
if (requireAgreement) {
const agreementId = `agree-${offeringId}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {});
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
}
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
}
async function main() {
console.log('Phase 9 Commerce Integration acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
const provider = getPaymentProvider();
assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe');
// ── 1/2: price configuration, public price serializer ────────────────────────────────────
const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`;
await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => {
await setupPaidOffering(owner, { offeringId, slug, capacity: 3 });
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
assert.equal(pub.price_amount, 15000);
assert.equal(pub.price_currency, 'usd');
assert.equal(pub.requires_payment, true);
for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`);
});
// ── 3/4: checkout eligibility, missing agreements rejected ───────────────────────────────
let registrationId;
await test('checkout is rejected until required agreements are accepted', async () => {
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
registrationId = reg.body.registration_id;
const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
});
// ── 5: closed registration rejected ───────────────────────────────────────────────────────
await test('registration is rejected on a draft/closed offering', async () => {
const draftOffering = `off-p9-draft-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } });
assert.equal(reg.status, 409);
assert.equal(reg.body.error, 'OFFERING_NOT_OPEN');
});
// ── 6: full offering rejected ─────────────────────────────────────────────────────────────
await test('a full offering rejects further registrations', async () => {
const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false });
const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } });
assert.equal(r1.status, 201);
const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } });
assert.equal(r2.status, 409);
assert.equal(r2.body.error, 'OFFERING_FULL');
});
// ── 7/8: server-authoritative amount, checkout creation ──────────────────────────────────
let transactionId, providerReference;
await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => {
const agreementId = `agree-${offeringId}`;
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 });
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
transactionId = checkout.body.transaction_id;
const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`);
assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent');
const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
providerReference = txRow.body[0].provider_reference;
});
// ── 9/10/11: webhook signature, invalid rejection, idempotency ───────────────────────────
await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => {
const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' });
const bad = await webhookPost(event.body, 'wrong-signature');
assert.equal(bad.status, 400);
assert.equal(bad.body.error, 'invalid_signature');
const first = await webhookPost(event.body, event.signature);
assert.equal(first.status, 200);
assert.equal(first.body.result, 'processed');
const replay = await webhookPost(event.body, event.signature);
assert.equal(replay.status, 200);
assert.equal(replay.body.result, 'duplicate');
const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`);
assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment');
});
// ── 12/14/15/16: payment success, registration confirmation, conversion, activation ──────
let convertedStudentId;
await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => {
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(reg.body.status, 'enrollment_activated');
convertedStudentId = reg.body.student_id;
assert.ok(convertedStudentId);
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active');
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
const types = notifs.body.map((n) => n.notification_type);
assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`);
// no duplicate registration_confirmed
assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1);
});
// ── 13: payment failure ────────────────────────────────────────────────────────────────────
await test('a failed payment does not release the seat and leaves the registration retriable', async () => {
const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } });
const checkout = 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 event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
const result = await webhookPost(event.body, event.signature);
assert.equal(result.body.result, 'processed');
const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`);
assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat');
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
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}`;
await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false });
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [201, 409]);
const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`);
assert.equal(off.body.reserved_seats, 1);
});
// ── 20/21: abandoned reservation release ─────────────────────────────────────────────────
await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => {
const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } });
psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`);
const result = await commerce.reapExpiredReservations(pool);
assert.ok(result.released >= 1);
const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`);
assert.equal(offAfter.body.reserved_seats, 0);
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'cancelled');
});
// ── 22: refund recording ──────────────────────────────────────────────────────────────────
await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
const paid = tx.body.find((t) => t.status === 'paid');
const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' });
assert.equal(refund.status, 200);
assert.equal(refund.body.status, 'refunded');
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 enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment');
});
// ── 17: guardian recipient routing ────────────────────────────────────────────────────────
await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => {
const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false });
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug,
registrant: { display_name: 'Minor Camper', is_minor: true },
guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` },
});
assert.equal(submit.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${submit.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/${submit.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' });
await webhookPost(event.body, event.signature);
const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`);
assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email');
const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`);
assert.ok(notifs.body.some((n) => n.notification_type === 'activation'));
});
// ── 18: admin payment view ────────────────────────────────────────────────────────────────
await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.ok(tx.body.length > 0);
for (const t of tx.body) {
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
}
});
// ── 24/25: instructor payment-data restrictions, student payment isolation ──────────────
await test('a plain instructor cannot see registration payment data; the public API never can either', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' });
const stray = makeSession(GW);
await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' });
const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.equal(denied.status, 403);
});
// ── 26/27: audit hygiene, metric hygiene ─────────────────────────────────────────────────
await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => {
const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`);
assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata');
const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null);
// Metrics may be unauthenticated-admin or absent in some deployments — only assert content
// shape when reachable, never fail the whole suite on an unrelated auth requirement.
if (metricsBody && metricsBody.ok) {
const text = await metricsBody.text();
assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label');
}
});
await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => {
const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`);
assert.equal(status.status, 200);
assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']);
const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`);
assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route');
});
// ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ───────
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
});
await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => {
const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(checkoutAttempt.status, 409);
assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED');
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p9-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' });
assert.equal(session.status, 201);
});
await pool.end();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });