exportExcel's CSV/XLS write streams had no 'error' listener, so a write failure (disk full, export directory becomes inaccessible mid-run — both realistic for a large CNPJ export) surfaced as an uncaught exception that crashed the whole export-job.mjs child process, bypassing every await/try-catch in the caller. The exports row was then never marked 'error' and stayed stuck, and a half-written file could sit at the final path (no temp+rename, unlike the same pattern already used in rfb-extractor.mjs/rfb-downloader.mjs). Streams now track their error via a listener instead of throwing, writes and stream-end are rejected through the normal promise chain so export-job.mjs's existing catch/finally handles it, and both files are written to .tmp paths and only renamed to their final names once both streams close successfully. Found via a full-project audit.
143 lines
6.2 KiB
JavaScript
143 lines
6.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
|
import { exportExcel } from '../src/export/excel.mjs';
|
|
import { registerExportRoutes } from '../src/server/routes/exports.mjs';
|
|
import { createRouter } from '../src/server/router.mjs';
|
|
import { stripBom } from '../src/util/csv.mjs';
|
|
|
|
function seedLead(db) {
|
|
createLeadInserter(db)({
|
|
lead_uid: 'P-1', dedup_key: 'p-1', empresa: 'PREMIUM EXPRESS', legal_name: '',
|
|
whatsapp: '557193335451', ddd: '71', email: '', cidade: 'Salvador', uf: 'BA',
|
|
segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '57557263000113',
|
|
cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base', telefone_valido: 1,
|
|
cnpj_valido: 1, empresa_valida: 1, score: 100, classificacao: 'bom', motivo_classificacao: '',
|
|
capture_run_id: null, captured_at: null, exported_at: null
|
|
});
|
|
}
|
|
|
|
test('exportExcel usa nome interno e neutraliza formulas no CSV/XLS', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-'));
|
|
const escapedPath = path.resolve(dir, '../../escape.csv');
|
|
try {
|
|
await fs.rm(escapedPath, { force: true });
|
|
const ids = [];
|
|
db.prepare("UPDATE leads SET empresa = '=HYPERLINK(\"https://evil\")' WHERE id = 1").run();
|
|
const result = await exportExcel({ db, filtro: {}, dir, baseName: '../../escape', collectIds: ids });
|
|
assert.deepEqual(result.files, ['leads-excel.csv', 'leads-excel.xls']);
|
|
const csv = stripBom(await fs.readFile(path.join(dir, 'leads-excel.csv'), 'utf8'));
|
|
const xls = await fs.readFile(path.join(dir, 'leads-excel.xls'), 'utf8');
|
|
assert.match(csv, /'=HYPERLINK/);
|
|
assert.match(xls, /'=HYPERLINK/);
|
|
await assert.rejects(fs.stat(escapedPath));
|
|
assert.doesNotMatch(csv, /(^|;)=HYPERLINK/m);
|
|
assert.deepEqual(ids, [1]);
|
|
} finally {
|
|
await fs.rm(escapedPath, { force: true });
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
// Antes, um erro no stream de escrita (disco cheio, pasta inacessivel) virava
|
|
// excecao nao capturada e derrubava o processo do job inteiro, sem nunca
|
|
// chegar ao catch de export-job.mjs; e o arquivo final podia ficar pela
|
|
// metade no lugar do arquivo definitivo. Forca EISDIR criando um diretorio
|
|
// no caminho .tmp esperado do CSV, no lugar de um disco cheio de verdade.
|
|
test('exportExcel rejeita (nao derruba o processo) quando o stream falha, e nao deixa arquivo pela metade', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-falha-'));
|
|
try {
|
|
await fs.mkdir(path.join(dir, 'leads-excel.csv.tmp'));
|
|
await assert.rejects(() => exportExcel({ db, filtro: {}, dir }));
|
|
assert.equal(await fs.stat(path.join(dir, 'leads-excel.csv')).then(() => true, () => false), false);
|
|
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls')).then(() => true, () => false), false);
|
|
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls.tmp')).then(() => true, () => false), false);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('rota de exportacao rejeita tipos corporativos removidos', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
const router = createRouter();
|
|
registerExportRoutes(router, { db, jobs: { startJob: () => 'job-1' } });
|
|
const calls = [];
|
|
await router.match('POST', '/api/exports').handler(null, null, {
|
|
body: { tipo: 'zpro-oportunidades' },
|
|
json: (status, data) => calls.push({ status, data })
|
|
});
|
|
assert.equal(calls[0].status, 400);
|
|
assert.match(calls[0].data.erro, /excel/);
|
|
db.close();
|
|
});
|
|
|
|
test('rota de exportacao persiste somente marcarExportado nos params', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
const router = createRouter();
|
|
let started;
|
|
registerExportRoutes(router, { db, jobs: { startJob: (options) => { started = options; return 'job-1'; } } });
|
|
await router.match('POST', '/api/exports').handler(null, null, { body: { tipo: 'excel', params: { baseName: '../../evil', marcarExportado: false, outro: true } }, json: () => {} });
|
|
assert.deepEqual(JSON.parse(db.prepare('SELECT params_json FROM exports').get().params_json), { marcarExportado: false });
|
|
const row = db.prepare('SELECT job_id, owner_token, heartbeat_at FROM exports').get();
|
|
assert.equal(row.job_id, 'job-1');
|
|
assert.ok(row.owner_token);
|
|
assert.ok(row.heartbeat_at);
|
|
assert.equal(started.ownerToken, row.owner_token);
|
|
db.close();
|
|
});
|
|
|
|
test('rota de exportacao rejeita nova exportacao enquanto outra esta pendente', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending')").run();
|
|
const router = createRouter();
|
|
registerExportRoutes(router, { db, jobs: { startJob: () => 'job-1' } });
|
|
let response;
|
|
await router.match('POST', '/api/exports').handler(null, null, {
|
|
body: { tipo: 'excel' },
|
|
json: (status, data) => { response = { status, data }; }
|
|
});
|
|
assert.equal(response.status, 409);
|
|
assert.equal(db.prepare('SELECT COUNT(*) total FROM exports').get().total, 1);
|
|
db.close();
|
|
});
|
|
|
|
test('rota de exportacao converte corrida de constraint em 409 sem row parcial', async () => {
|
|
const db = openDb(':memory:');
|
|
seedLead(db);
|
|
const raceDb = {
|
|
prepare(sql) {
|
|
if (/INSERT INTO exports/.test(sql)) {
|
|
const error = new Error('UNIQUE constraint failed: active export');
|
|
error.errcode = 2067;
|
|
throw error;
|
|
}
|
|
return db.prepare(sql);
|
|
}
|
|
};
|
|
const router = createRouter();
|
|
let started = false;
|
|
registerExportRoutes(router, { db: raceDb, jobs: { startJob: () => { started = true; } } });
|
|
let response;
|
|
await router.match('POST', '/api/exports').handler(null, null, {
|
|
body: { tipo: 'excel' },
|
|
json: (status, data) => { response = { status, data }; }
|
|
});
|
|
assert.equal(response.status, 409);
|
|
assert.match(response.data.erro, /simultaneamente/i);
|
|
assert.equal(db.prepare('SELECT COUNT(*) total FROM exports').get().total, 0);
|
|
assert.equal(started, false);
|
|
db.close();
|
|
});
|