import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { db } from '../src/db/knex.js'; import { formatSummary, importDoctors, parseDoctorCsv, validateDoctors, } from '../src/services/doctorImport.js'; import { truncateTables } from './truncate.js'; const HEADER = 'Sr NO,TASKFORCE,ZBM Name,RBM Name,ABM Name,HQ,TM ID,TM Name,HCP NAME,LEGACY_CODE,PCODE,SPECIALITY,CLASS,VENDORCODE,VENDORNAME,Email ID,Contact No'; /** One CSV data row in the field team's format. */ function line(srNo, name, email, contact = '9769283034') { return `${srNo},Respiratory 2,Shaikh Mohseen Ahmed,Satish Dhonde,Adityakumar Tiwari,mumbai western,014262,Salim Khan,${name},CIP833167,011667437,Chest Physician,A+,100064617,${name},${email},${contact}`; } /** Parse and validate CSV text with the header row added. */ async function check(lines, header = HEADER) { return validateDoctors(await parseDoctorCsv(Readable.from([[header, ...lines].join('\n')]))); } describe('doctor CSV validation', () => { it('maps every column, ignores Sr NO and lowercases the email', async () => { const result = await check([line(1, 'Sandeep S Tilve', ' Tilve@Gmail.com ')]); expect(result).toMatchObject({ missing: [], unexpected: [], invalid: [], duplicates: [] }); expect(result.records[0]).toEqual({ taskforce: 'Respiratory 2', zbm_name: 'Shaikh Mohseen Ahmed', rbm_name: 'Satish Dhonde', abm_name: 'Adityakumar Tiwari', hq: 'mumbai western', tm_id: '014262', tm_name: 'Salim Khan', hcp_name: 'Sandeep S Tilve', legacy_code: 'CIP833167', pcode: '011667437', speciality: 'Chest Physician', class: 'A+', vendor_code: '100064617', vendor_name: 'Sandeep S Tilve', email: 'tilve@gmail.com', contact_no: '9769283034', }); }); it('reports missing and unexpected headers', async () => { const result = await check(['1,A,a@x.com,y'], 'Sr NO,HCP NAME,Email,Extra'); expect(result.missing).toContain('Email ID'); expect(result.unexpected).toEqual(['Email', 'Extra']); }); it('rejects missing and badly formatted emails and short rows', async () => { const result = await check([ line(1, 'Dr A', 'a@x.com'), line(2, 'Dr B', 'b@x'), line(3, 'Dr C', ''), '4,short', ]); expect(result.records.map((record) => record.email)).toEqual(['a@x.com']); expect(result.invalid).toEqual([ { row: 2, name: 'Dr B', email: 'b@x', reason: '"b@x" is not a valid email' }, { row: 3, name: 'Dr C', email: '', reason: 'email is missing' }, { row: 4, name: '', email: '', reason: 'wrong number of columns' }, ]); }); it('groups duplicate emails regardless of case', async () => { const result = await check([ line(1, 'Dr A', 'a@x.com'), line(2, 'Dr B', 'b@x.com'), line(3, 'Dr A2', 'A@X.com'), ]); expect(result.duplicates).toEqual([ { email: 'a@x.com', rows: [ { row: 1, name: 'Dr A' }, { row: 3, name: 'Dr A2' }, ], }, ]); }); it('only accepts a plain .csv file name', async () => { await expect(importDoctors(undefined)).rejects.toThrow(/Pass the CSV file name/); await expect(importDoctors('../secrets.csv')).rejects.toThrow(/not a path/); await expect(importDoctors('doctors.txt')).rejects.toThrow(/not a path/); await expect(importDoctors('missing-file.csv')).rejects.toThrow(/File not found/); }); }); describe('doctor CSV import', () => { let directory; beforeEach(async () => { directory = await mkdtemp(path.join(tmpdir(), 'doctor-import-')); }); afterEach(async () => { await rm(directory, { recursive: true, force: true }); await truncateTables(); }); /** Write a CSV into the temp import folder and return its name. */ async function write(name, lines) { await writeFile(path.join(directory, name), [HEADER, ...lines].join('\n')); return name; } /** Rows currently in pppaimt_doctors. */ async function doctorCount() { return Number((await db('pppaimt_doctors').count({ total: '*' }).first())?.total); } it('inserts new doctors, then updates them by email on re-import', async () => { const first = await importDoctors( await write('first.csv', [line(1, 'Dr A', 'a@x.com'), line(2, 'Dr B', 'b@x.com')]), { directory }, ); expect(first).toMatchObject({ stopped: false, rowsRead: 2, inserted: 2, updated: 0, rejected: 0 }); expect(await doctorCount()).toBe(2); const second = await importDoctors( await write('second.csv', [ line(1, 'Dr A', 'A@X.com', '9000000000'), line(3, 'Dr C', 'c@x.com'), ]), { directory }, ); expect(second).toMatchObject({ stopped: false, inserted: 1, updated: 1, rejected: 0 }); expect(await db('pppaimt_doctors').where({ email: 'a@x.com' }).first()).toMatchObject({ contact_no: '9000000000', source_file: 'second.csv', }); expect(await doctorCount()).toBe(3); const audits = await db('audit_log').where({ action: 'DOCTORS_IMPORTED' }).count({ total: '*' }).first(); expect(Number(audits?.total)).toBe(2); }); it('writes nothing when the file has duplicate or invalid emails', async () => { const summary = await importDoctors( await write('bad.csv', [ line(1, 'Dr A', 'a@x.com'), line(2, 'Dr A again', 'a@x.com'), line(3, 'Dr B', 'b@x'), line(4, 'Dr C', 'c@x.com'), ]), { directory }, ); expect(summary).toMatchObject({ stopped: true, inserted: 0, updated: 0, rejected: 3 }); expect(await doctorCount()).toBe(0); const report = formatSummary(summary); expect(report).toContain('Import stopped: bad.csv. Nothing was written.'); expect(report).toContain('a@x.com row 1 (Dr A), row 2 (Dr A again)'); expect(report).toContain('row 3 (Dr B): "b@x" is not a valid email'); }); });