const { pointAtDevDb } = require('./setup'); const Database = require('better-sqlite3'); const { v4: uuidv4 } = require('uuid'); pointAtDevDb(); const dbPath = process.env.DB_PATH; const getDb = () => { const db = new Database(dbPath); db.pragma('foreign_keys = ON'); return db; }; // Import the service const OffboardingService = require('../src/services/OffboardingService'); describe('OffboardingService pipeline', () => { let db; beforeAll(() => { db = getDb(); }); afterAll(() => { db.close(); }); // Helper to create a user const createUser = (role, email = `test-${uuidv4()}@school.com`) => { const uid = uuidv4(); const result = db.prepare(` INSERT INTO users (uid, email, password, role, first_name, last_name, is_active) VALUES (?, ?, 'hash', ?, 'Test', 'User', 1) `).run(uid, email, role); return { id: result.lastInsertRowid, uid, email }; }; it('student offboarding happy path (no fees, no hostel, no transport)', () => { const student = createUser('student'); const admin = createUser('school_admin'); const result = OffboardingService.offboardStudent(student.id, { reason: 'graduated', effectiveDate: '2026-07-22', reasonNotes: 'Happy path test' }, admin.id); expect(result.record).toBeDefined(); expect(result.record.user_id).toBe(student.id); expect(result.record.reason).toBe('graduated'); expect(result.record.alumni_status).toBe('active_alumni'); expect(result.record.status).toBe('completed'); expect(result.actions.length).toBe(11); // Check all actions are completed result.actions.forEach(action => { expect(action.status).toBe('completed'); }); // Verify user is inactive and archived const updatedUser = db.prepare('SELECT is_active, archive_status FROM users WHERE id = ?').get(student.id); expect(updatedUser.is_active).toBe(0); expect(updatedUser.archive_status).toBe('active_alumni'); // Verify audit log exists const audit = db.prepare("SELECT * FROM audit_logs WHERE action = 'STUDENT_OFFBOARD' AND entity_id = ?").get(student.uid); expect(audit).toBeDefined(); }); it('student offboarding blocked by outstanding fees without override', () => { const student = createUser('student'); const admin = createUser('school_admin'); // Add a fee group and student fee const feeGroupUid = uuidv4(); const groupResult = db.prepare("INSERT INTO fee_groups (uid, name, amount) VALUES (?, 'Test Tuition', 100)").run(feeGroupUid); db.prepare(` INSERT INTO student_fees (uid, student_id, fee_group_id, amount, paid_amount, status) VALUES (?, ?, ?, 100, 0, 'pending') `).run(uuidv4(), student.id, groupResult.lastInsertRowid); // Call without override expect(() => { OffboardingService.offboardStudent(student.id, { reason: 'withdrawn', effectiveDate: '2026-07-22' }, admin.id); }).toThrow(/outstanding fees/i); // Verify student is still active const user = db.prepare('SELECT is_active, archive_status FROM users WHERE id = ?').get(student.id); expect(user.is_active).toBe(1); expect(user.archive_status).toBeNull(); }); it('student offboarding succeeds with outstanding fees when overridden', () => { const student = createUser('student'); const admin = createUser('school_admin'); // Add fee const feeGroupUid = uuidv4(); const groupResult = db.prepare("INSERT INTO fee_groups (uid, name, amount) VALUES (?, 'Test Tuition 2', 200)").run(feeGroupUid); db.prepare(` INSERT INTO student_fees (uid, student_id, fee_group_id, amount, paid_amount, status) VALUES (?, ?, ?, 200, 0, 'pending') `).run(uuidv4(), student.id, groupResult.lastInsertRowid); // Call with override const result = OffboardingService.offboardStudent(student.id, { reason: 'withdrawn', effectiveDate: '2026-07-22', override: { reason: 'Financial aid transition' } }, admin.id); expect(result.record.status).toBe('completed'); // Check fee action notes const feeAction = result.actions.find(a => a.step === 'fee_settled'); expect(feeAction.status).toBe('completed'); expect(feeAction.notes).toContain('Financial aid transition'); // Verify audit log has the override log const overrideAudit = db.prepare("SELECT * FROM audit_logs WHERE action = 'FEE_OVERRIDE' AND entity_id = ?").get(student.uid); expect(overrideAudit).toBeDefined(); }); it('staff offboarding happy path (no leave, no payroll, no assignments)', () => { const teacher = createUser('teacher'); const admin = createUser('school_admin'); const result = OffboardingService.offboardStaff(teacher.id, { reason: 'resigned', effectiveDate: '2026-07-22', reasonNotes: 'Resignation accepted' }, admin.id); expect(result.record).toBeDefined(); expect(result.record.user_id).toBe(teacher.id); expect(result.record.reason).toBe('resigned'); expect(result.record.status).toBe('completed'); expect(result.actions.length).toBe(11); result.actions.forEach(action => { expect(action.status).toBe('completed'); }); const updatedUser = db.prepare('SELECT is_active, archive_status FROM users WHERE id = ?').get(teacher.id); expect(updatedUser.is_active).toBe(0); expect(updatedUser.archive_status).toBe('former_staff'); }); it('staff offboarding blocked by unpaid payroll without override', () => { const teacher = createUser('teacher'); const admin = createUser('school_admin'); // Add payroll run and payslip const runResult = db.prepare(` INSERT INTO payroll_runs (uid, period_month, period_year, status) VALUES (?, 7, 2026, 'approved') `).run(uuidv4()); db.prepare(` INSERT INTO payslips (uid, staff_id, payroll_run_id, basic_salary, gross_salary, net_salary, status) VALUES (?, ?, ?, 2000, 2000, 1800, 'approved') `).run(uuidv4(), teacher.id, runResult.lastInsertRowid); // Call without override expect(() => { OffboardingService.offboardStaff(teacher.id, { reason: 'terminated', effectiveDate: '2026-07-22' }, admin.id); }).toThrow(/outstanding payroll/i); }); it('staff offboarding clears classes and subjects', () => { const teacher = createUser('teacher'); const admin = createUser('school_admin'); // Create a class and subject assigned to this teacher const classResult = db.prepare(` INSERT INTO classes (uid, name, class_teacher_id) VALUES (?, 'Grade 4A', ?) `).run(uuidv4(), teacher.id); const subjectResult = db.prepare(` INSERT INTO subjects (uid, name, class_id, teacher_id) VALUES (?, 'Maths', ?, ?) `).run(uuidv4(), classResult.lastInsertRowid, teacher.id); // Run offboarding OffboardingService.offboardStaff(teacher.id, { reason: 'retired', effectiveDate: '2026-07-22' }, admin.id); // Verify assignments are cleared const updatedClass = db.prepare('SELECT class_teacher_id FROM classes WHERE id = ?').get(classResult.lastInsertRowid); expect(updatedClass.class_teacher_id).toBeNull(); const updatedSubject = db.prepare('SELECT teacher_id FROM subjects WHERE id = ?').get(subjectResult.lastInsertRowid); expect(updatedSubject.teacher_id).toBeNull(); }); it('pipeline transaction integrity (rollback on intermediate step failure)', () => { const student = createUser('student'); const admin = createUser('school_admin'); // We will cause a constraint violation or database error during deactivation // by passing an invalid student ID to a mock step. Wait, let's just make the user_id invalid or // pass a non-existent student ID. Let's see: if we pass a userId that does not exist to a private query, // or let's mock a database throw. Actually, we can trigger a check constraint failure. // For example, in offboardStudent, if we pass an invalid reason that fails the CHECK constraint on offboarding_records: // reason must be graduated, transferred, withdrawn, expelled, resigned, terminated, retired, deceased, other. // If we pass 'invalid_reason_string', the INSERT INTO offboarding_records will fail. // Wait, let's see if the student remains active! // Actually, we do select check: // If we pass an invalid reason, the transaction throws on insert and rolls back. But let's verify if a mid-pipeline failure rolls back. // For example, let's make Step 2 or 10 throw by setting a row constraint. // Or we can just test that the student status does NOT change if we pass an invalid reason. // Wait, if reason is invalid, it throws at offboarding_records insert. Since it's the very first step, the rest won't execute anyway. // How about throwing in the middle, say, after updating enrollments? // Let's create a situation where a unique constraint fails on insertion of an action or something. // Wait! In `enrollment_closed` step, we run `UPDATE enrollments SET status = ? WHERE id = ?`. // If we pass `payload.enrollmentStatus = 'VERY_LONG_INVALID_STATUS_THAT_FAILS_CHECK_CONSTRAINT'`, the check constraint on enrollments.status will fail! // Let's verify enrollments.status check constraint: // status CHECK(status IN ('active', 'inactive', 'transferred', 'graduated')) // If we pass `payload.enrollmentStatus = 'invalid_status_value'`, it will throw a constraint error during the update of enrollments! // Since it updates enrollments before deactivating user, if it throws, the transaction rolls back, and the student should remain active! // Let's verify this! // First create a class and an enrollment for this student const classResult = db.prepare(` INSERT INTO classes (uid, name) VALUES (?, 'Grade Test Rollback') `).run(uuidv4()); const enrollResult = db.prepare(` INSERT INTO enrollments (uid, student_id, class_id, status) VALUES (?, ?, ?, 'active') `).run(uuidv4(), student.id, classResult.lastInsertRowid); // Call offboarding with invalid enrollment status expect(() => { OffboardingService.offboardStudent(student.id, { reason: 'other', enrollmentStatus: 'super_invalid_status_value', // Fails CHECK constraint on enrollments.status effectiveDate: '2026-07-22' }, admin.id); }).toThrow(); // Verify student is still active (is_active is 1) const user = db.prepare('SELECT is_active, archive_status FROM users WHERE id = ?').get(student.id); expect(user.is_active).toBe(1); expect(user.archive_status).toBeNull(); // Verify enrollment remains 'active' const enroll = db.prepare('SELECT status FROM enrollments WHERE id = ?').get(enrollResult.lastInsertRowid); expect(enroll.status).toBe('active'); // Verify no offboarding record was created const record = db.prepare('SELECT count(*) as count FROM offboarding_records WHERE user_id = ?').get(student.id); expect(record.count).toBe(0); }); });