// Tests for the Legacy Import Controller (FR-SMS3). // // Validates the three core routes: // POST /api/legacy-import/preview // POST /api/legacy-import/commit // POST /api/legacy-import/error-csv // GET /api/legacy-import/mappings // // We seed two CSV files into the uploads dir and exercise the full // preview -> commit cycle, including the rollback-on-error path. const { pointAtDevDb } = require('./setup'); const path = require('path'); const fs = require('fs'); pointAtDevDb(); const request = require('supertest'); const app = require('../src/index'); const TMP = path.join(__dirname, '__tmp_legacy_imports'); if (!fs.existsSync(TMP)) fs.mkdirSync(TMP, { recursive: true }); const writeCsv = (name, body) => { const p = path.join(TMP, name); fs.writeFileSync(p, body, 'utf8'); return p; }; describe('Legacy Import Controller (FR-SMS3)', () => { let adminToken; let studentToken; let createdStudentEmails = []; // for cleanup 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; }); // ==================== Preview ==================== describe('POST /api/legacy-import/preview', () => { it('auto-detects column mapping from a well-formed CSV', async () => { const csv = [ 'First Name,Last Name,Email,Phone,Class,Section,Date of Birth,Gender', 'TestOne,LegacyOne,testone.legacy1@example.test,+263111111,Form 1,A,2010-01-01,Male', 'TestTwo,LegacyTwo,testtwo.legacy2@example.test,+263222222,Form 2,B,2011-02-02,Female', ].join('\n'); const p = writeCsv('preview-happy.csv', csv); const res = await request(app) .post('/api/legacy-import/preview') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p); expect(res.status).toBe(200); expect(res.body.total_rows).toBe(2); expect(res.body.valid_rows).toBe(2); expect(res.body.invalid_rows).toBe(0); expect(res.body.detected_headers).toEqual(expect.arrayContaining([ 'First Name', 'Last Name', 'Email', 'Phone', 'Class', 'Section', 'Date of Birth', 'Gender', ])); // Auto-detected mapping should pick up our aliases expect(res.body.detected_mapping['First Name']).toBe('first_name'); expect(res.body.detected_mapping['Last Name']).toBe('last_name'); expect(res.body.detected_mapping['Email']).toBe('email'); expect(res.body.detected_mapping['Class']).toBe('class_name'); expect(res.body.detected_mapping['Date of Birth']).toBe('date_of_birth'); expect(res.body.detected_mapping['Gender']).toBe('gender'); }); it('returns validation errors for missing required fields', async () => { const csv = [ 'First Name,Last Name,Email', ',MissingLast,only.first@example.test', ].join('\n'); const p = writeCsv('preview-missing.csv', csv); const res = await request(app) .post('/api/legacy-import/preview') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p); expect(res.status).toBe(200); expect(res.body.total_rows).toBe(1); expect(res.body.invalid_rows).toBe(1); // Row 2 has first_name='' (empty), last_name='MissingLast', email valid. // So only `missing first_name` is in the error list. expect(res.body.preview_rows[0].errors).toEqual(['missing first_name']); }); it('returns 403 for a student (operations admin only)', async () => { // The role gate fires before multer touches the multipart body, so // a real file isn't needed here — the test is about RBAC, not // upload handling. Sending a file triggers an ECONNRESET on the // supertest side because the server closes the connection mid // chunked upload, which is a transport quirk, not a logic issue. const res = await request(app) .post('/api/legacy-import/preview') .set('Authorization', `Bearer ${studentToken}`) .send({}); expect(res.status).toBe(403); }); it('returns 401 without a token', async () => { // Send a plain JSON body (no file) — the auth middleware should // short-circuit before multer touches the request, so we don't // need to send a multipart payload. const res = await request(app) .post('/api/legacy-import/preview') .set('Content-Type', 'application/json') .send({}); expect(res.status).toBe(401); }); it('rejects a non-CSV file extension with a 400', async () => { const p = path.join(TMP, 'preview-bad.txt'); fs.writeFileSync(p, 'hello', 'utf8'); const res = await request(app) .post('/api/legacy-import/preview') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p); // handleUpload wrapper converts the multer fileFilter error to 400 expect(res.status).toBe(400); expect(res.body.error).toMatch(/csv/i); }); }); // ==================== Commit ==================== describe('POST /api/legacy-import/commit', () => { it('creates students, classes, enrollments, and parent links (rollback-on-error default)', async () => { // Unique suffix per test run so we don't collide with the same email across re-runs. const stamp = Date.now(); const email1 = `legacy.commit.${stamp}.a@example.test`; const email2 = `legacy.commit.${stamp}.b@example.test`; const parentEmail = `legacy.parent.${stamp}@example.test`; // Seed a parent user in the DB so the legacy import can link to them // (the legacy controller only LINKS existing parents — it does not // create parent users from parent_email). const Database = require('better-sqlite3'); const db = new Database(process.env.DB_PATH); const parentUid = `legacy-parent-${stamp}`; const bcrypt = require('bcryptjs'); const hash = bcrypt.hashSync('parent-test-pw', 4); db.prepare(` INSERT INTO users (uid, email, role, first_name, last_name, is_active, password, sync_status) VALUES (?, ?, 'parent', 'Parent', 'Legacy', 1, ?, 'pending') `).run(parentUid, parentEmail, hash); db.close(); createdStudentEmails.push(email1, email2, parentEmail); const csv = [ 'first_name,last_name,email,phone,class_name,section,date_of_birth,gender,parent_email,parent_name', `LegacyA,CommitA,${email1},+263777000001,Form 9A-${stamp},A,2010-05-15,Male,${parentEmail},Parent Legacy`, `LegacyB,CommitB,${email2},+263777000002,Form 9B-${stamp},B,2011-06-16,Female,${parentEmail},Parent Legacy`, ].join('\n'); const p = writeCsv('commit-happy.csv', csv); const res = await request(app) .post('/api/legacy-import/commit') .set('Authorization', `Bearer ${adminToken}`) .field('mapping_name', `e2e-test-mapping-${stamp}`) .field('academic_year', '2026') .field('term', 'Term 1') .attach('file', p); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.created_users).toBe(2); // 2 students (parent is seeded, not created here) expect(res.body.created_enrollments).toBe(2); expect(res.body.linked_parents).toBe(2); expect(res.body.created_classes).toBe(2); expect(res.body.skipped_duplicates).toBe(0); expect(res.body.errors).toEqual([]); }); it('deduplicates on re-import (same email)', async () => { const stamp = Date.now(); const email = `legacy.dedup.${stamp}@example.test`; createdStudentEmails.push(email); const csv = [ 'first_name,last_name,email,class_name', `Dedup,User,${email},Form 9A`, ].join('\n'); // First commit: creates the student const p1 = writeCsv('commit-dedup-1.csv', csv); const r1 = await request(app) .post('/api/legacy-import/commit') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p1); expect(r1.status).toBe(200); expect(r1.body.created_users).toBe(1); // Second commit with the same data: dedup hit const p2 = writeCsv('commit-dedup-2.csv', csv); const r2 = await request(app) .post('/api/legacy-import/commit') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p2); expect(r2.status).toBe(200); expect(r2.body.created_users).toBe(0); expect(r2.body.skipped_duplicates).toBe(1); }); it('rolls back the entire batch when a single row fails (default)', async () => { const stamp = Date.now(); const email = `legacy.rollback.${stamp}@example.test`; createdStudentEmails.push(email); const csv = [ 'first_name,last_name,email', `Good,Row,${email}`, ',MissingLast,will.fail@example.test', // missing first_name AND last_name ].join('\n'); const p = writeCsv('commit-rollback.csv', csv); const res = await request(app) .post('/api/legacy-import/commit') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p); expect(res.status).toBe(409); expect(res.body.error).toMatch(/rolled back/i); expect(res.body.partial_results.errors.length).toBeGreaterThan(0); // The good row should NOT have been created (tx rolled back) const Database = require('better-sqlite3'); const db = new Database(process.env.DB_PATH); const found = db.prepare('SELECT id FROM users WHERE email = ? AND is_deleted = 0').get(email); db.close(); expect(found).toBeUndefined(); }); it('returns 400 when the CSV has no auto-detectable columns', async () => { // All column names are unknown to the auto-detector — no mapping // can be inferred and the caller did not pass an explicit one. const csv = 'foo,bar,baz\n1,2,3'; const p = writeCsv('commit-no-mapping.csv', csv); const res = await request(app) .post('/api/legacy-import/commit') .set('Authorization', `Bearer ${adminToken}`) .attach('file', p); expect(res.status).toBe(400); expect(res.body.error).toMatch(/mapping/i); expect(res.body.detected_headers).toEqual(['foo', 'bar', 'baz']); }); }); // ==================== Error CSV ==================== describe('POST /api/legacy-import/error-csv', () => { it('returns a CSV with row_number + errors', async () => { const res = await request(app) .post('/api/legacy-import/error-csv') .set('Authorization', `Bearer ${adminToken}`) .send({ errors: [ { row_number: 2, errors: ['missing first_name', 'invalid email'] }, { row_number: 5, errors: ['missing last_name'] }, ], }); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/csv/); expect(res.headers['content-disposition']).toMatch(/legacy-import-errors\.csv/); const body = res.text; expect(body).toMatch(/^.{0,3}row_number,errors/); // BOM is OK expect(body).toMatch(/2,"missing first_name; invalid email"/); expect(body).toMatch(/5,"missing last_name"/); }); }); // ==================== Mappings ==================== describe('Legacy mappings CRUD', () => { it('lists, gets, and deletes a saved mapping', async () => { const list = await request(app) .get('/api/legacy-import/mappings') .set('Authorization', `Bearer ${adminToken}`); expect(list.status).toBe(200); expect(Array.isArray(list.body.mappings)).toBe(true); if (list.body.mappings.length > 0) { const uid = list.body.mappings[0].uid; const get = await request(app) .get(`/api/legacy-import/mappings/${uid}`) .set('Authorization', `Bearer ${adminToken}`); expect(get.status).toBe(200); expect(get.body.uid).toBe(uid); expect(get.body.mapping).toBeDefined(); const del = await request(app) .delete(`/api/legacy-import/mappings/${uid}`) .set('Authorization', `Bearer ${adminToken}`); expect(del.status).toBe(200); expect(del.body.success).toBe(true); const get2 = await request(app) .get(`/api/legacy-import/mappings/${uid}`) .set('Authorization', `Bearer ${adminToken}`); expect(get2.status).toBe(404); } }); }); afterAll(() => { // Clean up the temp files; the inserted students are soft-stale // (sync_status = 'pending') and will be picked up by the next // Supabase sync if it's ever configured. For now we leave them // in the dev DB — the test data is identifiable by the email // pattern `legacy.*@example.test`. try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) { /* ignore */ } }); });