// Tests for the Phase 1 wiring routes added to fees.controller.js and // transfers.controller.js. Validates FR-SMS4 (sub-paths) and FR-XFER2 // (graduation export). const { pointAtDevDb } = require('./setup'); pointAtDevDb(); const request = require('supertest'); const app = require('../src/index'); describe('SRS Phase 1 wiring — FR-SMS4 sub-paths + FR-XFER2 graduation', () => { let adminToken; let studentToken; let parentToken; beforeAll(async () => { const r1 = await request(app).post('/api/auth/login').send({ email: 'admin@school.com', password: 'admin123' }); adminToken = r1.body?.token; const r2 = await request(app).post('/api/auth/login').send({ email: 'student@school.com', password: 'student123' }); studentToken = r2.body?.token; const r3 = await request(app).post('/api/auth/login').send({ email: 'parent@school.com', password: 'parent123' }); parentToken = r3.body?.token; }); // ==================== FR-SMS4: /api/fees/structure ==================== describe('GET /api/fees/structure', () => { it('returns 200 with the full fee structure for an admin', async () => { const res = await request(app) .get('/api/fees/structure') .set('Authorization', `Bearer ${adminToken}`); expect(res.status).toBe(200); expect(res.body).toHaveProperty('academic_year'); expect(res.body).toHaveProperty('fee_groups'); expect(res.body).toHaveProperty('fee_plans'); expect(res.body).toHaveProperty('active_discounts'); expect(res.body).toHaveProperty('outstanding_by_class'); expect(Array.isArray(res.body.fee_groups)).toBe(true); expect(Array.isArray(res.body.fee_plans)).toBe(true); expect(Array.isArray(res.body.active_discounts)).toBe(true); expect(Array.isArray(res.body.outstanding_by_class)).toBe(true); }); it('returns 200 for a student (read-only meta is public-ish)', async () => { const res = await request(app) .get('/api/fees/structure') .set('Authorization', `Bearer ${studentToken}`); expect(res.status).toBe(200); }); it('returns 401 without a token', async () => { const res = await request(app).get('/api/fees/structure'); expect(res.status).toBe(401); }); it('honours the academic_year query param', async () => { const res = await request(app) .get('/api/fees/structure?academic_year=2025') .set('Authorization', `Bearer ${adminToken}`); expect(res.status).toBe(200); expect(res.body.academic_year).toBe('2025'); }); }); // ==================== FR-SMS4: /api/fees/invoices ==================== describe('GET /api/fees/invoices', () => { it('returns 200 with an array (admin)', async () => { const res = await request(app) .get('/api/fees/invoices') .set('Authorization', `Bearer ${adminToken}`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('returns 403 for a student (finance roles only)', async () => { const res = await request(app) .get('/api/fees/invoices') .set('Authorization', `Bearer ${studentToken}`); expect(res.status).toBe(403); }); it('returns 401 without a token', async () => { const res = await request(app).get('/api/fees/invoices'); expect(res.status).toBe(401); }); }); // ==================== FR-XFER2: /api/xfer/graduation ==================== describe('POST /api/xfer/graduation', () => { it('returns 200 with a complete graduation package for admin', async () => { const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4, target_institution: 'University of Zimbabwe' }); expect(res.status).toBe(200); expect(res.body.package_type).toBe('graduation_export'); expect(res.body.schema_version).toBe('1.0'); expect(res.body.student.first_name).toBeTruthy(); expect(res.body).toHaveProperty('academic_history'); expect(res.body).toHaveProperty('attendance_summary'); expect(res.body).toHaveProperty('disciplinary_record'); expect(res.body).toHaveProperty('financial_clearance'); expect(res.body).toHaveProperty('signature'); expect(res.body.signature.signature_digest).toMatch(/^[a-f0-9]{64}$/); }); it('returns 400 when target_institution is missing', async () => { const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4 }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/target_institution/); }); it('returns 400 when both student_id and student_uid are missing', async () => { const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ target_institution: 'X' }); expect(res.status).toBe(400); }); it('returns 404 when the student does not exist', async () => { const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 99999, target_institution: 'X' }); expect(res.status).toBe(404); }); it('returns 403 when a student tries to read another student', async () => { // student@school.com resolves to id 4 in the dev seed; pick a // different student (id 9 = a generated 'Test User' student from // the e2e seed) to exercise the cross-student RBAC check. const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${studentToken}`) .send({ student_id: 9, target_institution: 'X' }); expect(res.status).toBe(403); }); it('accepts a consent_id and gates on consent_status', async () => { // First, create a consent row const Database = require('better-sqlite3'); const db = new Database(process.env.DB_PATH); db.pragma('foreign_keys = ON'); // Ensure table exists (the controller creates it on first hit) const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='transfer_consents'").get(); if (!exists) { // Hit the controller first to create the table await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4, target_institution: 'X' }); db.close(); const db2 = new Database(process.env.DB_PATH); const { v4: uuidv4 } = require('uuid'); const consentUid = uuidv4(); db2.prepare(` INSERT INTO transfer_consents (uid, student_id, requested_by, target_school, consent_status, sync_status) VALUES (?, 4, 'audit@test', 'University of Zimbabwe', 'pending', 'pending') `).run(consentUid); db2.close(); // Now try to export with that pending consent — must be 409 const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4, target_institution: 'University of Zimbabwe', consent_id: consentUid }); expect(res.status).toBe(409); expect(res.body.consent_status).toBe('pending'); } else { // Table already exists; insert a pending consent directly const { v4: uuidv4 } = require('uuid'); const consentUid = uuidv4(); db.prepare(` INSERT INTO transfer_consents (uid, student_id, requested_by, target_school, consent_status, sync_status) VALUES (?, 4, 'audit@test', 'University of Zimbabwe', 'pending', 'pending') `).run(consentUid); db.close(); const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4, target_institution: 'University of Zimbabwe', consent_id: consentUid }); expect(res.status).toBe(409); expect(res.body.consent_status).toBe('pending'); } }); it('passes when consent is approved (signature includes consent metadata)', async () => { const Database = require('better-sqlite3'); const db = new Database(process.env.DB_PATH); const { v4: uuidv4 } = require('uuid'); const consentUid = uuidv4(); db.prepare(` INSERT INTO transfer_consents (uid, student_id, requested_by, target_school, consent_status, signed_at, signature_hash, sync_status) VALUES (?, 4, 'audit@test', 'University of Zimbabwe', 'approved', datetime('now','localtime'), 'abc123', 'pending') `).run(consentUid); db.close(); const res = await request(app) .post('/api/xfer/graduation') .set('Authorization', `Bearer ${adminToken}`) .send({ student_id: 4, target_institution: 'University of Zimbabwe', consent_id: consentUid }); expect(res.status).toBe(200); expect(res.body.consent).toBeTruthy(); expect(res.body.consent.consent_id).toBe(consentUid); expect(res.body.consent.consent_status).toBe('approved'); }); }); });