test(phase6b): add ROOT curriculum acceptance coverage + real-Lab rehearsal

e2e-phase6b.js (24 checks): hierarchy for the 13 new modules across
ROOT 05-10/12/Capstone, prerequisite chains through the newly-inserted
front-door modules, lesson/knowledge-check completion in brand-new
modules, a newly-wired scenario (job-task) completing correctly, the
optional-vs-required (is_required) completion rule, persistence/
isolation/RBAC/draft-hiding proven against the new content, and the
capstone server-side unlock fix in both directions (a direct bypass
attempt rejected with blocking_stage_ids, an ordinary exam unaffected,
a legitimately unlocked attempt still accepted and its completion
reflected in the course content model).

test-phase6b-root-course-rehearsal.js + wrapper (11 checks, real k3s):
one continuous student narrative through a brand-new module (ROOT 07
Services & Service Discovery, not the Phase 6A gold module) — real Lab
launch, the Lab's own setup_commands, a real 'kubectl expose' against
the live cluster, real validate, logout/login/Resume Learning, Lab
destroy/recreate with academic progress intact — plus an instructor
narrative proving Curriculum view, Prepare Labs, and classroom/learning
tooling all still work against the expanded curriculum.

e2e-phase6a.js updated for two of this phase's own intentional,
documented behavior changes (not defects): resume now points at
root-05's new mental-model module instead of the old bare-lab bank, and
root-10's gold module now unlocks the new troubleshooting-framework
module rather than the bare-lab bank directly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 09:09:20 -05:00
co-authored by Claude Sonnet 5
parent e0f954e714
commit 505783e5f7
4 changed files with 571 additions and 5 deletions
+17 -5
View File
@@ -128,8 +128,10 @@ 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');
assert.equal(body.resume.module_id, 'root-05-m01-pods-and-kubectl-basics');
assert.equal(body.resume.activity_id, 'root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq');
// 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');
});
await test('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => {
@@ -138,8 +140,15 @@ async function main() {
assert.equal(body.error, 'not_manually_completable');
});
await test('completing root-05\'s 20 real scenarios marks the course complete and unlocks root-06', async () => {
await test('completing root-05\'s 20 real scenarios + the Phase 6B mental-model module marks the course complete and unlocks root-06', async () => {
for (const sid of FOUNDATION) await completeScenario(casey, sid);
// Phase 6B's root-05-m00 has its own manual-marker activities (lesson/demonstration/
// knowledge_check/guided_exercise/reflection) that scenario completion alone never
// satisfies — root-05 the COURSE isn't complete until these are marked too.
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) {
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
assert.equal(status, 200);
}
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
assert.equal(root05.complete, true);
@@ -180,8 +189,11 @@ async function main() {
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
assert.equal(goldModule.complete, true);
assert.equal(goldModule.percent_complete, 100);
const nextModule = moduleById(body.courses, 'root-10-m02-general-troubleshooting');
assert.equal(nextModule.locked, false, 'root-10-m02 must unlock once the gold module is complete');
// Phase 6B inserted root-10-m03 (the troubleshooting framework module) between the gold
// module and the pre-existing bare-lab bank — m02's prerequisite is now m03, not m01
// directly. See docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
const frameworkModule = moduleById(body.courses, 'root-10-m03-troubleshooting-framework');
assert.equal(frameworkModule.locked, false, 'root-10-m03 must unlock once the gold module is complete');
});
// ── Assessment/capstone-derived completion (auto_assessment) ────────────────────────────
+326
View File
@@ -0,0 +1,326 @@
#!/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 is byte-for-byte unchanged (still 6 activities, still READY)', async () => {
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
assert.equal(gold.status, 'ready');
assert.equal(gold.activities.length, 6);
});
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 05\'s new mental-model module, not the old bare-lab bank', async () => {
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model');
});
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);
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); });