From 8b3e56e4020086418dc9e5452d98820ba7ad0dd3 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Thu, 27 Aug 2026 09:05:46 -0500 Subject: [PATCH] test(phase9): add commerce integration acceptance suite 19/19: pricing in the public serializer, checkout gated on agreements, server-authoritative amount, webhook signature verification and idempotency, payment success/failure/abandonment, last-seat checkout race, reservation reaper, manual refund with no cascade, minor guardian routing, admin/RBAC payment-data restrictions, audit/metric hygiene, the status-lookup endpoint, and confirmation that ROOT_ V1/free-offering/classroom flows are unaffected. --- platform/README.md | 18 ++- platform/test/e2e-phase9.js | 312 ++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 platform/test/e2e-phase9.js diff --git a/platform/README.md b/platform/README.md index 03cfc86..ff2f4e2 100644 --- a/platform/README.md +++ b/platform/README.md @@ -118,12 +118,24 @@ further instructor accounts from the Instructors tab). (sequential and concurrent idempotency), required-agreement conversion blocking with version snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self- paced/classroom flows remain unaffected. +- `test/e2e-phase9.js` — offering pricing in the public serializer, checkout blocked until + agreements are accepted, the server-authoritative amount (never caller-supplied), webhook + signature verification and idempotency, payment success driving real conversion/activation, a + failed payment leaving the seat intact vs. an abandoned checkout releasing it, the reservation + reaper, a last-seat checkout race, manual refund with no cascade into registration/enrollment + status, minor payment confirmations routing to the guardian, the admin payment view (never card + data), instructor/public RBAC on payment data, audit/metric hygiene (no secrets, no + high-cardinality labels), the website status-lookup endpoint's minimal safe shape, and + confirmation that ROOT_ V1/free-offering/classroom flows remain unaffected. `infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations, Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp -rehearsals) — see each script's own header and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/ -PHASE8_ENROLLMENT_OPERATIONS.md` for what they prove beyond what the e2e suites -above already cover via direct API. +rehearsals, Phase 9's Winter Break COMMERCE rehearsal — +`test-phase9-winter-camp-commerce.js`, 13/13, real checkout/webhook against the deterministic +test payment provider, no live money) — see each script's own header and +`docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/PHASE8_ENROLLMENT_OPERATIONS.md`/`docs/ +PHASE9_COMMERCE_AND_LAUNCH.md` for what they prove beyond what the e2e suites above already +cover via direct API. All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is diff --git a/platform/test/e2e-phase9.js b/platform/test/e2e-phase9.js new file mode 100644 index 0000000..9e4017d --- /dev/null +++ b/platform/test/e2e-phase9.js @@ -0,0 +1,312 @@ +#!/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'); + }); + + // ── 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); });