Files
COMERCIAL-GTI/tests/export.test.mjs
T
Gustavo 3d552b5bc0 Fix export excel writer crashing the process on a stream write error
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.
2026-07-31 22:13:49 -03:00

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();
});