- 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]>
267 lines
18 KiB
JavaScript
267 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 6D — ROOT Foundations acceptance test. Covers what this phase actually built: ROOT 01
|
|
// (Computing & Linux Foundations, fully authored) and the real ROOT 01->02->03->04->05
|
|
// prerequisite chain. ROOT 02-04 remain PLANNED (architecture designed in
|
|
// docs/ROOT_FOUNDATIONS_ARCHITECTURE.md, not yet authored — see
|
|
// docs/PHASE6D_ROOT_FOUNDATIONS.md for why), so this suite does not assert content that
|
|
// doesn't exist. Same conventions as e2e-phase6a/6b/6c.js: assert-based, no framework, live
|
|
// docker-compose.dev.yml stack, no real Lab container launched here (see
|
|
// infra/scripts/drills/test-phase6d-foundations-rehearsal.js for the real-Lab counterpart).
|
|
//
|
|
// 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-phase6d.js
|
|
|
|
const assert = require('assert');
|
|
const { execSync } = require('child_process');
|
|
const { makeSession } = require('./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 (bootstrap one first with platform/api/bootstrap-owner.js)');
|
|
process.exit(1);
|
|
}
|
|
const RUN_ID = Date.now();
|
|
const PROGRAM = 'root-kubernetes-foundations';
|
|
// Phase 6F prepended a required orientation module (root-01-m00-welcome-to-root) — see
|
|
// docs/PHASE6F_ROOT_V1_PILOT_READINESS.md.
|
|
const ROOT01_MODULES = ['root-01-m00-welcome-to-root', 'root-01-m01-how-computers-run-software', 'root-01-m02-linux-and-the-shell', 'root-01-m03-navigating-the-filesystem', 'root-01-m04-files-and-text', 'root-01-m05-processes', 'root-01-m06-users-and-permissions-foundations'];
|
|
const ROOT01_LAB_SCENARIOS = ['linux-inspect-hardware', 'linux-shell-basics', 'linux-filesystem-navigation', 'linux-files-and-text', 'linux-process-management', 'linux-permissions-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++; }
|
|
}
|
|
async function completeScenario(session, scenarioId) {
|
|
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' });
|
|
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
|
}
|
|
async function completeActivity(session, id) {
|
|
const { status, body } = await session.request('POST', `/api/student/activities/${id}/complete`);
|
|
assert.equal(status, 200, `completing ${id} should 200: ${JSON.stringify(body)}`);
|
|
}
|
|
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 findActivity(courses, id) { for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; } 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-6D-${tag}-${RUN_ID}`, login_identifier: `e2e6d-${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 6D ROOT Foundations acceptance test\n');
|
|
|
|
const owner = makeSession(GW);
|
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
let structure;
|
|
|
|
// ── 1: ROOT 01 hierarchy ─────────────────────────────────────────────────────────────────
|
|
await test('ROOT 01 has its 6 modules, in sequence, all READY', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
assert.equal(status, 200);
|
|
structure = body;
|
|
const course = courseById(structure, 'root-01-computing-linux-foundations');
|
|
assert.equal(course.status, 'ready');
|
|
assert.deepEqual(course.modules.map((m) => m.module_id), ROOT01_MODULES);
|
|
for (const m of course.modules) assert.equal(m.status, 'ready', `${m.module_id} must be ready`);
|
|
});
|
|
|
|
// ── 2/3/4: ROOT 02/03/04 hierarchy — honestly PLANNED, zero modules, not padded ──────────
|
|
await test('ROOT 02/03/04 existed honestly PLANNED at the end of Phase 6D, and are real, authored courses as of Phase 6E', async () => {
|
|
// Phase 6D deliberately built only ROOT 01 and left 02-04 honestly empty — Phase 6E (see
|
|
// docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md) authored them for real. This assertion is
|
|
// updated to reflect that intentional, documented progression rather than re-asserting a
|
|
// fact Phase 6E was explicitly built to change.
|
|
for (const id of ['root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) {
|
|
const course = courseById(structure, id);
|
|
assert.equal(course.status, 'ready', `${id} should now be ready (Phase 6E)`);
|
|
assert.ok(course.modules.length > 0, `${id} should now have real modules (Phase 6E)`);
|
|
}
|
|
});
|
|
|
|
// ── 5/6: activity ordering + content actually loads ──────────────────────────────────────
|
|
await test('every ROOT 01 module has strictly ascending activity sequence and real lesson content', async () => {
|
|
for (const modId of ROOT01_MODULES) {
|
|
const mod = moduleById(structure, modId);
|
|
for (let i = 1; i < mod.activities.length; i++) assert.ok(mod.activities[i].sequence > mod.activities[i - 1].sequence, `${modId} activity ordering broken`);
|
|
const lesson = mod.activities.find((a) => a.activity_type === 'lesson');
|
|
assert.ok(lesson.body && lesson.body.length > 300, `${modId}'s lesson must have real content`);
|
|
}
|
|
});
|
|
|
|
// ── A real self-paced student walking ROOT 01 end to end ────────────────────────────────
|
|
const casey = await newStudent(owner, 'Casey');
|
|
|
|
await test('resume points at ROOT 01 module 0 activity 1, the very first activity in the whole program', async () => {
|
|
// Phase 6F added a required orientation module (root-01-m00-welcome-to-root) in front of
|
|
// what was previously the first module — see docs/PHASE6F_ROOT_V1_PILOT_READINESS.md.
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations');
|
|
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('walking the ROOT_ V1 orientation module completes it and unlocks module 1', async () => {
|
|
await completeActivity(casey.session, 'root-01-m00-a01-lesson-welcome');
|
|
await completeActivity(casey.session, 'root-01-m00-a02-lesson-disposable-lab');
|
|
await completeActivity(casey.session, 'root-01-m00-a03-knowledge-check');
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.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);
|
|
});
|
|
|
|
// ── 16: direct API cannot bypass — a lab activity cannot be manually marked complete ─────
|
|
await test('a lab activity cannot be marked complete manually — only real scenario completion counts', async () => {
|
|
const { status, body } = await casey.session.request('POST', '/api/student/activities/root-01-m01-a03-lab-inspect-hardware/complete');
|
|
assert.equal(status, 409);
|
|
assert.equal(body.error, 'not_manually_completable');
|
|
});
|
|
|
|
// ── 8/9/10/11: lesson/practical/knowledge-check/assessment completion, walked for real ──
|
|
await test('walking ROOT 01 module 1 through the real API completes it', async () => {
|
|
await completeActivity(casey.session, 'root-01-m01-a01-lesson');
|
|
await completeActivity(casey.session, 'root-01-m01-a02-guided-exercise');
|
|
await completeScenario(casey.session, 'linux-inspect-hardware');
|
|
await completeActivity(casey.session, 'root-01-m01-a04-reflection');
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
assert.equal(moduleById(body.courses, 'root-01-m01-how-computers-run-software').complete, true);
|
|
});
|
|
|
|
await test('completing every ROOT 01 module and its assessment marks the whole course complete', async () => {
|
|
// ROOT 01's own modules carry real intra-course prerequisites (m02 requires m01 complete,
|
|
// etc.) — each module must be completed fully, including its lab and reflection, before the
|
|
// next module unlocks. Completing every module's early activities first and circling back
|
|
// for labs/reflections later (the original order here) trips the module-lock check.
|
|
const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' };
|
|
for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) {
|
|
await completeActivity(casey.session, `root-01-${m}-a01-lesson`);
|
|
await completeActivity(casey.session, `root-01-${m}-a02-demonstration`);
|
|
await completeActivity(casey.session, `root-01-${m}-a03-knowledge-check`);
|
|
await completeActivity(casey.session, `root-01-${m}-a04-guided-exercise`);
|
|
await completeScenario(casey.session, labs[m]);
|
|
await completeActivity(casey.session, `root-01-${m}-a06-reflection`);
|
|
}
|
|
|
|
const start = await casey.session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start');
|
|
assert.equal(start.status, 201);
|
|
for (const s of start.body.scenarios) await completeScenario(casey.session, s.scenario_id);
|
|
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
const course = courseById(body.courses, 'root-01-computing-linux-foundations');
|
|
assert.equal(course.complete, true);
|
|
assert.equal(course.percent_complete, 100);
|
|
});
|
|
|
|
// ── 12/15: ROOT 01 unlocks ROOT 02 at the course level, for THIS student ─────────────────
|
|
await test('completing ROOT 01 unlocks ROOT 02 for this student', async () => {
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, false);
|
|
});
|
|
|
|
// ── A second, fresh student proves ROOT 02 stays LOCKED without ROOT 01 ─────────────────
|
|
const bob = await newStudent(owner, 'Bob');
|
|
await test('ROOT 02 is locked for a freshly-enrolled student who has not touched ROOT 01', async () => {
|
|
const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').locked, false, 'ROOT 01 itself is never locked — it is the first course');
|
|
assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, true, 'ROOT 02 must be locked until ROOT 01 is complete');
|
|
});
|
|
|
|
await test('ROOT 03/04/05 are now genuinely locked for a fresh student (Phase 6E gave 02/03/04 real content)', async () => {
|
|
// At the end of Phase 6D this was true via vacuous satisfaction (02-04 had zero activities).
|
|
// Phase 6E authored real content for 02-04, so the SAME chain now genuinely gates for real —
|
|
// this assertion is updated to prove that real enforcement, not the vacuous case it
|
|
// originally documented.
|
|
const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, true);
|
|
assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true);
|
|
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, true);
|
|
});
|
|
|
|
// ── 26/27: existing ROOT 05+ and Capstone state is untouched by this phase ──────────────
|
|
await test('ROOT 06 stays locked behind ROOT 05 exactly as before this phase (unaffected by the new 01-04 chain)', async () => {
|
|
const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true);
|
|
});
|
|
|
|
// ── 17: optional activities still behave correctly elsewhere in the curriculum ──────────
|
|
await test('the pre-existing is_required rule for other courses is unaffected by this phase', async () => {
|
|
const mod = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(mod.activities.filter((a) => a.is_required === false).length, 1, 'Phase 6C\'s optional readiness-probe lab must still be exactly 1');
|
|
});
|
|
|
|
// ── 18/19: self-paced + instructor rendering, and instructor-note exclusion ─────────────
|
|
await test('self-paced ROOT 01 content never defers to a live instructor', async () => {
|
|
for (const modId of ROOT01_MODULES) {
|
|
const mod = moduleById(structure, modId);
|
|
for (const a of mod.activities) {
|
|
if (!a.body) continue;
|
|
assert.ok(!/ask your instructor/i.test(a.body) && !/your instructor will explain/i.test(a.body), `${a.activity_id} must not defer explanation to a live instructor`);
|
|
}
|
|
}
|
|
});
|
|
|
|
await test('instructor notes on ROOT 01 content are visible to instructors and never to students', async () => {
|
|
const lesson = findActivity(structure, 'root-01-m06-a01-lesson');
|
|
assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0);
|
|
const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
const studentLesson = findActivity(body.courses, 'root-01-m06-a01-lesson');
|
|
assert.equal(studentLesson.instructor_notes, undefined);
|
|
});
|
|
|
|
// ── 20: enrollment entitlement / RBAC ────────────────────────────────────────────────────
|
|
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6d-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6D', role: 'instructor', password: 'StrayInstructorPass6D!' });
|
|
const stray = makeSession(GW);
|
|
await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6d-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6D!' });
|
|
await test('a plain instructor with no relationship to the student cannot see ROOT 01 progress', async () => {
|
|
const { status } = await stray.request('GET', `/api/admin/students/${casey.studentId}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 403);
|
|
});
|
|
await test('owner/admin can see any self-paced student\'s ROOT 01 progress', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/students/${casey.studentId}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 200);
|
|
assert.equal(courseById(body, 'root-01-computing-linux-foundations').complete, true);
|
|
});
|
|
|
|
// ── 21/23: persistence + cross-student isolation ────────────────────────────────────────
|
|
await test('ROOT 01 progress survives logout and a fresh login', async () => {
|
|
await casey.session.request('POST', '/api/logout');
|
|
const returning = makeSession(GW);
|
|
await returning.request('POST', '/api/student/login', { identifier: `e2e6d-Casey-${RUN_ID}`, password: 'CaseyPassword123!' });
|
|
const { body } = await returning.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, true);
|
|
});
|
|
|
|
await test('a second student sees independent, empty ROOT 01 progress (cross-student isolation)', async () => {
|
|
const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`);
|
|
assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, false);
|
|
});
|
|
|
|
// ── 25: content version stability ───────────────────────────────────────────────────────
|
|
await test('bumping a ROOT 01 module\'s version does not drop its content', async () => {
|
|
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-01-m06-users-and-permissions-foundations'`);
|
|
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
const mod = moduleById(body, 'root-01-m06-users-and-permissions-foundations');
|
|
assert.equal(mod.activities.length, 7, 'a version bump must never drop activities');
|
|
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
|
|
});
|
|
|
|
// ── content validation as part of this suite ────────────────────────────────────────────
|
|
await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => {
|
|
execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' });
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|