geocrop-platform./apps/nextgen/server/tests/legacy-import.test.js

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 */ }
});
});