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]>
326 lines
24 KiB
JavaScript
326 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 6A — course/module/activity content model acceptance test. Covers course/module/
|
|
// activity hierarchy and ordering, prerequisite-based locking, all three completion behaviors
|
|
// (manual_marker/auto_scenario/auto_assessment) derived from the EXISTING progress/achievements
|
|
// tables (never a second source of truth), resume-next-activity, cross-student isolation, RBAC
|
|
// (self-paced no-cohort students), draft-content hiding, and persistence across logout/login.
|
|
// Same conventions as e2e-phase5b.js/e2e-phase5c.js: assert-based, no framework, live
|
|
// docker-compose.dev.yml stack. No real Lab container is launched here — that's
|
|
// infra/scripts/drills/test-phase6a-course-rehearsal.js's job (uses the real Lab environment);
|
|
// this suite proves the API/data model, using /api/progress/record directly the same way
|
|
// e2e-phase3b.js/e2e-phase5b.js already do for scenario completion.
|
|
//
|
|
// 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-phase6a.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 API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
|
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';
|
|
|
|
// Same real curriculum snapshot e2e-phase3b.js/e2e-phase5b.js already drive full completion
|
|
// against — see those files' headers for why this is a real, not synthetic, content list.
|
|
const FOUNDATION = ['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'];
|
|
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
|
|
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
|
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
|
|
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
|
|
const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
|
|
|
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 6A ROOT_ course architecture & content delivery acceptance test\n');
|
|
|
|
const owner = makeSession(GW);
|
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
|
|
// ── Hierarchy, ordering, honest states ──────────────────────────────────────────────────
|
|
let structure;
|
|
await test('the ROOT_ course architecture exists with 13 courses in sequence', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.length, 13);
|
|
for (let i = 1; i < body.length; i++) assert.ok(body[i].sequence > body[i - 1].sequence, 'courses must be in ascending sequence order');
|
|
structure = body;
|
|
});
|
|
|
|
await test('courses use honest ready/partial/planned states, no fabricated content', async () => {
|
|
const plannedWithModules = structure.filter((c) => c.status === 'planned' && c.modules.length > 0);
|
|
assert.equal(plannedWithModules.length, 0, 'a PLANNED course must have zero modules, not placeholder content');
|
|
const gold = courseById(structure, 'root-10-kubernetes-troubleshooting-observability');
|
|
assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules
|
|
});
|
|
|
|
await test('the gold-standard module is fully READY with 7 ordered activities and real content', async () => {
|
|
// Phase 6C added one OPTIONAL practice lab (readiness-probe-task) after the original
|
|
// required lab — see docs/PHASE6C_CURRICULUM_COMPLETION.md. The required teaching chain
|
|
// (lesson/demo/kc/guided/lab/reflection) is otherwise byte-for-byte unchanged.
|
|
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(goldModule.status, 'ready');
|
|
assert.equal(goldModule.activities.length, 7);
|
|
const types = goldModule.activities.map((a) => a.activity_type);
|
|
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection']);
|
|
for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence);
|
|
const lesson = goldModule.activities[0];
|
|
assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty');
|
|
assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes'), 'instructor view must see instructor notes');
|
|
const lab = goldModule.activities.find((a) => a.activity_type === 'lab');
|
|
assert.equal(lab.scenario_id, 'rootnode-probe-port-mismatch');
|
|
assert.equal(lab.completion_behavior, 'auto_scenario');
|
|
});
|
|
|
|
await test('the capstone activity references the existing assessment engine, not a duplicate definition', async () => {
|
|
const capstoneModule = moduleById(structure, 'root-capstone-m01-operate-and-recover');
|
|
const activity = capstoneModule.activities[0];
|
|
assert.equal(activity.activity_type, 'capstone');
|
|
assert.equal(activity.assessment_definition_id, 'rootnode-capstone');
|
|
assert.equal(activity.completion_behavior, 'auto_assessment');
|
|
});
|
|
|
|
// ── A real self-paced student, enrolled and walked through the gold module ──────────────
|
|
const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Casey-${RUN_ID}`, login_identifier: `e2e6a-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;
|
|
|
|
let tree;
|
|
await test('a freshly-enrolled student sees the course tree with nothing complete and root-06 locked', async () => {
|
|
const { status, body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
assert.equal(status, 200);
|
|
tree = body.courses;
|
|
const root05 = courseById(tree, 'root-05-kubernetes-foundations');
|
|
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');
|
|
});
|
|
|
|
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`);
|
|
// 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 () => {
|
|
const { status, body } = await casey.request('POST', '/api/student/activities/root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq/complete');
|
|
assert.equal(status, 409);
|
|
assert.equal(body.error, 'not_manually_completable');
|
|
});
|
|
|
|
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);
|
|
}
|
|
// Phase 6C added a bookending lesson + reflection around root-05-m01's 20-task kubectl-
|
|
// fluency bank (see docs/PHASE6C_CURRICULUM_COMPLETION.md) — both are required manual
|
|
// markers, so root-05 isn't complete until they're marked too.
|
|
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
|
const { status } = await casey.request('POST', `/api/student/activities/root-05-m01-${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);
|
|
assert.equal(root05.percent_complete, 100);
|
|
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
|
assert.equal(root06.locked, false, 'root-06 must unlock once root-05 is complete');
|
|
});
|
|
|
|
// ── The gold module: lesson/demo/knowledge-check/guided-exercise (manual) + lab (auto) + reflection (manual) ──
|
|
await test('lesson-only activities complete via the explicit marker, not automatically', async () => {
|
|
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
|
const { status } = await casey.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
|
assert.equal(status, 200);
|
|
}
|
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(goldModule.activities.filter((a) => a.complete).length, 4);
|
|
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, false, 'the lab must not be complete yet — nothing was solved');
|
|
});
|
|
|
|
await test('marking the same lesson complete twice is idempotent, not an error', async () => {
|
|
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
|
assert.equal(status, 200);
|
|
});
|
|
|
|
await test('the lab activity completes automatically from real scenario completion, not a manual marker', async () => {
|
|
await completeScenario(casey, 'rootnode-probe-port-mismatch');
|
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, true);
|
|
assert.equal(goldModule.complete, false, 'the module is not complete until the reflection is also marked');
|
|
});
|
|
|
|
await test('completing the reflection finishes the gold module and unlocks the next one', async () => {
|
|
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
|
assert.equal(status, 200);
|
|
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(goldModule.complete, true, 'a module is complete once every REQUIRED activity is done, regardless of the one optional lab left untouched');
|
|
assert.equal(goldModule.percent_complete, Math.round((6 / 7) * 100), 'percent_complete reflects ALL activities, optional included — Phase 6C added one optional lab');
|
|
// 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) ────────────────────────────
|
|
await test('completing every remaining curriculum scenario and the real capstone marks the capstone activity complete', async () => {
|
|
for (const sid of [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING]) await completeScenario(casey, sid);
|
|
for (const sid of REAL_OPS) if (sid !== 'rootnode-probe-port-mismatch') await completeScenario(casey, sid); // already done above
|
|
const { status, body } = await casey.request('POST', '/api/assessments/rootnode-capstone/start');
|
|
assert.equal(status, 201, `capstone should start once every stage is complete: ${JSON.stringify(body)}`);
|
|
for (const s of body.scenarios) await completeScenario(casey, s.scenario_id);
|
|
const capstoneCheck = await casey.request('GET', '/api/capstone');
|
|
assert.equal(capstoneCheck.body.status, 'completed');
|
|
|
|
const { body: tree2 } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const capstoneActivity = findActivity(tree2.courses, 'root-capstone-m01-a01-capstone');
|
|
assert.equal(capstoneActivity.complete, true, 'the capstone course activity must derive completion from the real achievement, not a separate marker');
|
|
});
|
|
|
|
// ── Persistence across logout/login (no real Lab needed — this proves the DATA persists) ──
|
|
await test('course progress survives logout and a fresh login', async () => {
|
|
await casey.request('POST', '/api/logout');
|
|
casey = makeSession(GW);
|
|
await casey.request('POST', '/api/student/login', { identifier: `e2e6a-casey-${RUN_ID}`, password: 'CaseyPassword123!' });
|
|
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, 'progress must survive a real logout/login, not just an in-memory session');
|
|
});
|
|
|
|
// ── Cross-student isolation ──────────────────────────────────────────────────────────────
|
|
const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Isolated-${RUN_ID}`, login_identifier: `e2e6a-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 on the same program sees their own independent, empty progress', async () => {
|
|
const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`);
|
|
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
|
assert.equal(root05.complete, false, 'a different student\'s completion must never leak across');
|
|
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
|
assert.equal(root06.locked, true);
|
|
});
|
|
|
|
const { body: strangerCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Stranger-${RUN_ID}`, login_identifier: `e2e6a-stranger-${RUN_ID}` });
|
|
const stranger = makeSession(GW);
|
|
await stranger.request('POST', '/api/student/activate', { token: strangerCreate.activation_token, password: 'StrangerPassword123!' });
|
|
await test('a student with NO enrollment anywhere cannot mark any activity complete', async () => {
|
|
const { status, body } = await stranger.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
|
assert.equal(status, 403);
|
|
assert.equal(body.error, 'not_entitled');
|
|
});
|
|
|
|
// ── RBAC: self-paced students have no cohort — requireStudentRelationshipAccess must apply ──
|
|
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6a-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: `e2e6a-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' });
|
|
|
|
await test('a plain instructor with no relationship to a self-paced student is forbidden from their course progress', async () => {
|
|
const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 403);
|
|
});
|
|
|
|
// Note: self-paced enrollment creation is admin/owner-only (admin-programs-routes.js), so a
|
|
// plain 'instructor' can never become an enrollment's created_by for a self-paced student —
|
|
// requireStudentRelationshipAccess's positive path for a plain instructor is necessarily via
|
|
// a shared cohort, already covered by e2e-phase5c.js's equivalent notification-visibility
|
|
// tests against the same rbac.js helper. Not re-tested here to avoid duplicating that
|
|
// coverage against unrelated cohort machinery.
|
|
await test('owner/admin can see any self-paced student\'s course progress regardless of relationship', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 200);
|
|
const root05 = courseById(body, 'root-05-kubernetes-foundations');
|
|
assert.equal(root05.complete, true);
|
|
});
|
|
|
|
// ── Draft/inactive content never leaks to students ──────────────────────────────────────
|
|
await test('a draft activity is invisible to the student endpoint but visible to instructors', async () => {
|
|
psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
|
try {
|
|
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const workloadsModule = moduleById(studentView.courses, 'root-06-m01-deployments-and-workloads');
|
|
assert.ok(!workloadsModule.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'a draft activity must never appear in a student response');
|
|
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
const workloadsModuleAdmin = moduleById(instructorView, 'root-06-m01-deployments-and-workloads');
|
|
assert.ok(workloadsModuleAdmin.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'instructors must still see draft content');
|
|
} finally {
|
|
psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
|
}
|
|
});
|
|
|
|
await test('a draft module is invisible to students but visible to instructors', async () => {
|
|
psql(`UPDATE course_modules SET visibility = 'draft' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
|
try {
|
|
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
|
const root07 = courseById(studentView.courses, 'root-07-kubernetes-networking-config');
|
|
assert.ok(!root07.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
|
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
const root07Admin = courseById(instructorView, 'root-07-kubernetes-networking-config');
|
|
assert.ok(root07Admin.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
|
} finally {
|
|
psql(`UPDATE course_modules SET visibility = 'published' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
|
}
|
|
});
|
|
|
|
// ── Content version stability ────────────────────────────────────────────────────────────
|
|
await test('bumping a module\'s version does not break or drop its content', async () => {
|
|
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`);
|
|
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(goldModule.activities.length, 7, 'a version bump must never drop activities');
|
|
assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump');
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|