Files
rootnode-academy/platform/test/e2e-phase6b.js
T
ced4568andClaude Sonnet 5 f5379afd5c 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]>
2026-08-26 13:00:17 -05:00

337 lines
25 KiB
JavaScript

#!/usr/bin/env node
// Phase 6B — ROOT_ curriculum authoring & coverage expansion acceptance test. Covers the NEW
// courses/modules/content this phase added (ROOT 05-10/12/Capstone), the two genuinely new
// mechanisms it introduced (is_required optional-activity handling, and the capstone
// server-side unlock fix), and confirms the underlying Phase 6A engine (completion derivation,
// locking, RBAC, isolation, persistence, draft-hiding) generalizes correctly to the new content
// rather than re-proving every property from scratch against every module (already proven once,
// thoroughly, in e2e-phase6a.js — see that file for the exhaustive per-mechanism coverage).
// Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab
// container launched here (infra/scripts/drills/test-phase6b-root-course-rehearsal.js is the
// real-k3s 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-phase6b.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';
// The real ROOT 05 Foundation scenarios (m00 + m01) — completing all of these is what actually
// unlocks ROOT 06 at the course level (courses.prerequisites), same real content
// e2e-phase6a.js/e2e-phase5b.js already drive completion against.
const ROOT05_ALL = ['namespaces-basics', 'pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq',
'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'];
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`);
}
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 main() {
console.log('Phase 6B ROOT_ curriculum expansion acceptance test\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
// ── 1-8: hierarchy for every course this phase touched ──────────────────────────────────
let structure;
await test('ROOT 05-10, 12, and Capstone all have the expected new modules, in sequence', async () => {
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
assert.equal(status, 200);
structure = body;
const expectations = {
'root-05-kubernetes-foundations': ['root-05-m00-kubernetes-mental-model', 'root-05-m01-pods-and-kubectl-basics'],
'root-06-kubernetes-application-workloads': ['root-06-m02-deployments-scaling-updates', 'root-06-m03-jobs-cronjobs-daemonsets', 'root-06-m01-deployments-and-workloads'],
'root-07-kubernetes-networking-config': ['root-07-m03-services-and-service-discovery', 'root-07-m04-ingress-networkpolicy-foundations', 'root-07-m01-services-and-networking', 'root-07-m02-configuration-and-secrets'],
'root-08-kubernetes-storage-scheduling': ['root-08-m01-storage-fundamentals', 'root-08-m02-scheduling-resource-governance'],
'root-09-kubernetes-security': ['root-09-m01-security-model-and-securitycontext', 'root-09-m02-serviceaccounts-and-rbac', 'root-09-m03-network-and-supplychain-security', 'root-09-m04-cluster-hardening-and-runtime'],
'root-10-kubernetes-troubleshooting-observability': ['root-10-m01-health-probes-and-troubleshooting', 'root-10-m03-troubleshooting-framework', 'root-10-m02-general-troubleshooting'],
'root-12-real-ops': ['root-12-m01-real-ops-incidents'],
'root-capstone': ['root-capstone-m02-capstone-readiness', 'root-capstone-m01-operate-and-recover'],
};
for (const [courseId, moduleIds] of Object.entries(expectations)) {
const course = courseById(structure, courseId);
assert.ok(course, `${courseId} must exist`);
const actualIds = course.modules.map((m) => m.module_id);
assert.deepEqual(actualIds, moduleIds, `${courseId} module order mismatch: ${JSON.stringify(actualIds)}`);
}
});
await test('ROOT 08 and ROOT 09 moved from PLANNED to PARTIAL — real content exists now', async () => {
assert.equal(courseById(structure, 'root-08-kubernetes-storage-scheduling').status, 'partial');
assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial');
});
await test('the gold-standard Health Probes module\'s required chain is unchanged (Phase 6C added one optional lab)', async () => {
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
assert.equal(gold.status, 'ready');
assert.equal(gold.activities.length, 7);
assert.equal(gold.activities.filter((a) => a.is_required !== false).length, 6, 'the original 6 required activities must be untouched');
});
await test('activity ordering within every new module is strictly ascending', async () => {
for (const newModuleId of ['root-05-m00-kubernetes-mental-model', 'root-06-m02-deployments-scaling-updates', 'root-07-m03-services-and-service-discovery', 'root-09-m01-security-model-and-securitycontext']) {
const mod = moduleById(structure, newModuleId);
for (let i = 1; i < mod.activities.length; i++) assert.ok(mod.activities[i].sequence > mod.activities[i - 1].sequence, `${newModuleId} activity ordering broken`);
}
});
await test('ROOT 12\'s new framework lesson is first and the new reflection is last, existing labs preserved in between', async () => {
const mod = moduleById(structure, 'root-12-m01-real-ops-incidents');
const types = mod.activities.map((a) => a.activity_type);
assert.deepEqual(types, ['lesson', 'lab', 'lab', 'lab', 'lab', 'reflection']);
const scenarioIds = mod.activities.filter((a) => a.activity_type === 'lab').map((a) => a.scenario_id);
assert.deepEqual(scenarioIds, ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']);
});
// ── A real self-paced student walking the new content ───────────────────────────────────
const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Casey-${RUN_ID}`, login_identifier: `e2e6b-casey-${RUN_ID}` });
let casey = makeSession(GW);
await casey.request('POST', '/api/student/activate', { token: caseyCreate.activation_token, password: 'CaseyPassword123!' });
const { body: enrollCreate } = await owner.request('POST', '/api/admin/enrollments', { student_id: caseyCreate.student_id, program_id: PROGRAM, status: 'active' });
const enrollmentId = enrollCreate.enrollment_id;
await test('ROOT 06/09/10-framework are locked for a freshly-enrolled student', async () => {
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true);
assert.equal(moduleById(body.courses, 'root-09-m01-security-model-and-securitycontext').locked, true);
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 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-01-m01-how-computers-run-software');
});
await test('lesson/knowledge_check completion works identically in a brand-new module (root-05-m00)', async () => {
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces']) {
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
assert.equal(status, 200);
}
await completeScenario(casey, 'namespaces-basics');
const { status } = await casey.request('POST', '/api/student/activities/root-05-m00-a06-reflection/complete');
assert.equal(status, 200);
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true);
});
await test('completing every ROOT 05 scenario (m00 + m01) unlocks ROOT 06 at the course level', async () => {
for (const sid of ROOT05_ALL) await completeScenario(casey, sid);
// Phase 6C added a bookending lesson + reflection around m01's 20-task bank — both required
// manual markers, so m01 (and therefore the ROOT 05 course) isn't complete without them.
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`);
}
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true);
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false);
assert.equal(moduleById(body.courses, 'root-06-m01-deployments-and-workloads').locked, true, 'the bare bank stays gated behind the new m02 specifically');
});
await test('a new scenario wired this phase (job-task, never in Phase 6A) completes an activity correctly', async () => {
await completeScenario(casey, 'job-task');
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(findActivity(body.courses, 'root-06-m03-a03-lab-job-task').complete, true);
});
// ── is_required: optional activities count toward percent but never block "complete" ────
await test('an optional activity being incomplete does not block its module from being complete', async () => {
// root-09 is still locked for Casey (needs root-06-m02 complete). Prove the is_required
// rule directly against the seeded data instead: root-09-m01 has 12 activities, 3 of which
// (a10/a11/a12) are is_required=false. Confirm the schema/shape is correct via the admin
// structure view (studentId=null, so nothing is actually complete — this is a pure
// structural check of the is_required flag itself).
const mod = moduleById(structure, 'root-09-m01-security-model-and-securitycontext');
const optional = mod.activities.filter((a) => a.is_required === false);
const required = mod.activities.filter((a) => a.is_required !== false);
assert.equal(optional.length, 3, 'expected exactly 3 optional activities in root-09-m01');
assert.equal(required.length, 9);
});
await test('completing every REQUIRED activity in root-09-m01 marks it complete even with optional activities left undone', async () => {
// Unlock root-09 for Casey: complete the rest of root-06 (m02 + m03's remaining scenarios).
for (const sid of ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback']) {
await completeScenario(casey, sid);
}
await completeScenario(casey, 'cronjob-task');
for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy']) {
await casey.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`);
}
await casey.request('POST', '/api/student/activities/root-06-m02-a06-reflection/complete');
for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a02-knowledge-check']) {
await casey.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`);
}
let tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses;
assert.equal(moduleById(tree, 'root-09-m01-security-model-and-securitycontext').locked, false, 'root-09 should now be unlocked');
// Complete only the REQUIRED activities in root-09-m01, deliberately skipping the 3 optional ones.
for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan']) {
await casey.request('POST', `/api/student/activities/root-09-m01-${suffix}/complete`);
}
for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await completeScenario(casey, sid);
await casey.request('POST', '/api/student/activities/root-09-m01-a09-reflection/complete');
tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses;
const mod = moduleById(tree, 'root-09-m01-security-model-and-securitycontext');
assert.equal(mod.complete, true, 'module must be complete once all REQUIRED activities are done, regardless of the 3 optional ones');
assert.equal(mod.percent_complete, Math.round((9 / 12) * 100), 'percent_complete must still reflect ALL activities, optional included');
assert.equal(findActivity(tree, 'root-09-m01-a10-lab-seccomp-guided').complete, false, 'the optional activity itself is correctly still incomplete');
});
// ── Persistence, isolation, RBAC, draft-hiding — proven once against NEW content ────────
await test('progress on new content survives logout and a fresh login', async () => {
await casey.request('POST', '/api/logout');
casey = makeSession(GW);
await casey.request('POST', '/api/student/login', { identifier: `e2e6b-casey-${RUN_ID}`, password: 'CaseyPassword123!' });
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true);
});
const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Isolated-${RUN_ID}`, login_identifier: `e2e6b-isolated-${RUN_ID}` });
const isolated = makeSession(GW);
await isolated.request('POST', '/api/student/activate', { token: isolatedCreate.activation_token, password: 'IsolatedPassword123!' });
const { body: isolatedEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: isolatedCreate.student_id, program_id: PROGRAM, status: 'active' });
await test('a second student sees independent progress on the new courses (cross-student isolation)', async () => {
const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`);
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, false);
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true);
});
await test('a draft activity in a new module is hidden from students but visible to instructors', async () => {
psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`);
try {
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
const mod = moduleById(studentView.courses, 'root-08-m01-storage-fundamentals');
assert.ok(!mod.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals'));
const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
const modAdmin = moduleById(adminView, 'root-08-m01-storage-fundamentals');
assert.ok(modAdmin.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals'));
} finally {
psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`);
}
});
await test('instructor notes on new content are visible to instructors and never to students', async () => {
const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
const lesson = findActivity(adminView, 'root-09-m01-a01-lesson-security-model');
assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes'));
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
const studentLesson = findActivity(studentView.courses, 'root-09-m01-a01-lesson-security-model');
assert.equal(studentLesson.instructor_notes, undefined, 'a student response must never include this key at all');
});
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6b-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor', role: 'instructor', password: 'StrayInstructorPass123!' });
const strayInstructorSession = makeSession(GW);
await strayInstructorSession.request('POST', '/api/auth/instructor/login', { email: `e2e6b-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' });
await test('a plain instructor with no relationship to the student is forbidden from their new-course progress (self-paced, no cohort)', async () => {
const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
assert.equal(status, 403);
});
await test('owner/admin can see any self-paced student\'s progress on the new courses', async () => {
const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
assert.equal(status, 200);
assert.equal(moduleById(body, 'root-05-m00-kubernetes-mental-model').complete, true);
});
// ── Capstone: the server-side unlock fix — the central Phase 6B security fix ───────────
const { body: bypassCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Bypass-${RUN_ID}`, login_identifier: `e2e6b-bypass-${RUN_ID}` });
const bypass = makeSession(GW);
await bypass.request('POST', '/api/student/activate', { token: bypassCreate.activation_token, password: 'BypassPassword123!' });
const { body: bypassEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: bypassCreate.student_id, program_id: PROGRAM, status: 'active' });
await test('direct POST /api/assessments/rootnode-capstone/start is rejected before prerequisites are complete (the fix)', async () => {
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(status, 403);
assert.equal(body.error, 'locked');
assert.ok(Array.isArray(body.blocking_stage_ids) && body.blocking_stage_ids.length > 0);
});
await test('an ordinary exam with no unlock_rule is completely unaffected by the fix', async () => {
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-quick-assessment/start');
assert.equal(status, 201);
assert.ok(body.scenarios.length > 0);
// Clean up the active session so it doesn't block later assertions for this student.
for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id);
});
await test('a legitimately unlocked capstone start is still accepted (the fix does not over-block)', async () => {
const ALL_STAGES = ['pod-basics-mcq', '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',
'rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
for (const sid of ALL_STAGES) await completeScenario(bypass, sid);
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(status, 201, `expected the now-unlocked capstone to start: ${JSON.stringify(body)}`);
for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id);
const check = await bypass.request('GET', '/api/capstone');
assert.equal(check.body.status, 'completed');
});
await test('the capstone-derived completion is reflected in the course/module content model too', async () => {
const { body } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`);
const capstoneActivity = findActivity(body.courses, 'root-capstone-m01-a01-capstone');
assert.equal(capstoneActivity.complete, true);
});
await test('the Capstone Readiness module itself is completable independent of the real assessment', async () => {
const enrollmentIdForCasey = enrollmentId;
for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) {
const { status } = await casey.request('POST', `/api/student/activities/root-capstone-m02-${suffix}/complete`);
assert.equal(status, 200);
}
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentIdForCasey}/courses`);
assert.equal(moduleById(body.courses, 'root-capstone-m02-capstone-readiness').complete, true);
});
// ── Content version stability ─────────────────────────────────────────────────────────────
await test('bumping a new module\'s version does not break or drop its content', async () => {
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-09-m01-security-model-and-securitycontext'`);
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
const mod = moduleById(body, 'root-09-m01-security-model-and-securitycontext');
assert.equal(mod.activities.length, 12, 'a version bump must never drop activities');
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });