268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|