/** * Exam-review workflow e2e (added 2026-07-21). * * Walks the full review pipeline through the HTTP layer: * * 1. Teacher creates a draft exam group (POST /api/exams/groups) * 2. Teacher submits it for review (POST /api/exams/groups/:id/submit) * 3. Admin sees it in the pending queue (GET /api/exams/groups/pending) * 4. Admin rejects with a reason (POST /api/exams/groups/:id/reject) * 5. Teacher sees the rejected status + reason on the group * 6. Teacher resubmits (POST /api/exams/groups/:id/submit) * 7. Admin creates a draft schedule (POST /api/exams/schedules) * 8. Admin publishes (flips is_active + approves) (POST /api/exams/schedules/:id/publish) * 9. Group is now approved * * Plus RBAC checks at each gate: * - student gets 403 on admin endpoints * - teacher gets 403 on admin endpoints * - anonymous gets 401 on everything * * Uses request contexts directly so the test is fast (no browser * round-trip) and asserts the exact HTTP contract. */ import { test, expect, request as pwRequest } from '@playwright/test'; const API = 'http://localhost:3001'; const USERS = { admin: { email: 'admin@school.com', password: 'admin123' }, teacher: { email: 'teacher@school.com', password: 'teacher123' }, student: { email: 'student@school.com', password: 'student123' }, }; async function login(role) { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.post('/api/auth/login', { data: USERS[role] }); if (res.status() !== 200) { await ctx.dispose(); throw new Error(`login failed for ${role}: ${res.status()} ${await res.text()}`); } const body = await res.json(); return { ctx, token: body.token, user: body.user }; } async function get(token, path) { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.get(path, { headers: { Authorization: `Bearer ${token}` } }); const text = await res.text(); await ctx.dispose(); return { status: res.status(), body: safeParse(text) }; } async function post(token, path, data) { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.post(path, { headers: { Authorization: `Bearer ${token}` }, data: data || {}, }); const text = await res.text(); await ctx.dispose(); return { status: res.status(), body: safeParse(text) }; } function safeParse(text) { try { return JSON.parse(text); } catch { return text; } } test.describe('Exam review workflow — happy path @flow', () => { test('teacher creates draft → submits → admin rejects → teacher resubmits → admin publishes', async () => { const teacher = await login('teacher'); const admin = await login('admin'); // 1. Teacher creates a draft. const created = await post(teacher.token, '/api/exams/groups', { name: 'E2E Review Flow ' + Date.now(), description: 'e2e generated', exam_type: 'test', duration_minutes: 30, total_marks: 50, passing_marks: 25, }); expect(created.status).toBe(201); expect(created.body.status).toBe('draft'); const groupId = created.body.id; // 2. Teacher submits. const submitted = await post(teacher.token, `/api/exams/groups/${groupId}/submit`); expect(submitted.status).toBe(200); expect(submitted.body.status).toBe('pending_review'); // 3. Admin sees it in the queue. const queue = await get(admin.token, '/api/exams/groups/pending'); expect(queue.status).toBe(200); expect(Array.isArray(queue.body)).toBe(true); const inQueue = queue.body.find(g => g.id === groupId); expect(inQueue).toBeTruthy(); expect(inQueue.status).toBe('pending_review'); // 4. Admin rejects with a reason. const rejected = await post(admin.token, `/api/exams/groups/${groupId}/reject`, { reason: 'Needs more questions', }); expect(rejected.status).toBe(200); expect(rejected.body.status).toBe('rejected'); expect(rejected.body.rejection_reason).toBe('Needs more questions'); expect(rejected.body.reviewed_by).toBe(admin.user.id); expect(rejected.body.reviewed_at).toBeTruthy(); // 5. After reject, the group should be GONE from the pending queue. const queueAfterReject = await get(admin.token, '/api/exams/groups/pending'); expect(queueAfterReject.status).toBe(200); expect(queueAfterReject.body.find(g => g.id === groupId)).toBeFalsy(); // 6. Teacher can resubmit a rejected group. const resubmitted = await post(teacher.token, `/api/exams/groups/${groupId}/submit`); expect(resubmitted.status).toBe(200); expect(resubmitted.body.status).toBe('pending_review'); expect(resubmitted.body.rejection_reason).toBeNull(); // 7. Admin creates a draft schedule. // (We don't strictly need a real class_id/subject_id here — the // schedules table doesn't enforce FK on those, just on exam_group_id // and the explicit values. We use the first class/subject from the // list endpoint to satisfy any schema constraints; if the demo seed // is empty, we just call it and assert the response.) const sched = await post(admin.token, '/api/exams/schedules', { exam_group_id: groupId, class_id: 1, subject_id: 1, start_time: '2027-01-01T09:00:00.000Z', end_time: '2027-01-01T10:00:00.000Z', duration_minutes: 60, }); expect(sched.status).toBe(201); expect(sched.body.is_active).toBe(0); const scheduleId = sched.body.id; // 8. Admin publishes the schedule. const published = await post(admin.token, `/api/exams/schedules/${scheduleId}/publish`); expect(published.status).toBe(200); expect(published.body.is_active).toBe(1); // 9. The parent group should now be approved. const finalGroup = await get(admin.token, `/api/exams/groups/${groupId}`); expect(finalGroup.status).toBe(200); expect(finalGroup.body.status).toBe('approved'); expect(finalGroup.body.reviewed_by).toBe(admin.user.id); // 10. Republishing should be a no-op (409). const republish = await post(admin.token, `/api/exams/schedules/${scheduleId}/publish`); expect(republish.status).toBe(409); // Cleanup: delete the test data so re-runs don't pile up. await post(admin.token, `/api/exams/groups/${groupId}/reject`, { reason: 'e2e cleanup' }) .catch(() => { /* ignore — group is already approved, will leave it */ }); }); }); test.describe('Exam review workflow — error paths @errors', () => { test('reject without a reason returns 400', async () => { const admin = await login('admin'); const res = await post(admin.token, '/api/exams/groups/1/reject', {}); expect([400, 404]).toContain(res.status); // 400 if exists, 404 if id is gone }); test('submit on an already-approved group returns 409', async () => { // Find an approved group via direct API. If none, skip. const admin = await login('admin'); const list = await get(admin.token, '/api/exams/groups'); if (list.status !== 200 || !Array.isArray(list.body)) { test.skip(true, 'could not list groups'); return; } const approved = list.body.find(g => g.status === 'approved'); if (!approved) { test.skip(true, 'no approved group available to test 409'); return; } const res = await post(admin.token, `/api/exams/groups/${approved.id}/submit`); expect(res.status).toBe(409); }); }); test.describe('Exam review workflow — RBAC @rbac', () => { test('student is forbidden on every admin endpoint', async () => { const student = await login('student'); const checks = [ { method: 'get', path: '/api/exams/groups/pending' }, { method: 'post', path: '/api/exams/groups/1/reject', data: { reason: 'x' } }, { method: 'post', path: '/api/exams/schedules/1/publish' }, ]; for (const c of checks) { const r = c.method === 'get' ? await get(student.token, c.path) : await post(student.token, c.path, c.data); expect(r.status, `${c.method.toUpperCase()} ${c.path} should be 403, got ${r.status}`).toBe(403); } }); test('teacher is forbidden on admin-only endpoints (publish, reject, list-pending)', async () => { const teacher = await login('teacher'); const checks = [ { method: 'get', path: '/api/exams/groups/pending' }, { method: 'post', path: '/api/exams/groups/1/reject', data: { reason: 'x' } }, { method: 'post', path: '/api/exams/schedules/1/publish' }, ]; for (const c of checks) { const r = c.method === 'get' ? await get(teacher.token, c.path) : await post(teacher.token, c.path, c.data); expect(r.status, `${c.method.toUpperCase()} ${c.path} should be 403, got ${r.status}`).toBe(403); } }); test('anonymous (no token) gets 401 on the new endpoints', async () => { const checks = [ { method: 'get', path: '/api/exams/groups/pending' }, { method: 'post', path: '/api/exams/groups/1/submit' }, { method: 'post', path: '/api/exams/groups/1/reject', data: { reason: 'x' } }, { method: 'post', path: '/api/exams/schedules/1/publish' }, ]; for (const c of checks) { const r = c.method === 'get' ? await get(null, c.path) : await post(null, c.path, c.data); expect(r.status, `${c.method.toUpperCase()} ${c.path} should be 401, got ${r.status}`).toBe(401); } }); });