#!/usr/bin/env node // Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous // narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the // real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real // progress/attempt/achievement history, joins a live classroom, sends a help request, and the // Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two // of the five are then converted to real credentialed accounts and their entire prior history // (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual // migration-safety check §33 asks for, run against a live stack rather than asserted in the // abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js). // // Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first. 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 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'); process.exit(1); } const RUN_ID = Date.now(); const COHORT_ID = `p5a-rehearsal-${RUN_ID}`; 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++; } } // Seeds one realistic progress/attempt/achievement record for a pilot student directly in // Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full // Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js. function seedHistory(studentId) { const sql = ` INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at) VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days'); INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count) VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4); INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id) VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations'); `; execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`, { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }); } async function main() { console.log('Phase 5A student identity / remote classroom dress rehearsal\n'); const instructor = makeSession(GW); await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez']; const students = {}; // name -> student_id const sessions = {}; // name -> pilot login session await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => { await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' }); for (const name of names) { const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID }); assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`); students[name] = body.student_id; } }); await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => { for (const name of names) { sessions[name] = makeSession(GW); const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] }); assert.equal(status, 200); } }); await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => { seedHistory(students['Reh-Ada']); seedHistory(students['Reh-Bo']); }); let classroomId; await step('instructor: create and start a live classroom for the cohort', async () => { const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' }); classroomId = body.classroom_session_id; const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`); assert.equal(status, 200); }); await step('all 5 students join the classroom and heartbeat', async () => { for (const name of names) { const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`); assert.equal(status, 200); await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`); } }); await step('Reh-Cy sends a help request; instructor acknowledges it', async () => { const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' }); assert.equal(status, 201); const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); const req = dash.help_requests.find((h) => h.status === 'open'); assert.ok(req); await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`); }); await step('instructor posts an announcement and sets current activity', async () => { await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' }); await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' }); }); // ── The restart: the whole point of §30's "prove recovery" requirement. ───────────────── await step('Platform API restarts mid-rehearsal', () => { execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }); }); await new Promise((r) => setTimeout(r, 4000)); await step('after restart: the classroom is still live and every session still works, with no re-login', async () => { for (const name of names) { const { status } = await sessions[name].request('GET', `/api/classroom/live`); assert.equal(status, 200); } const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); assert.equal(dash.classroom.status, 'live'); assert.equal(dash.students.length, 5); assert.equal(dash.announcements.length, 1); }); await step('after restart: heartbeats and help requests still work normally', async () => { const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`); assert.equal(status, 200); }); // ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ─────────────── let adaActivation, boActivation; await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => { const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` }); const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` }); assert.equal(a.status, 200); assert.equal(b.status, 200); adaActivation = a.body.activation_token; boActivation = b.body.activation_token; }); await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => { const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] }); assert.equal(adaRetry.status, 409); const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] }); assert.equal(cyStillWorks.status, 200); }); let adaNewSession, boNewSession; await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => { adaNewSession = makeSession(GW); boNewSession = makeSession(GW); const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' }); const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' }); assert.equal(a.status, 200); assert.equal(b.status, 200); }); await step('every bit of prior history survived the conversion — nothing orphaned', async () => { const adaProgress = await adaNewSession.request('GET', '/api/progress'); assert.equal(adaProgress.status, 200); assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login'); assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed'); const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']); assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created'); const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`); assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion'); }); await step('cleanup: end the classroom', async () => { await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });