#!/usr/bin/env node // Phase 7 — Business & Product Architecture acceptance test. Organizations, locations, // guardians, curriculum packages, offerings (capacity, server-defined transitions), // registrations (lifecycle, agreements, conversion to real enrollment), RBAC, and explicit // proof that none of this depends on age or breaks ROOT_ V1's existing self-paced/classroom // flows. Same conventions as e2e-phase6a-f.js: assert-based, no framework, live // docker-compose.dev.yml stack. // // 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-phase7.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); const { completeRootFoundations01Through04 } = require('./foundations-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'; 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.stack || e.message}`); failed++; } } 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 7 Business & Product Architecture acceptance test\n'); const owner = makeSession(GW); { const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); } // ── 1: course families are guidance, not hard age gates ────────────────────────────────── await test('programs.family accepts bootcamp; recommended_min/max_age is never read by any RBAC/authorization code', async () => { psql(`INSERT INTO programs (program_id, name, family, delivery_mode, curriculum_path, status) VALUES ('p7-bootcamp-check-${RUN_ID}', 'Bootcamp Check', 'bootcamp', 'self_paced', 'rootnode-path', 'active') ON CONFLICT DO NOTHING`); const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`); assert.equal(row, 'bootcamp'); const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString(); // Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js // (Phase 8's public read-only serializer — displays the fields, never authorizes on them) // should ever mention these fields, and none of the three is an authorization/RBAC file — // confirmed by name, not content-sniffing. for (const file of src.trim().split('\n').filter(Boolean)) { assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`); } }); // ── 2/3/4: offering creation, program association, delivery-mode behavior ──────────────── const orgId = `org-p7-${RUN_ID}`; const locId = `loc-p7-${RUN_ID}`; let offeringA, offeringB, pkgId; await test('organization and two distinct locations are created', async () => { const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P7 Test Org', type: 'library' }); assert.equal(org.status, 201); const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'P7 Room', type: 'community_center', organizationId: orgId, accessNotes: 'internal only' }); assert.equal(loc.status, 201); }); await test('a curriculum package references existing ROOT courses without duplicating content', async () => { pkgId = `pkg-p7-${RUN_ID}`; const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'P7 Winter Package', programId: PROGRAM }); assert.equal(pkg.status, 201); const item1 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-01-computing-linux-foundations', sequence: 1 }); assert.equal(item1.status, 201); const item2 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-02-linux-administration-networking', sequence: 2 }); assert.equal(item2.status, 201); // 19/20: references existing content, no duplication — the module/activity counts under // root-01/02 must be byte-identical to what course-content validation already knows about. const activityCount = psql(`SELECT count(*) FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id WHERE cm.course_id IN ('root-01-computing-linux-foundations','root-02-linux-administration-networking')`); const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`); assert.equal(get.status, 200); assert.equal(get.body.items.length, 2); assert.equal(get.body.items[0].course_id, 'root-01-computing-linux-foundations'); assert.ok(Number(activityCount) > 0, 'the referenced courses must still have their real activities, unduplicated'); }); await test('a non-existent course cannot be added to a curriculum package (reference, never fabricate)', async () => { const r = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-99-does-not-exist', sequence: 3 }); assert.equal(r.status, 404); assert.equal(r.body.error, 'COURSE_NOT_FOUND'); }); await test('two offerings at two different locations coexist with no state confusion', async () => { offeringA = `off-a-${RUN_ID}`; offeringB = `off-b-${RUN_ID}`; const a = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringA, slug: `p7-camp-a-${RUN_ID}`, name: 'Camp A', programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 2, locationId: locId, organizationId: orgId }); assert.equal(a.status, 201); const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' }); assert.equal(b.status, 201); assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default'); // Phase 8 made registration-window enforcement real (server-side, not just client hiding a // button) — a still-draft offering now correctly rejects registration attempts. offeringB // needs to be open before anything in this file registers against it. await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' }); }); // ── 5: capacity ─────────────────────────────────────────────────────────────────────────── await test('offering capacity is enforced atomically, and validation happens BEFORE seat reservation', async () => { for (const p of [{ status: 'published' }, { status: 'enrollment_open' }]) { const r = await owner.request('POST', `/api/admin/offerings/${offeringA}/transition`, p); assert.equal(r.status, 200, JSON.stringify(r.body)); } const badMinor = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true }); assert.equal(badMinor.status, 400, 'a validation failure must not consume a seat'); const check0 = await owner.request('GET', `/api/admin/offerings/${offeringA}`); assert.equal(check0.body.reserved_seats, 0); const reg1 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg One', registrantEmail: `reg1-${RUN_ID}@example.com` }); assert.equal(reg1.status, 201); const reg2 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Two', registrantEmail: `reg2-${RUN_ID}@example.com` }); assert.equal(reg2.status, 201); const reg3 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Three (overflow)', registrantEmail: `reg3-${RUN_ID}@example.com` }); assert.equal(reg3.status, 409); assert.equal(reg3.body.error, 'OFFERING_FULL'); }); // ── 7/8/9: organization/location/instructor assignment ─────────────────────────────────── await test('a plain instructor is listed and assignable to an offering, reusing cohort_instructors\' role vocabulary', async () => { const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-stray-${RUN_ID}@example.com`, display_name: 'P7 Stray', role: 'instructor', password: 'P7StrayPass123!' }); const link = await owner.request('POST', `/api/admin/offerings/${offeringA}/instructors`, { instructor_id: strayCreate.instructor_id, role: 'teacher' }); assert.equal(link.status, 201); }); // ── 6/9: guardians, student-guardian relationship ──────────────────────────────────────── const guardianId = `guardian-p7-${RUN_ID}`; await test('a guardian is created and can be linked to a student', async () => { const g = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P7 Guardian', email: `guardian-${RUN_ID}@example.com` }); assert.equal(g.status, 201); }); // ── 11/12: adult learner path without a guardian; minor path without corrupting identity ── let minorRegId, adultRegId; await test('a minor registration requires a guardian; an adult registration needs none', async () => { const minorReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { guardianId, registrantDisplayName: 'P7 Minor', isMinor: true }); assert.equal(minorReg.status, 201); minorRegId = minorReg.body.registration_id; const adultReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { registrantDisplayName: 'P7 Adult', registrantEmail: `adult-${RUN_ID}@example.com` }); assert.equal(adultReg.status, 201); adultRegId = adultReg.body.registration_id; }); // ── 13/14: registration lifecycle ──────────────────────────────────────────────────────── await test('a registration moves through its real lifecycle, rejecting an invalid transition', async () => { const bad = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'enrollment_activated' }); assert.equal(bad.status, 409, 'cannot jump straight from started to enrollment_activated'); for (const status of ['submitted', 'agreements_pending']) { const r = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status }); assert.equal(r.status, 200, `${status}: ${JSON.stringify(r.body)}`); } }); // ── 15: agreement-version acceptance ───────────────────────────────────────────────────── const agreementId = `agree-p7-${RUN_ID}`; await test('an agreement definition is created and a guardian acceptance is recorded with a version snapshot', async () => { const def = await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P7 Test Agreement', bodySummary: 'Plain-language summary only.' }); assert.equal(def.status, 201); const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId }); assert.equal(accept.status, 201); assert.equal(accept.body.accepted_version, 1); const list = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreements`); assert.equal(list.body.length, 1); }); // ── 16/17/18/19: offering enrollment conversion, offering→cohort, self-paced without cohort ─ await test('a confirmed registration converts to a real enrollment (self-paced offering, no cohort)', async () => { await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' }); const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {}); assert.equal(convert.status, 201, JSON.stringify(convert.body)); assert.equal(convert.body.enrollment.program_id, PROGRAM); assert.equal(convert.body.enrollment.status, 'active'); const student = convert.body.student; assert.ok(student.login_identifier && !student.email, 'a minor with no email gets a login_identifier, never a guardian email written to students.email'); const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE student_id = '${student.student_id}'`); assert.equal(Number(cohortRow), 0, 'offeringB has no cohort_id — no cohort membership should be created'); }); // ── 21: historical package version stability ───────────────────────────────────────────── await test('bumping a curriculum package\'s version does not change its historical item list retroactively in a confusing way', async () => { psql(`UPDATE curriculum_packages SET version = version + 1 WHERE curriculum_package_id = '${pkgId}'`); const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`); assert.equal(get.body.items.length, 2, 'items must survive a version bump on the package row itself'); }); // ── 22/23: registration RBAC, guardian data RBAC ───────────────────────────────────────── await test('a plain instructor cannot view registrations or create a guardian/organization, but CAN list offerings', async () => { const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-rbac-${RUN_ID}@example.com`, display_name: 'P7 RBAC', role: 'instructor', password: 'P7RbacPass123!' }); const stray = makeSession(GW); const login = await stray.request('POST', '/api/auth/instructor/login', { email: `p7-rbac-${RUN_ID}@example.com`, password: 'P7RbacPass123!' }); assert.equal(login.status, 200); const regs = await stray.request('GET', `/api/admin/offerings/${offeringB}/registrations`); assert.equal(regs.status, 403); const guardianCreate = await stray.request('POST', '/api/admin/guardians', { guardianId: `g-rbac-${RUN_ID}`, displayName: 'Should Fail' }); assert.equal(guardianCreate.status, 403); const orgCreate = await stray.request('POST', '/api/admin/organizations', { organizationId: `org-rbac-${RUN_ID}`, name: 'Should Fail' }); assert.equal(orgCreate.status, 403); const listOfferings = await stray.request('GET', '/api/admin/offerings'); assert.equal(listOfferings.status, 200, 'listing offerings themselves is not sensitive'); const listLocations = await stray.request('GET', '/api/admin/locations'); assert.equal(listLocations.status, 200); assert.ok(listLocations.body.every((l) => !('access_notes' in l)), 'a plain instructor must never receive access_notes'); }); // ── 27: no age-based authorization lock anywhere ───────────────────────────────────────── await test('an offering with a recommended age range still accepts a registrant of any age (guidance only)', async () => { const agedOffering = `off-aged-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 }); assert.equal(create.status, 201); await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' }); const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` }); assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked'); }); // ── 28/29/30: existing ROOT V1 / self-paced / classroom flows still work ───────────────── await test('an existing ROOT_ V1 self-paced enrollment (unrelated to any offering) still works end to end', async () => { const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P7-RootV1-${RUN_ID}`, login_identifier: `p7-rootv1-${RUN_ID}` }); const s = makeSession(GW); await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' }); const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' }); const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`); assert.equal(r.status, 200); const { body } = await s.request('GET', `/api/student/programs/${enroll.enrollment_id}/courses`); assert.ok(body.courses.some((c) => c.course_id === 'root-01-computing-linux-foundations')); }); await test('an existing classroom (cohort) flow still works unaffected', async () => { const cohortId = `p7-cohort-check-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P7 Cohort Check', tier: 'root' }); assert.equal(create.status, 201); const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P7 Check Session' }); assert.equal(session.status, 201); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error(e); process.exit(1); });