Files
ced4568andClaude Sonnet 5 2864fe03dc test(phase6f): add V1 pilot acceptance suite; fix regression from real curriculum changes
- New platform/test/e2e-phase6f.js (17 tests): V1 manifest/exclusion correctness, the full
  V1 prerequisite graph, ROOT 01-04 re-verification, the orientation module's unlock
  behavior, optional-activity handling, the strengthened Capstone gate in both directions,
  instructor-notes RBAC on locked (not just draft) content, isolation, and an explicit
  age-independence check.
- foundations-helper.js: completeThroughRoot06/completeThroughRoot10 updated for the new
  practice-bank intro-lesson activities; new completeThroughCapstoneEligible() helper
  (ROOT 07-m03/m04 + full ROOT 09 + ROOT 12) shared by e2e-phase6f.js and both new drills.
- e2e-phase6a/6b/6d/6e.js: resume-pointer assertions updated for the new orientation
  module; e2e-phase6a.js's and e2e-phase6b.js's own inline ROOT 06/07/09/10/12 completion
  sequences updated for the new intro-lesson activities and the new root-09 Capstone
  dependency; e2e-phase6b.js's stale ROOT 09 status assertion and its draft-activity test
  (previously targeting the now fully-excluded ROOT 08) corrected.
- test-phase6d/6e-foundations-rehearsal.js: same orientation-module fix applied to the
  real-Lab dress rehearsals.

All of 6A (23/23) / 6B (26/26) / 6C (17/17) / 6D (21/21) / 6E (23/23) / 6F (17/17) verified
green on independent fresh stacks. See docs/PHASE6F_ROOT_V1_PILOT_READINESS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 18:33:21 -05:00

216 lines
16 KiB
JavaScript

#!/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); });