#!/usr/bin/env node // Phase 6F — ROOT_ V1 Curriculum Integration & Pilot Readiness acceptance test. Covers the V1 // manifest/exclusion decisions, the full V1 prerequisite graph (including the new // root-capstone/root-09 edge), self-paced/instructor-led eligibility, the orientation module, // and the platform architecture's age-independence. Same conventions as e2e-phase6a-e.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-phase6f.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); const { completeRootFoundations01Through04, completeThroughRoot10, completeThroughCapstoneEligible } = 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.message}`); failed++; } } function courseById(courses, id) { return courses.find((c) => c.course_id === id); } 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; } 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 newStudent(owner, tag) { const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6F-${tag}-${RUN_ID}`, login_identifier: `e2e6f-${tag}-${RUN_ID}` }); const s = makeSession(GW); await s.request('POST', '/api/student/activate', { token: create.activation_token, password: `${tag}Password123!` }); const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' }); return { session: s, studentId: create.student_id, enrollmentId: enroll.enrollment_id }; } async function main() { console.log('Phase 6F ROOT_ V1 Curriculum Integration & Pilot Readiness 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: the V1 manifest — inclusion/exclusion, statuses ───────────────────────────────── let adminStructure; await test('the V1 manifest\'s course statuses match the live database', async () => { const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); adminStructure = body; for (const id of ['root-01-computing-linux-foundations', 'root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker', 'root-05-kubernetes-foundations', 'root-06-kubernetes-application-workloads', 'root-07-kubernetes-networking-config', 'root-09-kubernetes-security', 'root-10-kubernetes-troubleshooting-observability', 'root-12-real-ops', 'root-capstone']) { assert.equal(courseById(adminStructure, id).status, 'ready', `${id} should be ready`); } assert.equal(courseById(adminStructure, 'root-08-kubernetes-storage-scheduling').status, 'partial'); assert.equal(courseById(adminStructure, 'root-11-devops-platform-engineering').status, 'partial'); }); await test('ROOT 08 is excluded from V1 (draft) but ROOT 11 stays published as a bonus module', async () => { // buildCourseTree() never surfaces `visibility` on the shaped course object (it's a SQL // WHERE-clause filter only, per course-progress.js) — verified directly against Postgres. assert.equal(psql(`SELECT visibility FROM courses WHERE course_id = 'root-08-kubernetes-storage-scheduling'`), 'draft'); assert.equal(psql(`SELECT visibility FROM courses WHERE course_id = 'root-11-devops-platform-engineering'`), 'published'); }); const fresh = await newStudent(owner, 'Fresh'); await test('a brand-new student never sees ROOT 08 at all (visibility, not just locked)', async () => { const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-08-kubernetes-storage-scheduling'), undefined, 'a draft course must not appear in the student response'); assert.ok(courseById(body.courses, 'root-11-devops-platform-engineering'), 'ROOT 11 must still appear (published)'); }); // ── 3/4: prerequisite graph — no cycles, no missing nodes ──────────────────────────────── await test('the full V1 prerequisite graph has no cycles and no missing nodes (validator)', async () => { const out = execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' }).toString(); assert.ok(/PASS — 0 error\(s\)/.test(out), `expected 0 validation errors: ${out}`); }); await test('ROOT 08 (excluded) is not referenced as a prerequisite by anything ELSE in V1', async () => { for (const c of adminStructure) { if (c.course_id === 'root-08-kubernetes-storage-scheduling') continue; // ROOT 08's own internal module chain referencing itself doesn't count assert.ok(!(c.prerequisites || []).includes('root-08-kubernetes-storage-scheduling'), `${c.course_id} must not depend on excluded ROOT 08`); for (const m of c.modules) assert.ok(!(m.prerequisites || []).some((p) => p.startsWith('root-08-')), `${m.module_id} must not depend on excluded ROOT 08`); } }); // ── 5/6: ROOT 01-04 and intended ROOT 05+ modules ready ────────────────────────────────── await test('every ROOT 01-04 module is ready (re-verified, not inherited from Phase 6E)', async () => { for (const c of ['root-01-computing-linux-foundations', 'root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) { const course = courseById(adminStructure, c); for (const m of course.modules) assert.equal(m.status, 'ready', `${m.module_id} should be ready`); } }); // ── orientation, resume, unlock ─────────────────────────────────────────────────────────── await test('resume points at the new orientation module for a brand-new student', async () => { const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); assert.equal(body.resume.module_id, 'root-01-m00-welcome-to-root'); assert.equal(body.resume.activity_id, 'root-01-m00-a01-lesson-welcome'); }); await test('completing the orientation module unlocks ROOT 01 module 1', async () => { for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) { const r = await fresh.session.request('POST', `/api/student/activities/${id}/complete`); assert.equal(r.status, 200); } const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-01-m00-welcome-to-root').complete, true); assert.equal(moduleById(body.courses, 'root-01-m01-how-computers-run-software').locked, false); }); // ── 11: optional practice never blocks (ROOT 09's advanced CKS labs) ───────────────────── const optStudent = await newStudent(owner, 'OptionalCheck'); await test('an optional activity left undone never blocks its module or course from completing', async () => { await completeThroughCapstoneEligible(optStudent.session); const { body } = await optStudent.session.request('GET', `/api/student/programs/${optStudent.enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-09-kubernetes-security').complete, true, 'root-09 complete despite its optional advanced labs never being touched'); assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true); }); // ── 14: Capstone gating — root-09 AND root-12 both required, direct API cannot bypass ──── await test('root-capstone stays locked with only root-12 done (root-09 also required)', async () => { const s = await newStudent(owner, 'CapstoneGateA'); await completeThroughRoot10(s.session); // root-12 only, no root-09: async function act(id) { const r = await s.session.request('POST', `/api/student/activities/${id}/complete`); assert.equal(r.status, 200, id); } async function scen(sid) { const r = await s.session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); assert.equal(r.status, 200, sid); } await act('root-12-m01-a00-lesson-incident-response'); for (const sid of ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-enforce-limits']) await scen(sid); await act('root-12-m01-a99-reflection-post-incident'); const { body } = await s.session.request('GET', `/api/student/programs/${s.enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true); assert.equal(courseById(body.courses, 'root-capstone').locked, true, 'root-capstone must stay locked until root-09 is ALSO complete'); const bypass = await s.session.request('POST', '/api/student/activities/root-capstone-m02-a01-lesson-what-it-measures/complete'); assert.equal(bypass.status, 403); assert.equal(bypass.body.error, 'locked'); }); await test('root-capstone unlocks once both root-09 and root-12 are complete', async () => { const { body } = await optStudent.session.request('GET', `/api/student/programs/${optStudent.enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-capstone').locked, false); const r = await optStudent.session.request('POST', '/api/student/activities/root-capstone-m02-a01-lesson-what-it-measures/complete'); assert.equal(r.status, 200); }); // ── 15: V1 version behavior — migrations bumped version, content unaffected ────────────── await test('bumping a Phase 6F module\'s version again does not drop its content', async () => { psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-06-m01-deployments-and-workloads'`); const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const mod = moduleById(body, 'root-06-m01-deployments-and-workloads'); assert.ok(mod.activities.some((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro')); }); // ── 16: instructor notes ───────────────────────────────────────────────────────────────── await test('instructor notes on the new orientation and intro-lesson content are instructor-only', async () => { const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const lesson = moduleById(adminView, 'root-01-m00-welcome-to-root').activities.find((a) => a.activity_id === 'root-01-m00-a02-lesson-disposable-lab'); assert.ok(lesson.instructor_notes === null || lesson.instructor_notes === undefined || lesson.instructor_notes === '', 'this particular lesson has no Instructor Notes section, which is fine — it must never be fabricated'); const bank = moduleById(adminView, 'root-06-m01-deployments-and-workloads').activities.find((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro'); assert.ok(bank.instructor_notes && bank.instructor_notes.includes('Instructor Notes')); // root-06-m01 is LOCKED for fresh (not yet unlocked), not DRAFT — a locked module still // appears in the tree so a student can see what's ahead, it just never carries // instructor_notes regardless of lock state. const { body: studentView } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); const studentBank = moduleById(studentView.courses, 'root-06-m01-deployments-and-workloads'); const studentBankActivity = studentBank.activities.find((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro'); assert.ok(!studentBankActivity.instructor_notes, 'a student must never receive instructor_notes, locked or not'); }); // ── 17/18: isolation, enrollment ────────────────────────────────────────────────────────── await test('two students on ROOT_ V1 have fully independent progress', async () => { const a = await newStudent(owner, 'IsoA'); const b = await newStudent(owner, 'IsoB'); await a.session.request('POST', '/api/student/activities/root-01-m00-a01-lesson-welcome/complete'); const [{ body: aBody }, { body: bBody }] = await Promise.all([ a.session.request('GET', `/api/student/programs/${a.enrollmentId}/courses`), b.session.request('GET', `/api/student/programs/${b.enrollmentId}/courses`), ]); assert.notEqual(moduleById(aBody.courses, 'root-01-m00-welcome-to-root').percent_complete, moduleById(bBody.courses, 'root-01-m00-welcome-to-root').percent_complete); }); await test('a fresh V1 enrollment sees the complete intended course list with no gaps', async () => { const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); const ids = body.courses.map((c) => c.course_id); for (const id of ['root-01-computing-linux-foundations', 'root-05-kubernetes-foundations', 'root-09-kubernetes-security', 'root-11-devops-platform-engineering', 'root-12-real-ops', 'root-capstone']) { assert.ok(ids.includes(id), `${id} should be visible to a self-paced V1 student`); } assert.ok(!ids.includes('root-08-kubernetes-storage-scheduling')); }); // ── 19: Lab lifecycle persistence (logout/login) ───────────────────────────────────────── await test('V1 progress survives logout and a fresh login', async () => { await fresh.session.request('POST', '/api/logout'); const returning = makeSession(GW); await returning.request('POST', '/api/student/login', { identifier: `e2e6f-Fresh-${RUN_ID}`, password: 'FreshPassword123!' }); const { body } = await returning.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-01-m00-welcome-to-root').complete, true); }); // ── 22: age-independent architecture — enrollment never requires or stores an age field ── await test('enrollment succeeds with no age/grade field anywhere in the request or student record', async () => { const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6F-NoAge-${RUN_ID}`, login_identifier: `e2e6f-noage-${RUN_ID}` }); assert.ok(create.student_id); const cols = psql(`SELECT column_name FROM information_schema.columns WHERE table_name = 'students' AND column_name ILIKE '%age%'`); assert.equal(cols, '', 'the students table must not carry an age/grade gate column'); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error(e); process.exit(1); });