chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user