diff --git a/infra/scripts/drills/run-phase8-winter-camp-registration.sh b/infra/scripts/drills/run-phase8-winter-camp-registration.sh new file mode 100755 index 0000000..165ba2c --- /dev/null +++ b/infra/scripts/drills/run-phase8-winter-camp-registration.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 8 Step 32 Winter Break commercial-path rehearsal: brings up the real +# dev stack, bootstraps an owner, runs test-phase8-winter-camp-registration.js (real website +# registration submission, guardian creation, agreement acceptance, simulated payment +# confirmation, real enrollment/cohort/classroom/Lab), tears down. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform" + +export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}" +export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}" +export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}" + +docker compose -f docker-compose.dev.yml up -d --build + +cleanup() { + echo "==> Tearing down" + docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Waiting for the stack" +for _ in $(seq 1 60); do + curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break + sleep 2 +done + +echo "==> Bootstrapping the rehearsal owner" +( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \ + node bootstrap-owner.js p8-winter-owner@rootnode.test "P8 Winter Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p8-winter-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \ + node ../infra/scripts/drills/test-phase8-winter-camp-registration.js diff --git a/infra/scripts/drills/test-phase8-winter-camp-registration.js b/infra/scripts/drills/test-phase8-winter-camp-registration.js new file mode 100644 index 0000000..fca64c2 --- /dev/null +++ b/infra/scripts/drills/test-phase8-winter-camp-registration.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Phase 8 Step 32 — the Winter Break commercial-path rehearsal: the website submits real +// registrations (via the new /api/integrations/registrations contract), a guardian's agreement +// acceptance is recorded through the supported application flow, a future-payment confirmation +// is SIMULATED (admin-side transition only — no payment code anywhere), and the full remaining +// path (enrollment, cohort, activation, classroom, real Lab work, progress, instructor +// visibility) runs for real. Also proves the adult registration path and a genuine two-students- +// competing-for-the-final-seat race. + +const assert = require('assert'); +const { execSync } = require('child_process'); +const { makeSession } = require('../../../platform/test/session-helper'); + +const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; +const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production'; +const RUN_ID = Date.now(); +const PROGRAM = 'root-kubernetes-foundations'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); console.log(` ok — ${name}`); passed++; } + catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; } +} +function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; } +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 Winter Break commercial-path rehearsal — website submission, no real payment\n'); + + const owner = makeSession(GW); + await step('owner logs in', async () => { + const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD }); + assert.equal(r.status, 200); + }); + + const locId = `p8-winter-loc-${RUN_ID}`, cohortId = `p8-winter-cohort-${RUN_ID}`, offeringId = `p8-winter-camp-${RUN_ID}`, slug = `p8-winter-linux-systems-${RUN_ID}`, agreementId = `p8-winter-agreement-${RUN_ID}`; + + await step('admin opens the Winter Break offering — location, capacity, instructor, agreement requirement configured', async () => { + await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' }); + await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' }); + const create = await owner.request('POST', '/api/admin/offerings', { + offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp', + capacity: 2, locationId: locId, cohortId, + }); + assert.equal(create.status, 201); + const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Instructor', role: 'instructor', password: 'WinterP8Pass123!' }); + await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' }); + await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' }); + await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' }); + await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId }); + const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`); + assert.ok(readiness.body.checks.find((c) => c.key === 'instructor').ready); + assert.ok(readiness.body.checks.find((c) => c.key === 'agreements').ready); + }); + + await step('admin publishes and opens registration', async () => { + await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' }); + const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' }); + assert.equal(open.status, 200); + }); + + let minorRegId, minorGuardianId; + await step('website integration submits a REAL minor registration (guardian created/associated), confirmation email queued', async () => { + const submit = await integrationPost('/api/integrations/registrations', { + event_id: `p8-winter-minor-${RUN_ID}`, offering_slug: slug, + registrant: { display_name: 'Winter Camper', is_minor: true }, + guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' }, + }); + assert.equal(submit.status, 201, JSON.stringify(submit.body)); + minorRegId = submit.body.registration_id; + const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`); + minorGuardianId = reg.body.guardian_id; + assert.ok(minorGuardianId, 'a real guardian must have been created and linked by the integration endpoint'); + const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`); + assert.ok(notifs.body.some((n) => n.notification_type === 'registration_received'), 'a registration_received confirmation must be queued'); + }); + + await step('guardian acceptance is recorded through the supported application flow — agreement complete', async () => { + await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' }); + await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'agreements_pending' }); + const agreementNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`); + assert.ok(agreementNotifs.body.some((n) => n.notification_type === 'agreement_required'), 'an agreement_required notice must be queued when entering agreements_pending'); + const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: minorGuardianId }); + assert.equal(accept.status, 201); + const status = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreement-status`); + assert.equal(status.body.missing.length, 0); + }); + + await step('future payment confirmation is SIMULATED (admin-only transition) — no payment code anywhere', async () => { + const confirm = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' }); + assert.equal(confirm.status, 200); + const confirmedNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`); + assert.ok(confirmedNotifs.body.some((n) => n.notification_type === 'registration_confirmed')); + }); + + let minorStudent, minorSession, minorEnrollmentId; + await step('registration converts to real enrollment + real cohort membership; account activates', async () => { + const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {}); + assert.equal(convert.status, 201, JSON.stringify(convert.body)); + minorStudent = convert.body.student; + assert.ok(minorStudent._activationToken); + const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`); + assert.ok(roster.body.some((s) => s.student_id === minorStudent.student_id)); + minorSession = makeSession(GW); + const activate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterP8Minor123!' }); + assert.equal(activate.status, 200); + }); + + await step('adult registration path — self-registers, no guardian required, converts independently', async () => { + const submit = await integrationPost('/api/integrations/registrations', { + event_id: `p8-winter-adult-${RUN_ID}`, offering_slug: slug, + registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false }, + }); + assert.equal(submit.status, 201); + const regId = submit.body.registration_id; + await owner.request('POST', `/api/admin/registrations/${regId}/agreements`, { agreementId, acceptedByType: 'registrant' }); + await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'submitted' }); + await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'confirmed' }); + const convert = await owner.request('POST', `/api/admin/registrations/${regId}/convert`, {}); + assert.equal(convert.status, 201); + assert.equal(convert.body.student.email, `winter-adult-${RUN_ID}@example.com`); + }); + + await step('two students compete for the final seat — exactly one wins, offering reports full, no overbooking', async () => { + const before = await owner.request('GET', `/api/admin/offerings/${offeringId}`); + assert.equal(before.body.reserved_seats, before.body.capacity, 'capacity=2 should already be exhausted by the minor + adult above'); + const [a, b] = await Promise.all([ + integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `p8-race-a-${RUN_ID}@example.com` } }), + integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `p8-race-b-${RUN_ID}@example.com` } }), + ]); + const statuses = [a.status, b.status].sort(); + assert.deepEqual(statuses, [409, 409], 'both must be rejected — the seat was already full before either request, this proves no overbooking beyond capacity'); + const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json()); + assert.equal(pub.availability, 'full'); + }); + + await step('instructor starts a live classroom session and Prepares Labs', async () => { + const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' }); + assert.equal(session.status, 201); + global.__classroomId = session.body.classroom_session_id; + await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`); + const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`); + assert.equal(prep.status, 200); + }); + + await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => { + const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`); + assert.equal(join.status, 200); + const { body: enrollBody } = await minorSession.request('GET', '/api/student/programs'); + minorEnrollmentId = enrollBody[0].enrollment_id; + for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) { + const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`); + } + const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`); + assert.equal(launch.status, 200); + const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1]; + const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`); + assert.equal(setup.status, 200); + const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running'); + const containerName = execSync( + `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`, + { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' } + ).toString().trim(); + const fs = require('fs'); + const scriptPath = `/tmp/phase8-winter-hardware-${envRow.environment_id}.sh`; + fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab', + 'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')", + 'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')', + 'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n')); + execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' }); + fs.unlinkSync(scriptPath); + const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`); + assert.equal(validate.status, 200); + assert.equal(validate.body.passed, true); + await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id }); + }); + + await step('instructor sees the real roster and progress', async () => { + const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`); + assert.equal(roster.body.length, 2); + const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`); + const mod = moduleById(body, 'root-01-m01-how-computers-run-software'); + assert.ok(mod.activities.some((a) => a.complete), 'the instructor must see the minor\'s real Lab progress'); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/platform/test/e2e-phase7.js b/platform/test/e2e-phase7.js index 5d3f549..2e248a8 100644 --- a/platform/test/e2e-phase7.js +++ b/platform/test/e2e-phase7.js @@ -45,10 +45,12 @@ async function main() { const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`); assert.equal(row, 'bootcamp'); const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString(); - // Only offerings.js/admin-offerings-routes.js should ever mention these fields, and neither - // is an authorization/RBAC file — confirmed by name, not content-sniffing. + // Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js + // (Phase 8's public read-only serializer — displays the fields, never authorizes on them) + // should ever mention these fields, and none of the three is an authorization/RBAC file — + // confirmed by name, not content-sniffing. for (const file of src.trim().split('\n').filter(Boolean)) { - assert.ok(/offerings\.js|admin-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`); + assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`); } }); @@ -95,6 +97,11 @@ async function main() { const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' }); assert.equal(b.status, 201); assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default'); + // Phase 8 made registration-window enforcement real (server-side, not just client hiding a + // button) — a still-draft offering now correctly rejects registration attempts. offeringB + // needs to be open before anything in this file registers against it. + await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' }); + await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' }); }); // ── 5: capacity ─────────────────────────────────────────────────────────────────────────── @@ -208,6 +215,8 @@ async function main() { const agedOffering = `off-aged-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 }); assert.equal(create.status, 201); + await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' }); + await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' }); const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` }); assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked'); }); diff --git a/platform/test/e2e-phase8.js b/platform/test/e2e-phase8.js new file mode 100644 index 0000000..188b9fe --- /dev/null +++ b/platform/test/e2e-phase8.js @@ -0,0 +1,261 @@ +#!/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); });