317 lines
13 KiB
JavaScript
317 lines
13 KiB
JavaScript
// 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 */ }
|
|
});
|
|
});
|