geocrop-platform./apps/nextgen/client/e2e/analytics.spec.ts

202 lines
8.6 KiB
TypeScript

/**
* Analytics dashboard (Phase 2) — smoke + happy-path coverage.
*
* Exercises the /reports surface end-to-end as the school_admin role:
* 1. Page mounts and renders the 5 KPI tiles
* 2. All 5 tabs are visible (Overview / Academics / Cohorts / Finance / Staff)
* 3. Time-range filter actually refetches (the data changes when
* switching between 7d and 6m)
* 4. Cohort drilldown opens a modal with the student roster
* 5. CSV export downloads a non-empty file (one of: overview / academics /
* cohorts / finance)
*
* Reuses the auth helpers (API-login + localStorage injection) so the
* spec runs fast and isn't coupled to the Login form. Skips gracefully
* if the demo seed is missing the admin user.
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { loginAs, isRoleAvailable, apiLogin, DEMO_ACCOUNTS } from './helpers/auth';
import { captureConsoleErrors, expectNoConsoleErrors, expectPageMounted } from './helpers/assertions';
const BASE = 'http://localhost:3000';
const API = 'http://localhost:3001';
test.describe('Analytics dashboard — school_admin happy path @flow', () => {
test.skip(!isRoleAvailable('school_admin'), 'school_admin demo user not seeded');
test.beforeEach(async ({ page }) => {
await loginAs(page, 'school_admin');
});
test('page mounts and renders 5 KPI tiles + filter bar', async ({ page }) => {
const cap = captureConsoleErrors(page);
try {
await page.goto(`${BASE}/reports`);
await expectPageMounted(page, '/reports', ['School Analytics', 'Phase 2']);
// Filter bar visible
await expect(page.getByLabel(/time range/i)).toBeVisible();
await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible();
await expect(page.getByRole('button', { name: /^export$/i })).toBeVisible();
// 5 KPI tile labels per the plan doc layout
await expect(page.getByText(/enrollments/i).first()).toBeVisible();
await expect(page.getByText(/attendance 30d/i).first()).toBeVisible();
await expect(page.getByText(/avg mark/i).first()).toBeVisible();
await expect(page.getByText(/pass rate/i).first()).toBeVisible();
} finally {
cap.dispose();
expectNoConsoleErrors(cap.errors);
}
});
test('all 5 tabs render and switch correctly', async ({ page }) => {
const cap = captureConsoleErrors(page);
try {
await page.goto(`${BASE}/reports`);
// All 5 tab labels visible (school_admin sees Finance + Staff)
await expect(page.getByRole('button', { name: /executive overview/i })).toBeVisible();
await expect(page.getByRole('button', { name: /academic/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /cohort/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /finance/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /staff/i }).first()).toBeVisible();
// Switch to Finance
await page.getByRole('button', { name: /^finance ledger$/i }).click();
await expect(page.getByText(/financial ledger|institutional financial ledger/i).first()).toBeVisible();
// Switch to Staff
await page.getByRole('button', { name: /^staff operations$/i }).click();
await expect(page.getByText(/HR & Staff Operations|leave summary/i).first()).toBeVisible();
// Switch to Cohorts
await page.getByRole('button', { name: /^cohort performance$/i }).click();
await expect(page.getByText(/cohorts pass rates|student cohorts analytics/i).first()).toBeVisible();
} finally {
cap.dispose();
expectNoConsoleErrors(cap.errors);
}
});
test('time range filter refetches when changed', async ({ page }) => {
const cap = captureConsoleErrors(page);
try {
await page.goto(`${BASE}/reports`);
// Wait for the initial timeseries load to settle before triggering a
// range change, otherwise the test races the page's loadAll() call.
const initial = page.waitForResponse(
(r) => r.url().includes('/api/reports/timeseries') && r.status() === 200,
{ timeout: 10000 },
);
await initial;
const rangeSelect = page.getByLabel(/time range/i);
await expect(rangeSelect).toBeVisible();
const tsResponse = page.waitForResponse(
(r) => r.url().includes('/api/reports/timeseries') && r.status() === 200,
{ timeout: 5000 },
);
await rangeSelect.selectOption('7d');
const r = await tsResponse;
const body = await r.json();
expect(body).toHaveProperty('range', '7d');
expect(Array.isArray(body.series)).toBe(true);
} finally {
cap.dispose();
expectNoConsoleErrors(cap.errors);
}
});
test('cohort drilldown opens a modal with the roster', async ({ page }) => {
const cap = captureConsoleErrors(page);
try {
await page.goto(`${BASE}/reports`);
// Switch to Cohorts tab first.
await page.getByRole('button', { name: /^cohort performance$/i }).click();
// If the seed has no cohorts, the "no cohorts yet" empty state shows;
// skip cleanly in that case.
const hasCohorts = await page.getByRole('button', { name: /view roster/i }).first().isVisible().catch(() => false);
test.skip(!hasCohorts, 'No cohorts in demo seed — drilldown assertion skipped');
const rosterResponse = page.waitForResponse(
(r) => /\/api\/cohorts\/\d+/.test(r.url()) && r.status() === 200,
{ timeout: 5000 },
);
await page.getByRole('button', { name: /view roster/i }).first().click();
const r = await rosterResponse;
const body = await r.json();
// The endpoint returns { ...cohort, students, classes, exam_groups }
expect(body).toHaveProperty('students');
expect(Array.isArray(body.students)).toBe(true);
// Modal title should be visible
await expect(page.getByText(/roster$/i).first()).toBeVisible();
// Close via Esc
await page.keyboard.press('Escape');
} finally {
cap.dispose();
expectNoConsoleErrors(cap.errors);
}
});
test('CSV export downloads a non-empty file', async ({ page }) => {
const cap = captureConsoleErrors(page);
try {
await page.goto(`${BASE}/reports`);
const downloadPromise = page.waitForEvent('download', { timeout: 8000 });
// Open the export dropdown and pick Overview.
await page.getByRole('button', { name: /^export$/i }).click();
await page.getByRole('button', { name: /overview csv/i }).click();
const download = await downloadPromise;
// Filename pattern: overview_export_<iso>.csv
expect(download.suggestedFilename()).toMatch(/^overview_export_.+\.csv$/);
// Read the file and verify it's non-empty.
const path = await download.path();
const fs = await import('node:fs');
const content = fs.readFileSync(path!, 'utf8');
expect(content.length).toBeGreaterThan(0);
// Should at minimum contain a header row.
expect(content.split('\n')[0]).toContain(',');
} finally {
cap.dispose();
expectNoConsoleErrors(cap.errors);
}
});
});
test.describe('Analytics dashboard — RBAC role gating @rbac', () => {
test('bursar sees Finance tab but no Staff tab', async ({ page }) => {
test.skip(!isRoleAvailable('bursar'), 'bursar demo user not seeded');
await loginAs(page, 'bursar');
await page.goto(`${BASE}/reports`);
// Finance visible
await expect(page.getByRole('button', { name: /^finance ledger$/i })).toBeVisible();
// Staff hidden
await expect(page.getByRole('button', { name: /^staff operations$/i })).toHaveCount(0);
});
test('hr sees Staff tab but no Finance tab', async ({ page }) => {
test.skip(!isRoleAvailable('hr'), 'hr demo user not seeded');
await loginAs(page, 'hr');
await page.goto(`${BASE}/reports`);
await expect(page.getByRole('button', { name: /^staff operations$/i })).toBeVisible();
await expect(page.getByRole('button', { name: /^finance ledger$/i })).toHaveCount(0);
});
test('teacher is denied 403 on /reports', async () => {
const { token } = await apiLogin('teacher');
const ctx = await pwRequest.newContext({ baseURL: API });
const reportsRes = await ctx.get('/api/reports/kpis', {
headers: { Authorization: `Bearer ${token}` },
});
// adminOrTeacher allows teacher → should be 200
expect(reportsRes.status()).toBe(200);
// Finance / staff fields are NOT in the response for teacher.
const body = await reportsRes.json();
expect(body).toHaveProperty('enrollments');
expect(body).not.toHaveProperty('finance');
expect(body).not.toHaveProperty('staff');
await ctx.dispose();
});
});