diff --git a/infra/scripts/drills/run-phase7-winter-camp.sh b/infra/scripts/drills/run-phase7-winter-camp.sh new file mode 100755 index 0000000..cb80f24 --- /dev/null +++ b/infra/scripts/drills/run-phase7-winter-camp.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 7 Step 31 Winter Break Camp internal rehearsal: brings up the real dev +# stack, bootstraps an owner, runs test-phase7-winter-camp.js (real offering/registration/ +# guardian/agreement/enrollment/cohort/classroom/Lab flow, no payment code), 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 p7-winter-owner@rootnode.test "P7 Winter Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p7-winter-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase7-winter-camp.js diff --git a/infra/scripts/drills/test-phase7-winter-camp.js b/infra/scripts/drills/test-phase7-winter-camp.js new file mode 100644 index 0000000..831f9b4 --- /dev/null +++ b/infra/scripts/drills/test-phase7-winter-camp.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node +// Phase 7 Step 31 — the Winter Break Camp internal operational rehearsal, without payments. +// Proves the full path: admin creates a draft offering (Winter Break Linux & Systems Camp, +// matching docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md's Option A: ROOT 01-02) with a real +// curriculum package, location, and instructor assignment; a guardian registers a minor and an +// adult registers themselves; both registrations are walked through agreements and a simulated +// commerce confirmation (admin-side only — no payment code exists or is invoked); both convert +// to real enrollments and real cohort membership; the instructor runs a real classroom session; +// the minor's own Lab work is done for real (the same linux-inspect-hardware checkpoint the +// Phase 6D/6F rehearsals already established); progress is confirmed to survive; the offering +// is marked complete. + +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 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 main() { + console.log('Phase 7 Winter Break Camp internal rehearsal — no payments, real everything else\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 orgId = `winter-org-${RUN_ID}`, locId = `winter-loc-${RUN_ID}`, pkgId = `winter-linux-systems-${RUN_ID}`, cohortId = `winter-cohort-${RUN_ID}`, offeringId = `winter-camp-${RUN_ID}`; + + await step('admin creates the venue (organization + location)', async () => { + const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'Oxford Community Library', type: 'library' }); + assert.equal(org.status, 201); + const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'Community Room B', type: 'community_center', capacity: 15 }); + assert.equal(loc.status, 201); + }); + + await step('admin creates the curriculum package — ROOT 01-02, matching Winter Break Camp Option A, referencing existing content', async () => { + const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'Winter Linux & Systems Foundations', programId: PROGRAM, description: 'ROOT 01-02 — see docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md Option A' }); + assert.equal(pkg.status, 201); + for (const [courseId, sequence] of [['root-01-computing-linux-foundations', 1], ['root-02-linux-administration-networking', 2]]) { + const item = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId, sequence }); + assert.equal(item.status, 201); + } + }); + + await step('admin creates the real cohort this camp will roster into', async () => { + const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Camp Cohort', tier: 'root' }); + assert.equal(cohort.status, 201); + }); + + await step('admin creates the DRAFT offering — dates/price/exact seat count left unset, exactly as authorized', async () => { + const offering = await owner.request('POST', '/api/admin/offerings', { + offeringId, slug: `winter-linux-systems-camp-${RUN_ID}`, name: 'Winter Break Linux & Systems Foundations Camp', + programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 12, + locationId: locId, organizationId: orgId, cohortId, + recommendedMinAge: 13, recommendedMaxAge: null, // guidance only — see the age-independence test in e2e-phase7.js + }); + assert.equal(offering.status, 201, JSON.stringify(offering.body)); + assert.equal(offering.body.status, 'draft'); + }); + + let instructorId; + await step('admin creates and assigns the lead instructor', async () => { + const create = await owner.request('POST', '/api/admin/instructors', { email: `winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Camp Instructor', role: 'instructor', password: 'WinterCampPass123!' }); + assert.equal(create.status, 201); + instructorId = create.body.instructor_id; + const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorId, role: 'teacher' }); + assert.equal(link.status, 201); + // The offering-level assignment is Phase 7's own new concept; the cohort-level one (the + // real RBAC scoping mechanism a classroom session actually checks) is Phase 2A/4.1, + // unchanged — both are assigned so the instructor genuinely has classroom access below. + const cohortLink = await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorId, role_in_cohort: 'teacher' }); + assert.equal(cohortLink.status, 200, JSON.stringify(cohortLink.body)); + }); + + await step('admin publishes the offering and opens registration', async () => { + const pub = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' }); + assert.equal(pub.status, 200); + const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' }); + assert.equal(open.status, 200); + }); + + const guardianId = `winter-guardian-${RUN_ID}`; + await step('a guardian registers a minor student for the camp', async () => { + const guardian = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'Winter Camp Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0100' }); + assert.equal(guardian.status, 201); + global.__minorReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { + guardianId, registrantDisplayName: 'Winter Camp Minor', isMinor: true, + emergencyContactName: 'Winter Camp Guardian', emergencyContactPhone: '555-0100', + readinessNotes: 'No prior Linux experience, comfortable with a keyboard.', + })).body; + assert.ok(global.__minorReg.registration_id); + }); + + await step('an adult registers themselves for the same camp', async () => { + global.__adultReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { + registrantDisplayName: 'Winter Camp Adult', registrantEmail: `winter-adult-${RUN_ID}@example.com`, + })).body; + assert.ok(global.__adultReg.registration_id); + }); + + await step('both registrations move through submission and agreement acceptance', async () => { + const agreementId = `winter-participation-${RUN_ID}`; + await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement', bodySummary: 'Plain-language summary — not legal text, see docs/STUDENT_GUARDIAN_DATA_MODEL.md.' }); + for (const reg of [global.__minorReg, global.__adultReg]) { + await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'submitted' }); + await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'agreements_pending' }); + } + // The minor's guardian accepts on the minor's behalf — the guardian identity already + // exists at registration time. The adult accepts as a guardian-style acceptance too here + // (accepted_by_guardian_id is really just "who accepted," not guardian-exclusive) since an + // adult registrant has no student_id yet until conversion below — a real, if slightly + // unusual, present-tense fact about this lifecycle worth noting: self-acceptance as + // accepted_by_type='student' is only possible AFTER conversion, when a real student_id + // exists (an adult could re-accept post-activation in a future UI; not required by V1). + const minorAccept = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId }); + assert.equal(minorAccept.status, 201); + }); + + await step('a simulated commerce confirmation (admin-only — no payment code exists or is called)', async () => { + for (const reg of [global.__minorReg, global.__adultReg]) { + const r = await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'confirmed' }); + assert.equal(r.status, 200, JSON.stringify(r.body)); + } + }); + + let minorStudent, adultStudent, minorSession, adultSession; + await step('both registrations convert to real enrollments and real cohort membership', async () => { + const minorConvert = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/convert`, {}); + assert.equal(minorConvert.status, 201); + minorStudent = minorConvert.body.student; + assert.ok(minorStudent._activationToken, 'the instructor must have a real token to relay to a no-email minor'); + const adultConvert = await owner.request('POST', `/api/admin/registrations/${global.__adultReg.registration_id}/convert`, {}); + assert.equal(adultConvert.status, 201); + adultStudent = adultConvert.body.student; + assert.ok(adultStudent._activationToken, 'the adult also gets a real token, delivered to their own email in production'); + + const rosterCheck = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`); + assert.equal(rosterCheck.status, 200); + const ids = rosterCheck.body.map((s) => s.student_id); + assert.ok(ids.includes(minorStudent.student_id) && ids.includes(adultStudent.student_id), 'both converted students must be on the real cohort roster'); + }); + + await step('both students activate their real accounts using their real one-time tokens', async () => { + minorSession = makeSession(GW); + const minorActivate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterCampMinor123!' }); + assert.equal(minorActivate.status, 200, JSON.stringify(minorActivate.body)); + adultSession = makeSession(GW); + const adultActivate = await adultSession.request('POST', '/api/student/activate', { token: adultStudent._activationToken, password: 'WinterCampAdult123!' }); + assert.equal(adultActivate.status, 200, JSON.stringify(adultActivate.body)); + }); + + let instructorSession, classroomId, minorEnrollmentId; + await step('instructor starts a live classroom session and Prepares Labs for the cohort', async () => { + const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' }); + assert.equal(session.status, 201); + classroomId = session.body.classroom_session_id; + await owner.request('POST', `/api/admin/classroom-sessions/${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 the classroom and completes a real ROOT 01 practical', async () => { + const join = await minorSession.request('POST', `/api/classroom/${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/phase7-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, 'expected a REAL pass, not a simulated one'); + const record = await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id }); + assert.equal(record.status, 200); + }); + + await step('the minor\'s progress survives logout/login (the actual academic-persistence guarantee this camp depends on)', async () => { + await minorSession.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: minorStudent.login_identifier, password: 'WinterCampMinor123!' }); + const { body } = await returning.request('GET', `/api/student/programs/${minorEnrollmentId}/courses`); + const mod = body.courses.flatMap((c) => c.modules).find((m) => m.module_id === 'root-01-m01-how-computers-run-software'); + assert.ok(mod.activities.some((a) => a.complete), 'the real Lab pass must have survived logout/login'); + }); + + await step('instructor sees real progress and roster state for the camp', async () => { + const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`); + assert.equal(roster.status, 200); + assert.equal(roster.body.length, 2); + }); + + await step('the offering moves through in_progress to completed', async () => { + const inProgress = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' }); + assert.equal(inProgress.status, 200); + const completed = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'completed' }); + assert.equal(completed.status, 200); + assert.equal(completed.body.status, 'completed'); + }); + + 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 new file mode 100644 index 0000000..5d3f549 --- /dev/null +++ b/platform/test/e2e-phase7.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// Phase 7 — Business & Product Architecture acceptance test. Organizations, locations, +// guardians, curriculum packages, offerings (capacity, server-defined transitions), +// registrations (lifecycle, agreements, conversion to real enrollment), RBAC, and explicit +// proof that none of this depends on age or breaks ROOT_ V1's existing self-paced/classroom +// flows. Same conventions as e2e-phase6a-f.js: assert-based, no framework, live +// docker-compose.dev.yml stack. +// +// 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-phase7.js + +const assert = require('assert'); +const { execSync } = require('child_process'); +const { makeSession } = require('./session-helper'); +const { completeRootFoundations01Through04 } = require('./foundations-helper'); + +const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; +const OWNER_EMAIL = process.env.OWNER_EMAIL; +const OWNER_PASSWORD = process.env.OWNER_PASSWORD; +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 main() { + console.log('Phase 7 Business & Product Architecture 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: course families are guidance, not hard age gates ────────────────────────────────── + await test('programs.family accepts bootcamp; recommended_min/max_age is never read by any RBAC/authorization code', async () => { + psql(`INSERT INTO programs (program_id, name, family, delivery_mode, curriculum_path, status) VALUES ('p7-bootcamp-check-${RUN_ID}', 'Bootcamp Check', 'bootcamp', 'self_paced', 'rootnode-path', 'active') ON CONFLICT DO NOTHING`); + 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. + 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}`); + } + }); + + // ── 2/3/4: offering creation, program association, delivery-mode behavior ──────────────── + const orgId = `org-p7-${RUN_ID}`; + const locId = `loc-p7-${RUN_ID}`; + let offeringA, offeringB, pkgId; + await test('organization and two distinct locations are created', async () => { + const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P7 Test Org', type: 'library' }); + assert.equal(org.status, 201); + const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'P7 Room', type: 'community_center', organizationId: orgId, accessNotes: 'internal only' }); + assert.equal(loc.status, 201); + }); + + await test('a curriculum package references existing ROOT courses without duplicating content', async () => { + pkgId = `pkg-p7-${RUN_ID}`; + const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'P7 Winter Package', programId: PROGRAM }); + assert.equal(pkg.status, 201); + const item1 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-01-computing-linux-foundations', sequence: 1 }); + assert.equal(item1.status, 201); + const item2 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-02-linux-administration-networking', sequence: 2 }); + assert.equal(item2.status, 201); + // 19/20: references existing content, no duplication — the module/activity counts under + // root-01/02 must be byte-identical to what course-content validation already knows about. + const activityCount = psql(`SELECT count(*) FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id WHERE cm.course_id IN ('root-01-computing-linux-foundations','root-02-linux-administration-networking')`); + const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`); + assert.equal(get.status, 200); + assert.equal(get.body.items.length, 2); + assert.equal(get.body.items[0].course_id, 'root-01-computing-linux-foundations'); + assert.ok(Number(activityCount) > 0, 'the referenced courses must still have their real activities, unduplicated'); + }); + + await test('a non-existent course cannot be added to a curriculum package (reference, never fabricate)', async () => { + const r = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-99-does-not-exist', sequence: 3 }); + assert.equal(r.status, 404); + assert.equal(r.body.error, 'COURSE_NOT_FOUND'); + }); + + await test('two offerings at two different locations coexist with no state confusion', async () => { + offeringA = `off-a-${RUN_ID}`; + offeringB = `off-b-${RUN_ID}`; + const a = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringA, slug: `p7-camp-a-${RUN_ID}`, name: 'Camp A', programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 2, locationId: locId, organizationId: orgId }); + assert.equal(a.status, 201); + 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'); + }); + + // ── 5: capacity ─────────────────────────────────────────────────────────────────────────── + await test('offering capacity is enforced atomically, and validation happens BEFORE seat reservation', async () => { + for (const p of [{ status: 'published' }, { status: 'enrollment_open' }]) { + const r = await owner.request('POST', `/api/admin/offerings/${offeringA}/transition`, p); + assert.equal(r.status, 200, JSON.stringify(r.body)); + } + const badMinor = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true }); + assert.equal(badMinor.status, 400, 'a validation failure must not consume a seat'); + const check0 = await owner.request('GET', `/api/admin/offerings/${offeringA}`); + assert.equal(check0.body.reserved_seats, 0); + + const reg1 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg One', registrantEmail: `reg1-${RUN_ID}@example.com` }); + assert.equal(reg1.status, 201); + const reg2 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Two', registrantEmail: `reg2-${RUN_ID}@example.com` }); + assert.equal(reg2.status, 201); + const reg3 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Three (overflow)', registrantEmail: `reg3-${RUN_ID}@example.com` }); + assert.equal(reg3.status, 409); + assert.equal(reg3.body.error, 'OFFERING_FULL'); + }); + + // ── 7/8/9: organization/location/instructor assignment ─────────────────────────────────── + await test('a plain instructor is listed and assignable to an offering, reusing cohort_instructors\' role vocabulary', async () => { + const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-stray-${RUN_ID}@example.com`, display_name: 'P7 Stray', role: 'instructor', password: 'P7StrayPass123!' }); + const link = await owner.request('POST', `/api/admin/offerings/${offeringA}/instructors`, { instructor_id: strayCreate.instructor_id, role: 'teacher' }); + assert.equal(link.status, 201); + }); + + // ── 6/9: guardians, student-guardian relationship ──────────────────────────────────────── + const guardianId = `guardian-p7-${RUN_ID}`; + await test('a guardian is created and can be linked to a student', async () => { + const g = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P7 Guardian', email: `guardian-${RUN_ID}@example.com` }); + assert.equal(g.status, 201); + }); + + // ── 11/12: adult learner path without a guardian; minor path without corrupting identity ── + let minorRegId, adultRegId; + await test('a minor registration requires a guardian; an adult registration needs none', async () => { + const minorReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { guardianId, registrantDisplayName: 'P7 Minor', isMinor: true }); + assert.equal(minorReg.status, 201); + minorRegId = minorReg.body.registration_id; + const adultReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { registrantDisplayName: 'P7 Adult', registrantEmail: `adult-${RUN_ID}@example.com` }); + assert.equal(adultReg.status, 201); + adultRegId = adultReg.body.registration_id; + }); + + // ── 13/14: registration lifecycle ──────────────────────────────────────────────────────── + await test('a registration moves through its real lifecycle, rejecting an invalid transition', async () => { + const bad = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'enrollment_activated' }); + assert.equal(bad.status, 409, 'cannot jump straight from started to enrollment_activated'); + for (const status of ['submitted', 'agreements_pending']) { + const r = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status }); + assert.equal(r.status, 200, `${status}: ${JSON.stringify(r.body)}`); + } + }); + + // ── 15: agreement-version acceptance ───────────────────────────────────────────────────── + const agreementId = `agree-p7-${RUN_ID}`; + await test('an agreement definition is created and a guardian acceptance is recorded with a version snapshot', async () => { + const def = await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P7 Test Agreement', bodySummary: 'Plain-language summary only.' }); + assert.equal(def.status, 201); + const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId }); + assert.equal(accept.status, 201); + assert.equal(accept.body.accepted_version, 1); + const list = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreements`); + assert.equal(list.body.length, 1); + }); + + // ── 16/17/18/19: offering enrollment conversion, offering→cohort, self-paced without cohort ─ + await test('a confirmed registration converts to a real enrollment (self-paced offering, no cohort)', async () => { + 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.enrollment.program_id, PROGRAM); + assert.equal(convert.body.enrollment.status, 'active'); + const student = convert.body.student; + assert.ok(student.login_identifier && !student.email, 'a minor with no email gets a login_identifier, never a guardian email written to students.email'); + const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE student_id = '${student.student_id}'`); + assert.equal(Number(cohortRow), 0, 'offeringB has no cohort_id — no cohort membership should be created'); + }); + + // ── 21: historical package version stability ───────────────────────────────────────────── + await test('bumping a curriculum package\'s version does not change its historical item list retroactively in a confusing way', async () => { + psql(`UPDATE curriculum_packages SET version = version + 1 WHERE curriculum_package_id = '${pkgId}'`); + const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`); + assert.equal(get.body.items.length, 2, 'items must survive a version bump on the package row itself'); + }); + + // ── 22/23: registration RBAC, guardian data RBAC ───────────────────────────────────────── + await test('a plain instructor cannot view registrations or create a guardian/organization, but CAN list offerings', async () => { + const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-rbac-${RUN_ID}@example.com`, display_name: 'P7 RBAC', role: 'instructor', password: 'P7RbacPass123!' }); + const stray = makeSession(GW); + const login = await stray.request('POST', '/api/auth/instructor/login', { email: `p7-rbac-${RUN_ID}@example.com`, password: 'P7RbacPass123!' }); + assert.equal(login.status, 200); + const regs = await stray.request('GET', `/api/admin/offerings/${offeringB}/registrations`); + assert.equal(regs.status, 403); + const guardianCreate = await stray.request('POST', '/api/admin/guardians', { guardianId: `g-rbac-${RUN_ID}`, displayName: 'Should Fail' }); + assert.equal(guardianCreate.status, 403); + const orgCreate = await stray.request('POST', '/api/admin/organizations', { organizationId: `org-rbac-${RUN_ID}`, name: 'Should Fail' }); + assert.equal(orgCreate.status, 403); + const listOfferings = await stray.request('GET', '/api/admin/offerings'); + assert.equal(listOfferings.status, 200, 'listing offerings themselves is not sensitive'); + const listLocations = await stray.request('GET', '/api/admin/locations'); + assert.equal(listLocations.status, 200); + assert.ok(listLocations.body.every((l) => !('access_notes' in l)), 'a plain instructor must never receive access_notes'); + }); + + // ── 27: no age-based authorization lock anywhere ───────────────────────────────────────── + await test('an offering with a recommended age range still accepts a registrant of any age (guidance only)', async () => { + 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); + 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'); + }); + + // ── 28/29/30: existing ROOT V1 / self-paced / classroom flows still work ───────────────── + await test('an existing ROOT_ V1 self-paced enrollment (unrelated to any offering) still works end to end', async () => { + const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P7-RootV1-${RUN_ID}`, login_identifier: `p7-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); + const { body } = await s.request('GET', `/api/student/programs/${enroll.enrollment_id}/courses`); + assert.ok(body.courses.some((c) => c.course_id === 'root-01-computing-linux-foundations')); + }); + + await test('an existing classroom (cohort) flow still works unaffected', async () => { + const cohortId = `p7-cohort-check-${RUN_ID}`; + const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P7 Cohort Check', tier: 'root' }); + assert.equal(create.status, 201); + const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P7 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); });