201 lines
8.6 KiB
TypeScript
201 lines
8.6 KiB
TypeScript
/**
|
|
* E2E for Phase 1 PR 1 — auxiliary roles (thin slice).
|
|
*
|
|
* Covers the cover-teacher happy path end-to-end through the real
|
|
* /api/* endpoints:
|
|
*
|
|
* 1. Admin grants a teacher a class cover for class X with expires_at = +1 day
|
|
* 2. The teacher's /api/auth/refresh-roles now reports 'teacher' in effective_roles
|
|
* 3. The teacher can mark attendance for class X (previously forbidden)
|
|
* 4. The teacher cannot mark attendance for class Y (no cover there)
|
|
* 5. Admin revokes the cover
|
|
* 6. The teacher can no longer mark attendance for class X
|
|
*
|
|
* Plus: existing /api/attendance/admin-or-teacher check still works for
|
|
* admins and form-tutors. (Regression guard — no existing route was
|
|
* supposed to break.)
|
|
*
|
|
* Prerequisites:
|
|
* cd server && npm run dev (port 3001)
|
|
* cd client && npm run dev (port 3000)
|
|
* cd client && npx playwright install chromium (one-off)
|
|
* cd client && npm run test:e2e -- --grep user-roles
|
|
*
|
|
* Seed requirement: at least one teacher user, at least TWO classes
|
|
* (so test 4 can find a class the cover teacher is NOT form tutor of).
|
|
* If only one class exists, test 4 skips.
|
|
*/
|
|
|
|
import { test, expect, request } from '@playwright/test';
|
|
|
|
const API = 'http://localhost:3001/api';
|
|
|
|
interface Ctx {
|
|
token: string;
|
|
get: (path: string, opts?: any) => Promise<any>;
|
|
post: (path: string, opts?: any) => Promise<any>;
|
|
put: (path: string, opts?: any) => Promise<any>;
|
|
delete: (path: string, opts?: any) => Promise<any>;
|
|
dispose: () => Promise<void>;
|
|
}
|
|
|
|
async function login(email: string, password: string): Promise<Ctx> {
|
|
const ctx = await request.newContext();
|
|
const res = await ctx.post(`${API}/auth/login`, { data: { email, password } });
|
|
expect(res.ok(), `login failed for ${email}: ${await res.text()}`).toBeTruthy();
|
|
const body = await res.json();
|
|
const token = body.token as string;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
return {
|
|
token,
|
|
get: (path, opts = {}) => ctx.get(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }),
|
|
post: (path, opts = {}) => ctx.post(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }),
|
|
put: (path, opts = {}) => ctx.put(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }),
|
|
delete: (path, opts = {}) => ctx.delete(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }),
|
|
dispose: () => ctx.dispose(),
|
|
};
|
|
}
|
|
|
|
// Shared state across the describe's tests. Playwright re-initializes
|
|
// `let` bindings per test, so we use a plain object that all tests
|
|
// reference by identity.
|
|
const state: { admin?: Ctx; teacher?: Ctx; teacherId?: number; classId?: number; grantId: number } = { grantId: 0 };
|
|
|
|
test.describe('auxiliary roles + class cover (Phase 1 PR 1)', () => {
|
|
test.beforeAll(async () => {
|
|
state.admin = await login('admin@school.com', 'admin123');
|
|
|
|
const usersRes = await state.admin.get('/users?role=teacher&limit=5');
|
|
expect(usersRes.ok(), `users fetch failed: ${await usersRes.text()}`).toBeTruthy();
|
|
const usersBody = await usersRes.json();
|
|
const users = (usersBody.users || usersBody).slice().sort((a: any, b: any) => a.id - b.id);
|
|
expect(users.length, 'no teachers in seed').toBeGreaterThan(0);
|
|
state.teacherId = users[0].id;
|
|
|
|
const classesRes = await state.admin.get('/classes?limit=5');
|
|
expect(classesRes.ok()).toBeTruthy();
|
|
const classes = await classesRes.json();
|
|
expect(classes.length, 'no classes in seed').toBeGreaterThan(0);
|
|
state.classId = classes[0].id;
|
|
|
|
state.teacher = await login(users[0].email, 'teacher123');
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (state.grantId) {
|
|
// Best-effort cleanup; ignore if already revoked
|
|
try {
|
|
await state.admin!.delete(`/user-roles/${state.grantId}`, { data: { revoke_reason: 'e2e cleanup' } });
|
|
} catch { /* ignore */ }
|
|
}
|
|
await state.admin?.dispose();
|
|
await state.teacher?.dispose();
|
|
});
|
|
|
|
test('1. admin can grant a class cover to a teacher', async () => {
|
|
const expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
const res = await state.admin!.post(`/users/${state.teacherId}/roles`, {
|
|
data: {
|
|
role: 'teacher',
|
|
scope_class_id: state.classId,
|
|
starts_at: new Date().toISOString(),
|
|
expires_at,
|
|
reason: 'e2e test cover',
|
|
},
|
|
});
|
|
expect(res.ok(), `grant failed: ${await res.text()}`).toBeTruthy();
|
|
const row = await res.json();
|
|
expect(row.role).toBe('teacher');
|
|
expect(row.scope_class_id).toBe(state.classId);
|
|
state.grantId = row.id;
|
|
});
|
|
|
|
test('2. teacher refresh-roles sees the cover in effective_roles', async () => {
|
|
const res = await state.teacher!.get('/auth/refresh-roles');
|
|
expect(res.ok()).toBeTruthy();
|
|
const body = await res.json();
|
|
expect(body.token).toBeTruthy();
|
|
expect(body.user.role).toBe('teacher');
|
|
expect(body.user.effective_roles).toContain('teacher');
|
|
});
|
|
|
|
test('3. teacher CAN mark attendance for the covered class', async () => {
|
|
const studentsRes = await state.admin!.get('/users?role=student&limit=5');
|
|
const studentsBody = await studentsRes.json();
|
|
const students = studentsBody.users || [];
|
|
if (students.length === 0) {
|
|
test.skip(true, 'no students in seed');
|
|
return;
|
|
}
|
|
const studentId = students[0].id;
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const res = await state.teacher!.post('/attendance', {
|
|
data: { student_id: studentId, class_id: state.classId, date: today, status: 'present' },
|
|
});
|
|
expect(res.ok(), `mark failed (${res.status()}): ${await res.text()}`).toBeTruthy();
|
|
});
|
|
|
|
test('4. teacher CANNOT mark attendance for a class without a cover (and where they are not form tutor)', async () => {
|
|
// Find a second class that the covering teacher is NOT form tutor of.
|
|
// If the only teacher is the form tutor of all classes, this test
|
|
// skips — the cover-denial case is logically the same as admin-denies-
|
|
// own-class which we already test in test 7.
|
|
const classesRes = await state.admin!.get('/classes?limit=100');
|
|
const classes = await classesRes.json();
|
|
const otherClass = classes.find((c: any) => c.id !== state.classId && c.class_teacher_id !== state.teacherId);
|
|
if (!otherClass) {
|
|
test.skip(true, 'no second class with a different form tutor in seed');
|
|
return;
|
|
}
|
|
const studentsRes = await state.admin!.get('/users?role=student&limit=5');
|
|
const studentsBody = await studentsRes.json();
|
|
const students = studentsBody.users || [];
|
|
if (students.length === 0) {
|
|
test.skip(true, 'no students in seed');
|
|
return;
|
|
}
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const res = await state.teacher!.post('/attendance', {
|
|
data: { student_id: students[0].id, class_id: otherClass.id, date: today, status: 'present' },
|
|
});
|
|
expect(res.status(), `expected 403, got ${res.status()}`).toBe(403);
|
|
});
|
|
|
|
test('5. admin can revoke the cover', async () => {
|
|
expect(state.grantId, 'test 1 should have set grantId').toBeGreaterThan(0);
|
|
const res = await state.admin!.delete(`/user-roles/${state.grantId}`, {
|
|
data: { revoke_reason: 'e2e test cleanup' },
|
|
});
|
|
expect(res.ok(), `revoke failed: ${await res.text()}`).toBeTruthy();
|
|
});
|
|
|
|
test('6. revoked cover is reflected in the grants list', async () => {
|
|
const res = await state.admin!.get(`/users/${state.teacherId}/roles`);
|
|
expect(res.ok()).toBeTruthy();
|
|
const body = await res.json();
|
|
const revoked = body.grants.find((g: any) => g.reason === 'e2e test cover' && g.id === state.grantId && g.revoked_at);
|
|
expect(revoked, 'expected to find the revoked e2e cover grant').toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('regression — existing /api/attendance behavior unchanged for admins', () => {
|
|
test('admin can still mark attendance for any class', async () => {
|
|
const admin = await login('admin@school.com', 'admin123');
|
|
const classesRes = await admin.get('/classes?limit=5');
|
|
const classes = await classesRes.json();
|
|
const studentsRes = await admin.get('/users?role=student&limit=5');
|
|
const studentsBody = await studentsRes.json();
|
|
const students = studentsBody.users || [];
|
|
if (classes.length === 0 || students.length === 0) {
|
|
test.skip(true, 'no classes or students in seed');
|
|
return;
|
|
}
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const res = await admin.post('/attendance', {
|
|
data: { student_id: students[0].id, class_id: classes[0].id, date: today, status: 'present' },
|
|
});
|
|
expect(res.ok(), `admin mark failed (${res.status()}): ${await res.text()}`).toBeTruthy();
|
|
await admin.dispose();
|
|
});
|
|
});
|