#!/usr/bin/env node // Phase 5B §18 — the self-paced dress rehearsal. One continuous realistic story, not synthetic // unit assertions: a student is created, activates, has no program, a simulated website // enrollment event enrolls them, they work real curriculum in a real Lab, the Lab is destroyed, // they come back later, Continue Learning resumes correctly, a fresh Lab provisions, and they // complete enough of the program to prove the completion transition. A second, uninvolved // student proves they cannot reach the first student's program or Lab. No framework, matching // project convention (see test-fake-cohort-rehearsal.js / test-phase5a-rehearsal.js). // // Run via run-phase5b-selfpaced-rehearsal.sh, which brings up platform/docker-compose.dev.yml // first. const assert = require('assert'); 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; const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production'; if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); } const RUN_ID = Date.now(); const PROGRAM = 'root-kubernetes-foundations'; // Just enough of the real Foundation stage to prove stage-completion + capstone unlock without // re-driving the entire curriculum a second time (e2e-phase5b.js already does that // exhaustively) — this rehearsal seeds the REST of the curriculum's completion directly in // Postgres (a real, documented technique — see test-phase5a-rehearsal.js's seedHistory) so the // story can reach "program completed" without spending another 40+ real scenario round trips. const FOUNDATION_FIRST = 'pod-basics-mcq'; 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 fireIntegrationEvent(payload) { return fetch(`${GW}/api/integrations/enrollments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(payload), }).then(async (r) => ({ status: r.status, body: await r.json() })); } async function main() { console.log('Phase 5B self-paced dress rehearsal — slow, real k3s boots\n'); const owner = makeSession(GW); await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Rehearsal Self-Paced Student', login_identifier: `p5b-selfpaced-${RUN_ID}`, }); const session = makeSession(GW); let enrollmentId, envId; await step('student activates credentials and logs in', async () => { const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' }); assert.equal(status, 200); }); await step('student initially has no program', async () => { const { body } = await session.request('GET', '/api/student/programs'); assert.equal(body.length, 0); }); await step('a simulated website enrollment event enrolls the student (server-to-server, not a browser session)', async () => { const { status, body } = await fireIntegrationEvent({ event_id: `p5b-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { login_identifier: `p5b-selfpaced-${RUN_ID}`, display_name: 'Rehearsal Self-Paced Student' }, }); assert.equal(status, 201); assert.equal(body.status, 'active'); enrollmentId = body.enrollment_id; }); await step('the self-paced root program now appears on the student\'s dashboard, ready to go', async () => { const { body } = await session.request('GET', '/api/student/programs'); assert.equal(body.length, 1); assert.equal(body[0].can_launch, true); assert.equal(body[0].next_activity.scenario_id, FOUNDATION_FIRST); }); await step('student launches a REAL Lab and completes the first real scenario', async () => { const { status, body } = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`); assert.equal(status, 200); assert.equal(body.status, 'running'); const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running'); envId = envRow.environment_id; const complete = await session.request('POST', '/api/progress/record', { scenario_id: FOUNDATION_FIRST, status: 'completed', environment_id: envId }); assert.equal(complete.status, 200); }); await step('the Lab is destroyed (student closed the tab, session ended — the disposable-by-design lifecycle)', async () => { await owner.request('DELETE', `/api/admin/environments/${envId}`); }); await step('student logs out', async () => { await session.request('POST', '/api/logout'); }); const returningSession = makeSession(GW); await step('time passes — student logs back in on a new session', async () => { const { status } = await returningSession.request('POST', '/api/student/login', { identifier: `p5b-selfpaced-${RUN_ID}`, password: 'RehearsalPass123!' }); assert.equal(status, 200); }); await step('Continue Learning points to the correct next activity, unchanged by the Lab being gone', async () => { const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`); assert.notEqual(body.next_activity.scenario_id, FOUNDATION_FIRST); // moved past it assert.ok(body.progress.percent_complete > 0); }); await step('a new Lab provisions and prior progress is intact under it', async () => { const { status, body } = await returningSession.request('POST', `/api/student/programs/${enrollmentId}/launch`); assert.equal(status, 200); assert.equal(body.status, 'running'); const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running'); envId = envRow.environment_id; }); await step('seed the rest of curriculum completion directly (realistic bulk backfill, not re-driving 40+ real requests — see file header)', async () => { const client = `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c`; const { execSync } = require('child_process'); const rest = ['kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics', 'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq', 'deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'services-mcq', 'expose-service-basics', 'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline', 'extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq']; const values = rest.map((sid) => `('${created.student_id}','${sid}','completed',1,now(),now())`).join(','); execSync(`${client} "INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at) VALUES ${values} ON CONFLICT (student_id, scenario_id) DO UPDATE SET status='completed'"`, { cwd: `${__dirname}/../../../platform` }); }); await step('student completes the RootNode Capstone for real (the actual server-authoritative draw + validation)', async () => { const start = await returningSession.request('POST', '/api/assessments/rootnode-capstone/start'); assert.equal(start.status, 201); for (const s of start.body.scenarios) { const r = await returningSession.request('POST', '/api/progress/record', { scenario_id: s.scenario_id, status: 'completed', environment_id: envId }); assert.equal(r.status, 200); } }); await step('the program moves to Completed — server-derived, no client "mark complete" action', async () => { const { body } = await returningSession.request('GET', `/api/student/programs/${enrollmentId}`); assert.equal(body.status, 'completed'); assert.ok(body.completed_at); }); await step('the admin portal shows a durable completion record for this student', async () => { const { body } = await owner.request('GET', `/api/admin/students/${created.student_id}/enrollments`); const view = body.find((e) => e.enrollment_id === enrollmentId); assert.equal(view.status, 'completed'); }); // ── A second, uninvolved student — proves entitlement is real, not just "logged in" ────── const { body: otherCreate } = await owner.request('POST', '/api/admin/students', { display_name: 'Rehearsal Outsider', login_identifier: `p5b-outsider-${RUN_ID}` }); const outsider = makeSession(GW); await outsider.request('POST', '/api/student/activate', { token: otherCreate.activation_token, password: 'OutsiderPass123!' }); await step('an uninvolved student cannot reach the first student\'s program or Lab path', async () => { const read = await outsider.request('GET', `/api/student/programs/${enrollmentId}`); assert.equal(read.status, 404); const launch = await outsider.request('POST', `/api/student/programs/${enrollmentId}/launch`); assert.equal(launch.status, 404); const list = await outsider.request('GET', '/api/student/programs'); assert.equal(list.body.length, 0); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });