223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
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<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}`).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);
|
|
});
|
|
});
|