#!/usr/bin/env node // Phase 8 — Enrollment Operations acceptance test. Admin offering/registration operations, // launch readiness, registration-window enforcement, capacity concurrency through the REAL // registration endpoints, the public offering serializer, the website registration integration // contract (idempotency, concurrent duplicates), agreements (required/missing/version snapshot, // blocking conversion), guardian recipient resolution, RBAC, and confirmation that ROOT_ V1/ // self-paced/classroom flows are unaffected. Same conventions as e2e-phase6a-7.js. // // 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=... INTEGRATION_SHARED_SECRET=... node test/e2e-phase8.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); 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'; 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 main() { console.log('Phase 8 Enrollment Operations 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); } // ── 1/2/3: admin offering list/detail/CRUD ─────────────────────────────────────────────── let offeringId, slug; await test('admin can create, list, and fetch an offering', async () => { offeringId = `off-p8-${RUN_ID}`; slug = `p8-camp-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: 'Phase 8 Test Camp', programId: PROGRAM, deliveryMode: 'camp', capacity: 3 }); assert.equal(create.status, 201); const list = await owner.request('GET', '/api/admin/offerings'); assert.ok(list.body.some((o) => o.offering_id === offeringId)); const detail = await owner.request('GET', `/api/admin/offerings/${offeringId}`); assert.equal(detail.status, 200); assert.equal(detail.body.status, 'draft'); }); // ── 4/5: status transitions, readiness calculation ─────────────────────────────────────── await test('status transitions are server-defined; readiness reflects real configuration', async () => { const badTransition = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' }); assert.equal(badTransition.status, 409); const r1 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`); assert.equal(r1.body.ready_to_open_registration, false); const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-lead-${RUN_ID}@example.com`, display_name: 'P8 Lead', role: 'instructor', password: 'P8LeadPass123!' }); await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' }); await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' }); const r2 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`); const instructorCheck = r2.body.checks.find((c) => c.key === 'instructor'); assert.equal(instructorCheck.ready, true); }); // ── 6/7: registration open/closed enforcement ──────────────────────────────────────────── let closedOffering; await test('registration is rejected on a draft offering, and accepted once open', async () => { closedOffering = `off-p8-closed-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: closedOffering, slug: `p8-closed-${RUN_ID}`, name: 'Closed Offering', programId: PROGRAM, deliveryMode: 'workshop' }); const rejected = await owner.request('POST', `/api/admin/offerings/${closedOffering}/registrations`, { registrantDisplayName: 'Too Early' }); assert.equal(rejected.status, 409); assert.equal(rejected.body.error, 'OFFERING_NOT_OPEN'); const accepted = await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult First', registrantEmail: `adult-first-${RUN_ID}@example.com` }); assert.equal(accepted.status, 201); }); // ── 8/9: full capacity + concurrent final-seat race ────────────────────────────────────── await test('capacity is enforced and a concurrent final-seat race yields exactly one winner', async () => { await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult Second', registrantEmail: `adult-second-${RUN_ID}@example.com` }); // capacity=3, 2 used — one seat left. Race two integration submissions for it. const [a, b] = await Promise.all([ integrationPost('/api/integrations/registrations', { event_id: `p8-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `racea-${RUN_ID}@example.com` } }), integrationPost('/api/integrations/registrations', { event_id: `p8-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `raceb-${RUN_ID}@example.com` } }), ]); const statuses = [a.status, b.status].sort(); assert.deepEqual(statuses, [201, 409], `expected exactly one winner: ${JSON.stringify([a, b])}`); const final = await owner.request('GET', `/api/admin/offerings/${offeringId}`); assert.equal(final.body.reserved_seats, 3); assert.ok(final.body.reserved_seats <= final.body.capacity, 'must never overbook'); }); // ── 10/11: public offering safe serializer, no internal fields leaked ─────────────────── await test('the public offerings API never leaks internal/PII fields', async () => { const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json()); const forbidden = ['access_notes', 'organization_id', 'location_id', 'created_by', 'reserved_seats', 'capacity', 'metadata']; for (const key of forbidden) assert.ok(!(key in pub), `public offering response must never include ${key}`); assert.equal(pub.availability, 'full', 'a fully-booked offering should report full availability, not a raw seat count'); }); // ── 12/13/14: website registration integration, duplicate idempotency, concurrent dup ──── let integrationOffering, integrationSlug; await test('website registration integration is idempotent under sequential AND concurrent replay', async () => { integrationOffering = `off-p8-web-${RUN_ID}`; integrationSlug = `p8-web-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: integrationOffering, slug: integrationSlug, name: 'Web Offering', programId: PROGRAM, deliveryMode: 'self_paced' }); await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'enrollment_open' }); const eventId = `p8-web-evt-${RUN_ID}`; const first = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } }); assert.equal(first.status, 201); const replay = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } }); assert.equal(replay.status, 200); assert.equal(replay.body.registration_id, first.body.registration_id); // Concurrent duplicate — two identical submissions racing the same event_id. const concEventId = `p8-web-conc-${RUN_ID}`; const [c1, c2] = await Promise.all([ integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }), integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }), ]); assert.equal(c1.body.registration_id, c2.body.registration_id, 'a concurrent duplicate submission must resolve to exactly one logical registration'); const count = psql(`SELECT count(*) FROM offering_registrations WHERE registration_id = '${c1.body.registration_id}'`); assert.equal(Number(count), 1); }); // ── 15/16/17: adult registration, minor registration, guardian relationship ────────────── let guardianId, minorRegId; await test('adult registration needs no guardian; minor registration creates/links a real guardian', async () => { guardianId = `guardian-p8-${RUN_ID}`; await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P8 Guardian', email: `p8guardian-${RUN_ID}@example.com` }); const minorReg = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { guardianId, registrantDisplayName: 'P8 Minor', isMinor: true }); assert.equal(minorReg.status, 201); minorRegId = minorReg.body.registration_id; const badMinor = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true }); assert.equal(badMinor.status, 400); }); // ── 18: student email semantics preserved (no guardian email written to students.email) ── await test('converting a minor registration never writes the guardian email into students.email', async () => { await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' }); await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' }); const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {}); assert.equal(convert.status, 201, JSON.stringify(convert.body)); assert.equal(convert.body.student.email, null); assert.ok(convert.body.student.login_identifier.startsWith('reg-')); }); // ── 19/20/21: required agreement behavior, version snapshot, missing blocks conversion ─── let agreementId, agreementOffering; await test('a required agreement blocks conversion until accepted; acceptance snapshots the version', async () => { agreementId = `agree-p8-${RUN_ID}`; await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P8 Agreement' }); agreementOffering = `off-p8-agree-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: agreementOffering, slug: `p8-agree-${RUN_ID}`, name: 'Agreement Offering', programId: PROGRAM, deliveryMode: 'workshop' }); await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'enrollment_open' }); await owner.request('POST', `/api/admin/offerings/${agreementOffering}/agreement-requirements`, { agreement_id: agreementId }); const reg = await owner.request('POST', `/api/admin/offerings/${agreementOffering}/registrations`, { registrantDisplayName: 'Agreement Tester', registrantEmail: `agreetest-${RUN_ID}@example.com` }); await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' }); await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' }); const blocked = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {}); assert.equal(blocked.status, 409); assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE'); const accept = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/agreements`, { agreementId, acceptedByType: 'registrant' }); assert.equal(accept.status, 201); assert.equal(accept.body.accepted_version, 1); psql(`UPDATE agreement_definitions SET version = version + 1 WHERE agreement_id = '${agreementId}'`); const accepted = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/agreement-status`); assert.equal(accepted.body.missing.length, 0, 'the already-recorded acceptance must still count even after the definition\'s live version changed'); const nowOk = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {}); assert.equal(nowOk.status, 201); }); // ── 22/23: registration→enrollment, offering→cohort ────────────────────────────────────── await test('a cohort-backed offering creates real cohort membership on conversion', async () => { const cohortId = `p8-cohort-${RUN_ID}`; await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort', tier: 'root' }); const cohortOffering = `off-p8-cohort-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: cohortOffering, slug: `p8-cohort-off-${RUN_ID}`, name: 'Cohort Offering', programId: PROGRAM, deliveryMode: 'cohort', cohortId }); await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'enrollment_open' }); const reg = await owner.request('POST', `/api/admin/offerings/${cohortOffering}/registrations`, { registrantDisplayName: 'Cohort Student', registrantEmail: `cohortstudent-${RUN_ID}@example.com` }); await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' }); await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' }); const convert = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {}); const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${convert.body.student.student_id}'`); assert.equal(Number(cohortRow), 1); }); // ── 24/25: account activation, notification queued ─────────────────────────────────────── await test('a converted student has a real activation token and a queued registration notification', async () => { const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`); assert.ok(notifs.body.length > 0, 'at least a registration_received notification must have been queued'); assert.ok(notifs.body.every((n) => !n.recipient_email_masked || n.recipient_email_masked.includes('*')), 'recipient email must always be masked in the admin view'); }); // ── 26: guardian recipient resolution ──────────────────────────────────────────────────── await test('notification recipient resolution prefers a real guardian when the student has no email', async () => { const notifications = require('../api/src/notifications'); const { Pool } = require('../api/node_modules/pg'); const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' }); try { const { rows: studentRows } = await pool.query(`SELECT student_id FROM offering_registrations WHERE registration_id = '${minorRegId}'`); const studentId = studentRows[0].student_id; await pool.query(`INSERT INTO student_guardians (student_id, guardian_id, is_primary_contact, can_receive_communications) VALUES ($1, $2, true, true) ON CONFLICT (student_id, guardian_id) DO NOTHING`, [studentId, guardianId]); const recipient = await notifications.resolveRecipient(pool, studentId, null); assert.equal(recipient, `p8guardian-${RUN_ID}@example.com`); } finally { await pool.end(); } }); // ── 28/29: instructor offering access, instructor guardian-data denial ────────────────── await test('a plain instructor can see offerings but not guardians/registrations; RBAC holds', async () => { const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p8-stray-${RUN_ID}@example.com`, display_name: 'P8 Stray', role: 'instructor', password: 'P8StrayPass123!' }); const stray = makeSession(GW); await stray.request('POST', '/api/auth/instructor/login', { email: `p8-stray-${RUN_ID}@example.com`, password: 'P8StrayPass123!' }); const offeringsList = await stray.request('GET', '/api/admin/offerings'); assert.equal(offeringsList.status, 200); const guardianDenied = await stray.request('POST', '/api/admin/guardians', { guardianId: `denied-${RUN_ID}`, displayName: 'Denied' }); assert.equal(guardianDenied.status, 403); const registrationsDenied = await stray.request('GET', `/api/admin/offerings/${offeringId}/registrations`); assert.equal(registrationsDenied.status, 403); }); // ── 31: organization/location behavior ─────────────────────────────────────────────────── await test('organization/location creation and access_notes RBAC stripping', async () => { const orgId = `org-p8-${RUN_ID}`, locId = `loc-p8-${RUN_ID}`; await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P8 Org', type: 'library' }); await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'P8 Loc', accessNotes: 'secret internal code' }); const asOwner = await owner.request('GET', '/api/admin/locations'); assert.ok(asOwner.body.find((l) => l.location_id === locId).access_notes); }); // ── 33/34/35: existing ROOT V1 / self-paced / classroom flows unaffected ──────────────── await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 8', async () => { const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P8-RootV1-${RUN_ID}`, login_identifier: `p8-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('an existing classroom (cohort) flow still works unaffected', async () => { const cohortId = `p8-cohort-check-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort Check', tier: 'root' }); assert.equal(create.status, 201); const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P8 Check Session' }); assert.equal(session.status, 201); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error(e); process.exit(1); });