/** * Admin portal smoke + happy-path coverage. * * Covers `school_admin` (operational admin — the role with the largest * surface). The `systems_admin` block is included but skipped when the * demo seed doesn't include that user — `sysadmin@school.com` is * promised by Login.tsx's demoLogin() map but is not in * server/src/database/seeds/demo.js today. Fix the seed to un-skip. * * The smoke group walks every route the role can reach (per the App.tsx * route map). The happy-path group exercises one real interaction per * major module so a feature that renders but is dead on arrival still * shows up red. */ import { test, expect } from '@playwright/test'; import { loginAs, isRoleAvailable } from '../helpers/auth'; import { ROUTES_PER_ROLE, ROLE_LANDING } from '../helpers/nav'; import { captureConsoleErrors, expectNoConsoleErrors, expectPageMounted, } from '../helpers/assertions'; const BASE = 'http://localhost:3000'; test.describe('Admin portal — school_admin smoke @smoke', () => { test.beforeEach(async ({ page }) => { await loginAs(page, 'school_admin'); }); for (const route of ROUTES_PER_ROLE.school_admin) { test(`mounts ${route}`, async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}${route}`); await expectPageMounted(page, route, [route.split('/').pop() || route, 'Africa Alert']); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); } }); test.describe('Admin portal — systems_admin smoke @smoke', () => { test.beforeAll(async () => { const ok = await isRoleAvailable('systems_admin'); test.skip(!ok, 'systems_admin demo account not in seed — see server/src/database/seeds/demo.js'); }); test.beforeEach(async ({ page }) => { await loginAs(page, 'systems_admin'); }); for (const route of ROUTES_PER_ROLE.systems_admin) { test(`mounts ${route}`, async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}${route}`); await expectPageMounted(page, route, [route.split('/').pop() || route, 'Africa Alert']); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); } }); test.describe('Admin portal — happy path @flow', () => { test.beforeEach(async ({ page }) => { await loginAs(page, 'school_admin'); }); test('lands on the school admin dashboard after login', async ({ page }) => { await page.goto(`${BASE}/`); await expect(page).toHaveURL(new RegExp(`${ROLE_LANDING.school_admin.replace(/\//g, '\\/')}$`)); }); test('front-office: visitor log page is interactive', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/front-office/visitors`); await expect(page.locator('h1, h2').first()).toBeVisible(); // The add button or the empty state should be reachable. Use a // generous locator — the label can be "Add", "New", or "Log visitor". const addOrEmpty = page .getByRole('button', { name: /add|new|log visitor/i }) .or(page.getByText(/no visitors|empty/i)); await expect(addOrEmpty.first()).toBeVisible({ timeout: 5_000 }); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); test('finance: expenses list page renders', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/finance/expenses`); await expect(page.locator('h1, h2').first()).toBeVisible(); // The page either has a table of expenses or an empty state — both prove // the route mounted and the API call returned. const tableOrEmpty = page .locator('table') .or(page.getByText(/no expenses|no records|empty/i)); await expect(tableOrEmpty.first()).toBeVisible({ timeout: 5_000 }); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); test('marks: bulk-marks page renders the upload surface', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/marks/bulk`); await expect(page.locator('h1, h2').first()).toBeVisible(); // Should have either a file input or a CSV/paste input. const fileInput = page.locator('input[type="file"]'); await expect(fileInput.first()).toBeAttached({ timeout: 5_000 }); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); test('notice board: admin can see the post surface', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/notice-board`); // Admin can post — look for a button, textarea, or a known label. const postSurface = page .getByRole('button', { name: /post|create|new|add/i }) .or(page.locator('textarea')) .or(page.getByText(/notice|announcement/i)); await expect(postSurface.first()).toBeVisible({ timeout: 5_000 }); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); test('library: catalog page is reachable', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/library`); await expect(page.locator('h1, h2').first()).toBeVisible(); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); // Exam-review workflow (added 2026-07-21). The page either shows // the queue table or the "queue clear" empty state — both prove the // /api/exams/groups/pending endpoint works and the page mounted. test('exams: review queue page renders', async ({ page }) => { const cap = captureConsoleErrors(page); try { await page.goto(`${BASE}/exams/review`); await expectPageMounted(page, '/exams/review', ['Exam Review', 'Queue']); const queueOrEmpty = page .locator('table') .or(page.getByText(/queue clear|no.*awaiting/i)); await expect(queueOrEmpty.first()).toBeVisible({ timeout: 5_000 }); } finally { cap.dispose(); expectNoConsoleErrors(cap.errors); } }); });