test(camp): add Winter Break commerce rehearsal
13/13 against a real, freshly-built stack, PAYMENT_PROVIDER=test (no live money): admin configuration including price/payment-provider readiness, minor + adult registration through real checkout and a real webhook call, duplicate-webhook idempotency, failed-payment seat retention, abandoned-checkout seat release, a last-seat checkout race, a real classroom session, and a real Lab task completed by the newly-activated minor student.
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Orchestrates the Phase 9 Step 30 Winter Break commercial rehearsal: brings up the real dev
|
||||||
|
# stack, bootstraps an owner, runs test-phase9-winter-camp-commerce.js (real checkout/webhook
|
||||||
|
# processing against the deterministic test payment provider — no live Stripe, no real money),
|
||||||
|
# tears down.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||||
|
|
||||||
|
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||||
|
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||||
|
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||||
|
export PAYMENT_PROVIDER="${PAYMENT_PROVIDER:-test}"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "==> Tearing down"
|
||||||
|
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||||
|
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||||
|
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> Waiting for the stack"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Bootstrapping the rehearsal owner"
|
||||||
|
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||||
|
node bootstrap-owner.js [email protected] "P9 Owner" 'RehearsalPass!2026' )
|
||||||
|
|
||||||
|
echo "==> Running the rehearsal"
|
||||||
|
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
|
||||||
|
node ../infra/scripts/drills/test-phase9-winter-camp-commerce.js
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Phase 9 Step 30 — the full Winter Break commercial rehearsal, WITH real checkout/webhook
|
||||||
|
// processing this time (Phase 8's rehearsal simulated payment confirmation via a plain admin
|
||||||
|
// transition; this one drives the actual commerce path end to end). Uses the deterministic
|
||||||
|
// 'test' payment provider — no live money, no real Stripe credentials anywhere in this script.
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { makeSession } = require('../../../platform/test/session-helper');
|
||||||
|
const { getPaymentProvider } = require('../../../platform/api/src/payment-provider');
|
||||||
|
|
||||||
|
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||||
|
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||||
|
const RUN_ID = Date.now();
|
||||||
|
const PROGRAM = 'root-kubernetes-foundations';
|
||||||
|
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
async function step(name, fn) {
|
||||||
|
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||||
|
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
async function integrationPost(path, body) {
|
||||||
|
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
async function webhookPost(body, signature) {
|
||||||
|
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Phase 9 Winter Break commercial rehearsal — real checkout/webhook, test provider, no live money\n');
|
||||||
|
const provider = getPaymentProvider();
|
||||||
|
assert.equal(provider.name, 'test');
|
||||||
|
|
||||||
|
const owner = makeSession(GW);
|
||||||
|
await step('owner logs in', async () => {
|
||||||
|
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
const locId = `p9-loc-${RUN_ID}`, cohortId = `p9-cohort-${RUN_ID}`, offeringId = `p9-camp-${RUN_ID}`, slug = `p9-linux-systems-${RUN_ID}`, agreementId = `p9-agreement-${RUN_ID}`;
|
||||||
|
|
||||||
|
await step('admin configures the test offering — price, capacity, location, agreement, instructor', async () => {
|
||||||
|
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
|
||||||
|
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
|
||||||
|
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||||
|
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
|
||||||
|
capacity: 2, locationId: locId, cohortId, priceAmount: 20000, priceCurrency: 'usd',
|
||||||
|
});
|
||||||
|
assert.equal(create.status, 201);
|
||||||
|
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p9-instructor-${RUN_ID}@example.com`, display_name: 'P9 Instructor', role: 'instructor', password: 'P9InstructorPass123!' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||||
|
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
|
||||||
|
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||||
|
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||||
|
// Dates/registration-window are deliberately NOT set — real business decisions this phase
|
||||||
|
// must not invent (Steps 4/28/32) — so overall readiness is honestly false. Every check
|
||||||
|
// this rehearsal DID configure must show ready; the ones nobody has decided yet must not.
|
||||||
|
const byKey = Object.fromEntries(readiness.body.checks.map((c) => [c.key, c.ready]));
|
||||||
|
for (const key of ['curriculum', 'instructor', 'location', 'capacity', 'agreements', 'price', 'payment_provider']) {
|
||||||
|
assert.equal(byKey[key], true, `expected ${key} to be ready: ${JSON.stringify(readiness.body.checks)}`);
|
||||||
|
}
|
||||||
|
assert.equal(readiness.body.ready_to_open_registration, false, 'must honestly stay not-fully-ready until real dates are set');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('admin opens registration', async () => {
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||||
|
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||||
|
assert.equal(open.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Minor registration -> agreement -> checkout -> real payment -> enrollment -> Lab ──────
|
||||||
|
let minorRegId, minorTransactionRef;
|
||||||
|
await step('a minor registers via the website, guardian accepts the agreement, checkout is created', async () => {
|
||||||
|
const submit = await integrationPost('/api/integrations/registrations', {
|
||||||
|
event_id: `p9-minor-${RUN_ID}`, offering_slug: slug,
|
||||||
|
registrant: { display_name: 'Winter Camper', is_minor: true },
|
||||||
|
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
|
||||||
|
});
|
||||||
|
assert.equal(submit.status, 201);
|
||||||
|
minorRegId = submit.body.registration_id;
|
||||||
|
const preCheckout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||||
|
assert.equal(preCheckout.status, 409, 'checkout must be blocked before the agreement is accepted');
|
||||||
|
const accept = await integrationPost(`/api/integrations/registrations/${minorRegId}/agreements`, { agreement_id: agreementId });
|
||||||
|
assert.equal(accept.status, 201);
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||||
|
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
|
||||||
|
minorTransactionRef = tx.body[0].provider_reference;
|
||||||
|
});
|
||||||
|
|
||||||
|
let minorStudent, minorSession, minorEnrollmentId, minorPaymentEvent;
|
||||||
|
await step('the test payment provider confirms payment via a REAL webhook call — registration converts, account activates', async () => {
|
||||||
|
minorPaymentEvent = provider.buildTestEvent({ providerReference: minorTransactionRef, eventType: 'checkout.session.completed' });
|
||||||
|
const webhookResult = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
|
||||||
|
assert.equal(webhookResult.status, 200);
|
||||||
|
assert.equal(webhookResult.body.result, 'processed');
|
||||||
|
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
|
||||||
|
assert.equal(reg.body.status, 'enrollment_activated');
|
||||||
|
const cohortRoster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||||
|
assert.ok(cohortRoster.body.some((s) => s.student_id === reg.body.student_id));
|
||||||
|
minorEnrollmentId = reg.body.enrollment_id;
|
||||||
|
|
||||||
|
// The real activation token was already delivered to the (dev) email provider by the
|
||||||
|
// notification worker — retrieved here via the same dev-preview mechanism e2e-phase5c.js's
|
||||||
|
// own suite already established, never a raw-token-in-API-response shortcut (that only
|
||||||
|
// exists on the admin-authored convert route, not the webhook path — see notifications.js's
|
||||||
|
// own comment on why toAdminView() never includes it).
|
||||||
|
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||||
|
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation');
|
||||||
|
assert.ok(activationNotif, `expected a real activation notification, got: ${JSON.stringify(notifs.body)}`);
|
||||||
|
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||||
|
if (!preview.body || !preview.body.text) throw new Error(`dev-preview failed: status=${preview.status} notif=${JSON.stringify(activationNotif)} body=${JSON.stringify(preview.body)}`);
|
||||||
|
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||||
|
minorStudent = { student_id: reg.body.student_id };
|
||||||
|
minorSession = makeSession(GW);
|
||||||
|
const activate = await minorSession.request('POST', '/api/student/activate', { token, password: 'WinterP9Minor123!' });
|
||||||
|
assert.equal(activate.status, 200, JSON.stringify(activate.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('the duplicate webhook (a real Stripe retry scenario) changes nothing further', async () => {
|
||||||
|
// The EXACT same event (same event id) as the prior step — a real Stripe retry redelivers
|
||||||
|
// the identical event, it never mints a new event id for "the same" delivery. Building a
|
||||||
|
// fresh test event here would defeat the point: it's calling buildTestEvent() twice that
|
||||||
|
// simulates two DIFFERENT events, not a retry of the same one.
|
||||||
|
const replay = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
|
||||||
|
assert.equal(replay.body.result, 'duplicate');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Adult registration, real payment, independent conversion ──────────────────────────────
|
||||||
|
await step('an adult registers, pays, and converts independently — no guardian involved', async () => {
|
||||||
|
const submit = await integrationPost('/api/integrations/registrations', { event_id: `p9-adult-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false } });
|
||||||
|
assert.equal(submit.status, 201);
|
||||||
|
await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/agreements`, { agreement_id: agreementId });
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
|
||||||
|
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.completed' });
|
||||||
|
await webhookPost(event.body, event.signature);
|
||||||
|
const reg = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
|
||||||
|
assert.equal(reg.body.status, 'enrollment_activated');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Capacity now exhausted (2/2) — a third registration must fail cleanly ────────────────
|
||||||
|
await step('a failed payment leaves a retriable registration without consuming a phantom extra seat', async () => {
|
||||||
|
const failSlugOffering = `p9-fail-camp-${RUN_ID}`;
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId: `off-${failSlugOffering}`, slug: failSlugOffering, name: 'Fail Camp', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'published' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'enrollment_open' });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlugOffering, registrant: { display_name: 'Fail Registrant', email: `failreg-${RUN_ID}@example.com` } });
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||||
|
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
|
||||||
|
await webhookPost(event.body, event.signature);
|
||||||
|
const offAfter = await owner.request('GET', `/api/admin/offerings/off-${failSlugOffering}`);
|
||||||
|
assert.equal(offAfter.body.reserved_seats, 1, 'the seat stays reserved so the same registrant can retry checkout');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Abandoned checkout releases the seat back to the pool ────────────────────────────────
|
||||||
|
await step('an abandoned checkout (expired session) releases the seat', async () => {
|
||||||
|
const abandonOffering = `off-p9-abandon-${RUN_ID}`, abandonSlug = `p9-abandon-${RUN_ID}`;
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId: abandonOffering, slug: abandonSlug, name: 'Abandon Test', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'published' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'enrollment_open' });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonreg-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Abandoner', email: `abandoner-${RUN_ID}@example.com` } });
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||||
|
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.expired' });
|
||||||
|
await webhookPost(event.body, event.signature);
|
||||||
|
const offAfter = await owner.request('GET', `/api/admin/offerings/${abandonOffering}`);
|
||||||
|
assert.equal(offAfter.body.reserved_seats, 0, 'an abandoned checkout must release its seat');
|
||||||
|
// Prove the released seat is genuinely usable again — a fresh registration succeeds.
|
||||||
|
const retry = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonretry-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Retry Registrant', email: `retry-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(retry.status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Last-seat race: two students competing, exactly one wins ────────────────────────────
|
||||||
|
await step('two students genuinely compete for the final seat of a paid offering — exactly one wins', async () => {
|
||||||
|
const raceOffering = `off-p9-lastseat-${RUN_ID}`, raceSlug = `p9-lastseat-${RUN_ID}`;
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId: raceOffering, slug: raceSlug, name: 'Last Seat', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'published' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'enrollment_open' });
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last A', email: `lasta-${RUN_ID}@example.com` } }),
|
||||||
|
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last B', email: `lastb-${RUN_ID}@example.com` } }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Real classroom + Lab work for the actually-enrolled minor student ────────────────────
|
||||||
|
await step('instructor starts a classroom session and Prepares Labs', async () => {
|
||||||
|
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||||
|
assert.equal(session.status, 201);
|
||||||
|
global.__classroomId = session.body.classroom_session_id;
|
||||||
|
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
|
||||||
|
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||||
|
assert.equal(prep.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
|
||||||
|
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
|
||||||
|
assert.equal(join.status, 200);
|
||||||
|
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
|
||||||
|
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
|
||||||
|
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
|
||||||
|
}
|
||||||
|
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
|
||||||
|
assert.equal(launch.status, 200);
|
||||||
|
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||||
|
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
||||||
|
assert.equal(setup.status, 200);
|
||||||
|
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||||
|
const containerName = execSync(
|
||||||
|
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
|
||||||
|
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||||
|
).toString().trim();
|
||||||
|
const fs = require('fs');
|
||||||
|
const scriptPath = `/tmp/phase9-winter-hardware-${envRow.environment_id}.sh`;
|
||||||
|
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||||
|
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||||
|
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||||
|
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||||
|
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||||
|
fs.unlinkSync(scriptPath);
|
||||||
|
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||||
|
assert.equal(validate.status, 200);
|
||||||
|
assert.equal(validate.body.passed, true);
|
||||||
|
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('instructor sees real roster, payment, and progress state for the camp', async () => {
|
||||||
|
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||||
|
assert.equal(roster.body.length, 2);
|
||||||
|
const payments = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
|
||||||
|
assert.equal(payments.body[0].status, 'paid');
|
||||||
|
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
|
||||||
|
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
|
||||||
|
assert.ok(mod.activities.some((a) => a.complete));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
Reference in New Issue
Block a user