Files
ced4568andClaude Sonnet 5 aa63d09fb8 test(phase8): add enrollment operations coverage and Winter Break registration rehearsal
- e2e-phase8.js (16/16): offering CRUD/transitions/readiness, registration open/closed
  enforcement, capacity + concurrent final-seat race through the real registration
  endpoints, the public serializer's safety, website integration idempotency (sequential +
  concurrent), adult/minor paths, required-agreement conversion blocking with version
  snapshotting, offering->cohort membership, guardian recipient resolution, RBAC, and
  confirmation ROOT_ V1/self-paced/classroom flows are unaffected.
- test-phase8-winter-camp-registration.js (12/12): the full commercial-path story with no
  payment code — website submits a real minor registration (real guardian created/matched),
  guardian agreement acceptance, a simulated admin-side payment confirmation, conversion to
  real enrollment/cohort, real account activation, an independent adult path, two students
  genuinely competing for the final seat, and a REAL Lab task completed by the minor
  student with the instructor then seeing the real progress.
- e2e-phase7.js: fixed two now-stale test assumptions the new server-authoritative
  registration-window enforcement broke (an offering left in draft that Phase 7's own test
  never transitioned before registering against it) and widened a file-allowlist check for
  the new, legitimate public-offerings-routes.js.

Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32, e2e-phase5c.js 29/29, all fresh-stack.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:32 -05:00

249 lines
19 KiB
JavaScript

#!/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); });