172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|