import { test, expect, request } from '@playwright/test'; import { v4 as uuidv4 } from 'uuid'; import Database from '../../server/node_modules/better-sqlite3/lib/index.js'; import path from 'path'; const API = 'http://localhost:3001/api'; interface Ctx { token: string; get: (path: string, opts?: any) => Promise; post: (path: string, opts?: any) => Promise; put: (path: string, opts?: any) => Promise; delete: (path: string, opts?: any) => Promise; dispose: () => Promise; } async function login(email: string, password: string): Promise { const ctx = await request.newContext(); const res = await ctx.post(`${API}/auth/login`, { data: { email, password } }); expect(res.ok(), `login failed for ${email}`).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(), }; } test.describe('Offboarding Workflows (Phase 3)', () => { let admin: Ctx; test.beforeAll(async () => { admin = await login('admin@school.com', 'admin123'); }); test.afterAll(async () => { await admin.dispose(); }); // Helper to create a user via raw SQL / API or we can just use the create user endpoint if it exists // In the admin dashboard, we can register an account by posting to /api/users const createAccount = async (role: string, email: string) => { const res = await admin.post('/users', { data: { email, password: 'password123', first_name: 'E2E', last_name: 'TestUser', role, phone: '12345678', }, }); expect(res.ok(), `Failed to create user: ${await res.text()}`).toBeTruthy(); const body = await res.json(); // In our users controller, it returns the created user object which contains the ID return body.user || body; }; test('1. student offboarding happy path (no fees)', async () => { const email = `stud-${uuidv4()}@school.com`; const student = await createAccount('student', email); // 1. Preflight checks const preRes = await admin.get(`/offboarding/preflight/${student.id}`); expect(preRes.ok()).toBeTruthy(); const preBody = await preRes.json(); expect(preBody.role).toBe('student'); expect(preBody.checks.fees).toBe(0); // 2. Run offboarding const offRes = await admin.post(`/offboarding/students/${student.id}`, { data: { reason: 'graduated', effectiveDate: '2026-07-22', reasonNotes: 'E2E graduation', }, }); expect(offRes.ok(), `Offboarding student failed: ${await offRes.text()}`).toBeTruthy(); const offBody = await offRes.json(); expect(offBody.record.status).toBe('completed'); expect(offBody.record.reason).toBe('graduated'); expect(offBody.record.alumni_status).toBe('active_alumni'); expect(offBody.actions.length).toBe(11); // Check action states are all completed offBody.actions.forEach((a: any) => { expect(a.status).toBe('completed'); }); // 3. Confirm in alumni directory const alumniRes = await admin.get(`/offboarding/alumni?search=${email}`); expect(alumniRes.ok()).toBeTruthy(); const alumniList = await alumniRes.json(); expect(alumniList.length).toBe(1); expect(alumniList[0].id).toBe(student.id); }); test('2. student offboarding blocked by fees and bypassed via override', async () => { const email = `stud-${uuidv4()}@school.com`; const student = await createAccount('student', email); // To add a fee, let's look at the database. In local SQLite, we can insert directly in the E2E test? // E2E test runs outside database context, so it interacts via HTTP. // Is there a way to assign a fee group or generate invoices? // Let's check: we can create a fee plan or write student fee. // Wait, let's see if there is an endpoint to create a student fee. // Typically, bursar or admin can run fees invoice creation. // Let's check: in `fees.controller.js`, there is `POST /api/fees` or similar? // Or maybe we can just query the database in the test using `better-sqlite3`? // Yes! In Playwright, since we run locally, we can import `better-sqlite3` and insert a record directly into `student_fees`! // This is super fast, deterministic, and doesn't rely on fee UI endpoints which might change. // Let's do that! const dbPath = process.env.DB_PATH || path.resolve('../server/data/school.db'); const db = new Database(dbPath); db.pragma('foreign_keys = ON'); // Create a fee group and student fee const feeGroupUid = uuidv4(); const groupResult = db.prepare("INSERT INTO fee_groups (uid, name, amount) VALUES (?, 'E2E Tuition', 150)").run(feeGroupUid); db.prepare(` INSERT INTO student_fees (uid, student_id, fee_group_id, amount, paid_amount, status) VALUES (?, ?, ?, 150, 0, 'pending') `).run(uuidv4(), student.id, groupResult.lastInsertRowid); db.close(); // 1. Verify preflight shows outstanding fees const preRes = await admin.get(`/offboarding/preflight/${student.id}`); expect(preRes.ok()).toBeTruthy(); const preBody = await preRes.json(); expect(preBody.checks.fees).toBe(150); // 2. Run offboarding without override -> expect 400 Bad Request const failRes = await admin.post(`/offboarding/students/${student.id}`, { data: { reason: 'withdrawn', effectiveDate: '2026-07-22', }, }); expect(failRes.status()).toBe(400); const failBody = await failRes.json(); expect(failBody.error).toContain('fees'); // 3. Run offboarding WITH override -> expect success (201) const successRes = await admin.post(`/offboarding/students/${student.id}`, { data: { reason: 'withdrawn', effectiveDate: '2026-07-22', override: { reason: 'Bypassed by principal approval', }, }, }); expect(successRes.ok(), `Override failed: ${await successRes.text()}`).toBeTruthy(); const successBody = await successRes.json(); expect(successBody.record.status).toBe('completed'); const feeAction = successBody.actions.find((a: any) => a.step === 'fee_settled'); expect(feeAction.status).toBe('completed'); expect(feeAction.notes).toContain('Bypassed by principal approval'); }); test('3. staff offboarding happy path', async () => { const email = `teach-${uuidv4()}@school.com`; const teacher = await createAccount('teacher', email); // 1. Run offboarding const offRes = await admin.post(`/offboarding/staff/${teacher.id}`, { data: { reason: 'resigned', effectiveDate: '2026-07-22', reasonNotes: 'Teacher resignation', }, }); expect(offRes.ok(), `Offboarding staff failed: ${await offRes.text()}`).toBeTruthy(); const offBody = await offRes.json(); expect(offBody.record.status).toBe('completed'); expect(offBody.record.reason).toBe('resigned'); expect(offBody.actions.length).toBe(11); // 2. Confirm in former staff directory const formerRes = await admin.get(`/offboarding/former-staff?search=${email}`); expect(formerRes.ok()).toBeTruthy(); const formerList = await formerRes.json(); expect(formerList.length).toBe(1); expect(formerList[0].id).toBe(teacher.id); }); test('4. cancel offboarding re-activates user account', async () => { const email = `stud-${uuidv4()}@school.com`; const student = await createAccount('student', email); // Offboard first const offRes = await admin.post(`/offboarding/students/${student.id}`, { data: { reason: 'withdrawn', effectiveDate: '2026-07-22', }, }); expect(offRes.ok()).toBeTruthy(); const offBody = await offRes.json(); const recordId = offBody.record.id; // Cancel offboarding const cancelRes = await admin.post(`/offboarding/records/${recordId}/cancel`); expect(cancelRes.ok(), `Cancel failed: ${await cancelRes.text()}`).toBeTruthy(); const cancelBody = await cancelRes.json(); expect(cancelBody.success).toBe(true); // Verify user record status is active again const userRes = await admin.get(`/users?search=${email}`); expect(userRes.ok()).toBeTruthy(); const userBody = await userRes.json(); const users = userBody.users || userBody; const updatedStudent = users.find((u: any) => u.id === student.id); expect(updatedStudent.is_active).toBe(1); }); });