test(phase7): add business/product architecture acceptance suite and Winter Camp rehearsal
- e2e-phase7.js (17/17): organizations/locations, guardians, curriculum packages (reference-only), offering capacity/transitions, registration lifecycle, agreements, registration-to-enrollment conversion, RBAC, age-guidance-independence, and confirmation that existing ROOT_ V1 self-paced/classroom flows are unaffected. - test-phase7-winter-camp.js (18/18): the full Winter Break Camp operational path with NO payment code invoked — organization/location/curriculum-package/cohort/offering setup, guardian + adult registration, agreement acceptance, a simulated admin-side commerce confirmation, conversion to real enrollments and real cohort membership, real account activation via real one-time tokens, a real classroom session, and a REAL Lab task (linux-inspect-hardware) completed by the minor student inside a real disposable Lab container, confirmed to survive a real logout/login. Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32 (including its own embedded Phase 5A checks) on independent fresh stacks. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
+34
@@ -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 [email protected] "P7 Winter Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../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); });
|
||||
Reference in New Issue
Block a user