test(phase6d): acceptance suite, real-Lab rehearsal, and 6A/6B resume-point fixes
Adds platform/test/e2e-phase6d.js (20 checks): ROOT 01 hierarchy, ROOT 02-04 honestly empty
(not padded), activity ordering, lab-cannot-be-manually-completed, full course completion via
lesson/lab/assessment, the prerequisite chain proven on two students (one who completed ROOT 01,
one fresh), existing ROOT 05+/Capstone state unaffected, is_required unaffected, self-paced
no-instructor-deferral check, instructor-notes exclusion, RBAC, logout/login persistence,
cross-student isolation, version-bump stability, and content validation. 20/20 pass against both
a warm and a freshly-reset dev stack.
Adds infra/scripts/drills/{run,test}-phase6d-foundations-rehearsal — the real disposable-Lab
dress rehearsal: login -> enroll -> lesson/guided-exercise -> real Lab launch -> real bash
hardware inspection (nproc/free/df, written to a real file) -> real validation -> a genuine
permission denial (a second Linux user actually denied via `su -`) then a real chmod fix ->
remaining modules -> assessment -> course completion -> ROOT 02 unlock -> logout/login
persistence -> instructor visibility of the resulting learning state. 11/11 pass against a real
Lab container, no mocking. (First run caught a real triple-escaping bug in the hardware-report
step's inline heredoc — fixed by writing the script to a file and docker-cp-ing it in instead.)
Fixes two stale hardcoded assumptions in e2e-phase6a.js and e2e-phase6b.js that this phase's own
ROOT 01 addition broke: both suites asserted `resume` landed on ROOT 05's gold module as "the
first real content," which was true before this phase and is now ROOT 01. Both suites' original
intent (resume must never land on a bare-lab bank) is preserved, just against the current front
door. Both suites re-verified green (21/21 and 24/24) against a fresh stack.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -122,7 +122,7 @@ async function main() {
|
||||
assert.equal(status, 200);
|
||||
tree = body.courses;
|
||||
const root05 = courseById(tree, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.locked, false); // the first real course is never locked
|
||||
assert.equal(root05.locked, false); // root-04 (its real Phase 6D prerequisite) has zero activities — vacuously satisfied
|
||||
assert.equal(root05.complete, false);
|
||||
const root06 = courseById(tree, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, true, 'root-06 must be locked until root-05 is complete');
|
||||
@@ -130,11 +130,14 @@ async function main() {
|
||||
|
||||
await test('resume points at the first activity of the first unlocked course', async () => {
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(body.resume.course_id, 'root-05-kubernetes-foundations');
|
||||
// Phase 6B added root-05-m00 (the mental-model module) as the new front door, sequenced
|
||||
// before the pre-existing bare-lab bank — see docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
|
||||
assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model');
|
||||
assert.equal(body.resume.activity_id, 'root-05-m00-a01-lesson-mental-model');
|
||||
// Phase 6D authored ROOT 01 (Computing & Linux Foundations) as a real, non-PLANNED course
|
||||
// sequenced before ROOT 05 — it is now genuinely the program's front door, not ROOT 05's
|
||||
// own gold module (see docs/PHASE6D_ROOT_FOUNDATIONS.md). This test's original intent —
|
||||
// "resume lands on the first REAL content, not a bare-lab bank" — is still what's being
|
||||
// proven, just against the current front door.
|
||||
assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations');
|
||||
assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software');
|
||||
assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson');
|
||||
});
|
||||
|
||||
await test('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => {
|
||||
|
||||
@@ -126,9 +126,13 @@ async function main() {
|
||||
assert.equal(moduleById(body.courses, 'root-10-m03-troubleshooting-framework').locked, true, 'must stay locked behind the gold module even though it is a different course-level dependency chain');
|
||||
});
|
||||
|
||||
await test('resume points at ROOT 05\'s new mental-model module, not the old bare-lab bank', async () => {
|
||||
await test('resume points at ROOT 01 (Phase 6D\'s new front door), not ROOT 05\'s old bare-lab bank', async () => {
|
||||
// Phase 6D authored ROOT 01 as real, non-PLANNED content sequenced before ROOT 05 — it is
|
||||
// now genuinely the program's front door (docs/PHASE6D_ROOT_FOUNDATIONS.md). This test's
|
||||
// original intent — resume must never land on a bare-lab bank when real front-door content
|
||||
// exists — is unchanged, just against the current front door.
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model');
|
||||
assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software');
|
||||
});
|
||||
|
||||
await test('lesson/knowledge_check completion works identically in a brand-new module (root-05-m00)', async () => {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/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';
|
||||
const ROOT01_MODULES = ['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 are honestly PLANNED with zero modules — not padded to look further along than they are', async () => {
|
||||
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, 'planned', `${id} must stay honestly planned`);
|
||||
assert.equal(course.modules.length, 0, `${id} must have zero modules — no fabricated content`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 1 activity 1, the very first activity in the whole program', async () => {
|
||||
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-m01-how-computers-run-software');
|
||||
assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson');
|
||||
});
|
||||
|
||||
// ── 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 () => {
|
||||
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`);
|
||||
}
|
||||
for (const [sid, activity] of [['linux-shell-basics', 'root-01-m02-a06-reflection'], ['linux-filesystem-navigation', 'root-01-m03-a06-reflection'], ['linux-files-and-text', 'root-01-m04-a06-reflection'], ['linux-process-management', 'root-01-m05-a06-reflection']]) {
|
||||
await completeScenario(casey.session, sid);
|
||||
await completeActivity(casey.session, activity);
|
||||
}
|
||||
await completeScenario(casey.session, 'linux-permissions-foundations');
|
||||
await completeActivity(casey.session, 'root-01-m06-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 stay unlocked (vacuously satisfied) since their own prerequisite courses have zero content yet', async () => {
|
||||
const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`);
|
||||
assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, false);
|
||||
assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, false);
|
||||
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false);
|
||||
});
|
||||
|
||||
// ── 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); });
|
||||
Reference in New Issue
Block a user