Commit inicial: GTI Maquina Comercial

App local (Node + SQLite) para captura, tratamento e exportacao de leads
comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DESKTOP-F2O4A80\User
2026-07-12 14:22:21 -03:00
co-authored by Claude
commit b8d10b210c
78 changed files with 19781 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, verifyPassword } from '../src/auth.mjs';
import { openDb, createUser, findUserByUsername, listUsers, ensureDefaultAdmin } from '../src/db.mjs';
test('hashPassword/verifyPassword aceita a senha certa e rejeita a errada', () => {
const stored = hashPassword('minhaSenha123');
assert.ok(verifyPassword('minhaSenha123', stored));
assert.ok(!verifyPassword('senhaErrada', stored));
});
test('hashPassword gera salt diferente a cada chamada (hashes distintos p/ mesma senha)', () => {
assert.notEqual(hashPassword('abc'), hashPassword('abc'));
});
test('ensureDefaultAdmin cria admin/admin somente no primeiro acesso', () => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
const admin = findUserByUsername(db, 'admin');
assert.ok(admin);
assert.equal(admin.role, 'admin');
assert.ok(verifyPassword('admin', admin.password_hash));
createUser(db, { username: 'vendedor1', passwordHash: hashPassword('123456'), role: 'comercial' });
ensureDefaultAdmin(db); // nao deve recriar nem duplicar
assert.equal(listUsers(db).length, 2);
db.close();
});
test('createUser rejeita username duplicado (constraint UNIQUE)', () => {
const db = openDb(':memory:');
createUser(db, { username: 'joao', passwordHash: hashPassword('123456'), role: 'comercial' });
assert.throws(() => createUser(db, { username: 'joao', passwordHash: hashPassword('outra'), role: 'admin' }));
db.close();
});
+90
View File
@@ -0,0 +1,90 @@
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 { splitCsvLine, csvEscape, excelTextCell, streamCsvRows, createCsvWriter, stripBom, BOM } from '../src/util/csv.mjs';
test('splitCsvLine separa campos com separador dentro de aspas', () => {
assert.deepEqual(splitCsvLine('a;"b;c";d'), ['a', 'b;c', 'd']);
});
test('splitCsvLine trata aspas duplas escapadas', () => {
assert.deepEqual(splitCsvLine('"x""y";z'), ['x"y', 'z']);
});
test('splitCsvLine aceita separador virgula', () => {
assert.deepEqual(splitCsvLine('a,b,"c,d"', ','), ['a', 'b', 'c,d']);
});
test('csvEscape protege valores com separador, aspas e quebra de linha', () => {
assert.equal(csvEscape('sem risco'), 'sem risco');
assert.equal(csvEscape('a;b'), '"a;b"');
assert.equal(csvEscape('diz "oi"'), '"diz ""oi"""');
assert.equal(csvEscape(null), '');
});
test('excelTextCell gera formula de texto para numeros longos', () => {
assert.equal(excelTextCell('557193335451'), '"=""557193335451"""');
});
test('stripBom remove BOM apenas quando presente', () => {
assert.equal(stripBom('abc'), 'abc');
assert.equal(stripBom('abc'), 'abc');
});
test('streamCsvRows le arquivo com BOM, auto-detecta separador e normaliza headers', async () => {
const file = path.join(os.tmpdir(), `gti-test-csv-${Date.now()}.csv`);
await fs.writeFile(file, `${BOM}Nome Empresa;Telefone 1\n"ACME; Ltda";7599999\nBeta;`, 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file, {
normalizeHeader: (h) => h.toLowerCase().replace(/\s+/g, '_')
})) {
rows.push(row);
}
assert.equal(rows.length, 2);
assert.equal(rows[0].nome_empresa, 'ACME; Ltda');
assert.equal(rows[0].telefone_1, '7599999');
assert.equal(rows[1].telefone_1, '');
} finally {
await fs.rm(file, { force: true });
}
});
test('streamCsvRows suporta campo com quebra de linha dentro de aspas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvml-${Date.now()}.csv`);
await fs.writeFile(file, 'a;b\n1;"linha um\nlinha dois"\n2;simples', 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file)) rows.push(row);
assert.equal(rows.length, 2);
assert.equal(rows[0].b, 'linha um\nlinha dois');
assert.equal(rows[1].b, 'simples');
} finally {
await fs.rm(file, { force: true });
}
});
test('createCsvWriter grava BOM, header e linhas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvw-${Date.now()}.csv`);
const writer = createCsvWriter(file, ['a', 'b']);
writer.writeRow(['1', csvEscape('x;y')]);
writer.writeRow(['2', excelTextCell('123')]);
await writer.end();
try {
const content = await fs.readFile(file, 'utf8');
assert.ok(content.startsWith(BOM));
const lines = stripBom(content).trim().split('\n');
assert.equal(lines[0], 'a;b');
assert.equal(lines[1], '1;"x;y"');
assert.equal(lines[2], '2;"=""123"""');
} finally {
await fs.rm(file, { force: true });
}
});
+139
View File
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { dashboardSummary, dashboardDailyEvolution, dashboardMaiorOportunidade } from '../src/treatment/dashboard.mjs';
function seedLeads(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null
};
const hoje = new Date().toISOString();
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: hoje });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: null });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Ilheus', uf: 'BA', prioridade: 2, exported_at: null });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', prioridade: 4, exported_at: null });
}
test('dashboardSummary calcula totais, percentuais e meta diaria sobre a base toda', () => {
const db = openDb(':memory:');
seedLeads(db);
setSetting(db, 'meta_diaria', '2');
const s = dashboardSummary(db, {});
assert.equal(s.total, 4);
assert.equal(s.p1, 2);
assert.equal(s.p2, 1);
assert.equal(s.p3, 0);
assert.equal(s.p4, 1);
assert.equal(s.potencial_pct, 50);
assert.equal(s.p1_pct, 50);
assert.equal(s.p4_pct, 25);
assert.equal(s.exportados, 1);
assert.equal(s.disponiveis, 3);
assert.equal(s.cobertura_pct, 25);
assert.equal(s.hoje_exportados, 1);
assert.equal(s.meta_diaria, 2);
assert.equal(s.meta_pct, 50);
assert.equal(s.dias_estimados, 2);
db.close();
});
test('dashboardSummary respeita o filtro (cidade) ao calcular percentuais', () => {
const db = openDb(':memory:');
seedLeads(db);
const s = dashboardSummary(db, { cidade: 'Ilheus' });
assert.equal(s.total, 2);
assert.equal(s.p1, 0);
assert.equal(s.p2, 1);
assert.equal(s.p4, 1);
assert.equal(s.p2_pct, 50);
assert.equal(s.p4_pct, 50);
assert.equal(s.exportados, 0);
assert.equal(s.cobertura_pct, 0);
db.close();
});
test('dashboardSummary com base vazia nao divide por zero', () => {
const db = openDb(':memory:');
const s = dashboardSummary(db, {});
assert.equal(s.total, 0);
assert.equal(s.potencial_pct, 0);
assert.equal(s.cobertura_pct, 0);
assert.equal(s.meta_pct, 0);
assert.equal(s.dias_estimados, 0);
db.close();
});
function seedLeadsComProdutos(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null, prioridade: 1, produtos_json: '[]'
};
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T10:00:00.000Z' });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T11:00:00.000Z' });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Salvador', uf: 'BA', produto_principal: 'G-Chat', exported_at: '2026-07-11T09:00:00.000Z' });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', produto_principal: 'ERP', exported_at: null });
}
test('dashboardDailyEvolution agrupa exportacoes por dia em ordem cronologica', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, {}, 14).map((r) => ({ ...r }));
assert.deepEqual(evolution, [
{ dia: '2026-07-10', total: 2 },
{ dia: '2026-07-11', total: 1 }
]);
db.close();
});
test('dashboardDailyEvolution respeita o filtro', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, { cidade: 'Ilheus' }, 14);
assert.deepEqual(evolution, []);
db.close();
});
test('dashboardMaiorOportunidade retorna a combinacao cidade+produto com mais empresas', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const oportunidade = dashboardMaiorOportunidade(db, {});
assert.deepEqual({ ...oportunidade }, { cidade: 'Salvador', produto: 'ERP', total: 2 });
db.close();
});
test('dashboardMaiorOportunidade retorna null quando nao ha dados', () => {
const db = openDb(':memory:');
assert.equal(dashboardMaiorOportunidade(db, {}), null);
db.close();
});
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings } from '../src/db.mjs';
function sampleLead(overrides = {}) {
return {
lead_uid: 'GTI-TEST-1',
dedup_key: 'cnpj:57557263000113',
empresa: 'PREMIUM EXPRESS',
legal_name: 'PREMIUM EXPRESS LTDA',
whatsapp: '557193335451',
ddd: '71',
email: '',
cidade: 'Salvador',
uf: 'BA',
segmento: 'restaurantes',
segmento_label: 'Restaurantes',
interesse: 'Autoatendimento para WhatsApp',
cnpj: '57557263000113',
cnae: '5611203',
endereco: 'RUA X, 1',
website: '',
pains_json: '["atendimento"]',
origem: 'cnpj-base',
telefone_valido: 1,
cnpj_valido: 1,
empresa_valida: 1,
score: 100,
classificacao: 'bom',
motivo_classificacao: 'ok',
capture_run_id: null,
captured_at: '2026-07-09T14:26:18.560Z',
exported_at: null,
...overrides
};
}
test('openDb cria esquema e aplica migrations uma unica vez', () => {
const db = openDb(':memory:');
const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v;
assert.ok(version >= 1);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) {
assert.ok(tables.includes(table), `tabela ${table} ausente`);
}
db.close();
});
test('createLeadInserter insere e ignora duplicata pela dedup_key', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.equal(insert(sampleLead()), 'inserted');
assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate');
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
assert.equal(total, 1);
db.close();
});
test('transaction faz rollback em erro', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.throws(() =>
transaction(db, () => {
insert(sampleLead());
throw new Error('boom');
})
);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0);
db.close();
});
test('settings: get/set e seed com defaults Z-PRO', () => {
const db = openDb(':memory:');
seedSettings(db);
assert.equal(getSetting(db, 'zpro.pipelineId'), '1');
assert.equal(getSetting(db, 'zpro.stageId'), '1');
assert.equal(getSetting(db, 'zpro.responsibleId'), '15');
setSetting(db, 'zpro.pipelineId', '9');
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
// seed nao sobrescreve valor ja definido pelo usuario
seedSettings(db);
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
db.close();
});
test('seeds de segments, municipios e geo_cache', () => {
const db = openDb(':memory:');
seedSegments(db, [
{ name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] }
]);
const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas');
assert.equal(segment.label, 'Clinicas Medicas');
assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']);
seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]);
const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA');
assert.equal(municipio.nome_clean, 'santo antonio de jesus');
seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]);
const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus');
assert.equal(bbox.south, -12.995);
assert.equal(bbox.provider, 'config-legacy');
db.close();
});
+119
View File
@@ -0,0 +1,119 @@
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, seedSettings } from '../src/db.mjs';
import { exportExcel } from '../src/export/excel.mjs';
import { exportOportunidades, exportBulkDataInput } from '../src/export/zpro.mjs';
import { stripBom } from '../src/util/csv.mjs';
function seedLeads(db) {
const insert = createLeadInserter(db);
insert({
lead_uid: 'GTI-1', dedup_key: 'cnpj:57557263000113', empresa: 'PREMIUM EXPRESS',
legal_name: 'PREMIUM EXPRESS LTDA', whatsapp: '557193335451', ddd: '71', email: '',
cidade: 'Salvador', uf: 'BA', segmento: 'restaurantes', segmento_label: 'Restaurantes',
interesse: 'Autoatendimento para WhatsApp', cnpj: '57557263000113', cnae: '5611203',
endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
telefone_valido: 1, cnpj_valido: 1, empresa_valida: 1, score: 100,
classificacao: 'bom', motivo_classificacao: 'ok', capture_run_id: null,
captured_at: null, exported_at: null
});
insert({
lead_uid: 'GTI-2', dedup_key: 'name:sem telefone:salvador', empresa: 'SEM TELEFONE',
legal_name: '', whatsapp: '', ddd: '', email: '', cidade: 'Salvador', uf: 'BA',
segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '',
cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, score: 35,
classificacao: 'ruim', motivo_classificacao: 'score baixo', capture_run_id: null,
captured_at: null, exported_at: null
});
}
async function withTempDir(fn) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gti-export-'));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
test('exportExcel gera CSV com formulas de texto e xls HTML', async () => {
const db = openDb(':memory:');
seedLeads(db);
await withTempDir(async (dir) => {
const ids = [];
const result = await exportExcel({ db, filtro: { uf: 'BA' }, dir, baseName: 'teste', collectIds: ids });
assert.equal(result.total, 2);
assert.deepEqual(result.files, ['teste.csv', 'teste.xls']);
assert.equal(ids.length, 2);
const csv = stripBom(await fs.readFile(path.join(dir, 'teste.csv'), 'utf8'));
const lines = csv.trim().split('\n');
assert.ok(lines[0].startsWith('empresa;whatsapp;ddd;cidade;uf;segmento;segmento_legivel'));
assert.ok(lines[1].includes('"=""557193335451"""'), 'whatsapp deve ser formula de texto');
assert.ok(lines[1].includes('"=""57557263000113"""'), 'cnpj deve ser formula de texto');
assert.ok(lines[1].includes('cnpj:57557263000113'), 'chave_duplicidade presente');
const xls = await fs.readFile(path.join(dir, 'teste.xls'), 'utf8');
assert.ok(xls.includes('mso-number-format'));
assert.ok(xls.includes('<td class="text">557193335451</td>'));
});
db.close();
});
test('exportOportunidades gera JSONL no formato Z-PRO testado', async () => {
const db = openDb(':memory:');
seedSettings(db);
seedLeads(db);
await withTempDir(async (dir) => {
const result = await exportOportunidades({
db, filtro: { classificacao: 'bom' }, params: {}, dir, baseName: 'op'
});
assert.equal(result.total, 1);
const lines = (await fs.readFile(path.join(dir, 'op.jsonl'), 'utf8')).trim().split('\n');
const payload = JSON.parse(lines[0]);
assert.equal(payload.number, '557193335451');
assert.equal(payload.contactName, 'PREMIUM EXPRESS');
assert.equal(payload.name, 'PREMIUM EXPRESS - Autoatendimento para WhatsApp');
assert.equal(payload.pipelineId, 1);
assert.equal(payload.stageId, 1);
assert.equal(payload.responsibleId, 15);
assert.equal(payload.status, 'open');
assert.equal(payload.validateNumber, true);
assert.match(payload.closingForecast, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(payload.description.includes('CNPJ: 57557263000113'));
});
db.close();
});
test('exportBulkDataInput inclui apenas leads com whatsapp', async () => {
const db = openDb(':memory:');
seedSettings(db);
seedLeads(db);
await withTempDir(async (dir) => {
const result = await exportBulkDataInput({
db, filtro: {}, params: { mensagem: 'Ola {empresa}!' }, dir, baseName: 'bulk'
});
assert.equal(result.total, 1, 'lead sem whatsapp deve ficar de fora');
const csv = (await fs.readFile(path.join(dir, 'bulk-datainput.csv'), 'utf8')).trim();
assert.equal(csv, '557193335451,PREMIUM EXPRESS,Salvador,Restaurantes');
const info = await fs.readFile(path.join(dir, 'bulk-instrucoes.txt'), 'utf8');
assert.ok(info.includes('Ola {empresa}!'));
assert.ok(info.includes('bulkSendMessageWithVariable'));
});
db.close();
});
+157
View File
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
buildNextQuestion,
detectService,
normalizePhone,
processLead
} from '../src/lead-engine.mjs';
describe('lead engine', () => {
it('normaliza telefone brasileiro para formato internacional', () => {
assert.equal(normalizePhone('(75) 99999-1111'), '5575999991111');
assert.equal(normalizePhone('+55 75 99999-1111'), '5575999991111');
});
it('detecta servico por texto livre', () => {
assert.equal(detectService('quero trocar meu sistema de estoque'), 'erp');
assert.equal(detectService('preciso de suporte em servidor e backup'), 'ti');
assert.equal(detectService('automatizar atendimento no WhatsApp'), 'whatsapp');
});
it('prioriza lead com WhatsApp para tratamento manual', () => {
const result = processLead({
source: 'site',
contact: { name: 'Carlos Souza', phone: '(75) 99999-1111', email: 'carlos@mercado.com' },
company: {
name: 'Mercado Sao Joao',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'mercado',
employees: 18
},
interest: 'ERP',
current_system: 'uso outro sistema, mas quero trocar',
pains: ['estoque', 'financeiro', 'emissao fiscal'],
urgency: 'alta',
consent: true
});
assert.equal(result.lead.service, 'erp');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.equal(result.priority.hasWhatsapp, true);
assert.ok(result.whatsapp.internal.message.includes('Novo lead captado - COM WHATSAPP'));
assert.equal(result.nextAction, 'tratamento_manual');
});
it('mantem lead de TI com dados parciais como captado', () => {
const result = processLead({
origem: 'indicacao',
nome: 'Ana Lima',
telefone: '75988887777',
empresa: 'Clinica Vida',
cidade: 'Santo Antonio de Jesus',
uf: 'BA',
segmento: 'clinica',
servico: 'gestao de TI',
computadores: 8,
dor: 'backup; suporte tecnico',
urgencia: 'media'
});
assert.equal(result.lead.service, 'ti');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.ok(result.crmRecord.whatsapp.startsWith('55'));
});
it('mantem lead sem WhatsApp abaixo na prioridade', () => {
const result = processLead({
name: 'Lead Sem Servico',
message: 'quero mais informacoes'
});
assert.equal(result.lead.service, 'indefinido');
assert.equal(result.priority.priority, 'sem_whatsapp');
assert.equal(result.priority.hasWhatsapp, false);
assert.ok(buildNextQuestion(result.lead).includes('1 - Sistema ERP'));
});
it('reconhece segmentos compostos da prospeccao automatica como segmento-alvo', () => {
const compostos = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999993333',
company_name: 'Restaurante Sabor da Terra',
city: 'Amargosa',
state: 'BA',
segment: 'restaurante_pizzaria'
});
assert.equal(compostos.lead.isTargetSegment, true);
const materialConstrucao = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999994444',
company_name: 'Deposito Central',
segment: 'material_construcao_cimento_blocos'
});
assert.equal(materialConstrucao.lead.isTargetSegment, true);
const clinica = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999995555',
company_name: 'Clinica Odonto Vida',
segment: 'clinica_laboratorio_odonto_otica'
});
assert.equal(clinica.lead.isTargetSegment, true);
});
it('nao marca como segmento-alvo um segmento fora da lista', () => {
const result = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999996666',
company_name: 'Posto Boa Vista',
segment: 'posto_combustivel'
});
assert.equal(result.lead.isTargetSegment, false);
});
it('reconhece os novos nichos (beleza, celulares, transportadora) como segmento-alvo', () => {
const beleza = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999997777',
company_name: 'Studio Beleza Bahia',
segment: 'beleza_estetica_barbearia'
});
assert.equal(beleza.lead.isTargetSegment, true);
const celulares = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999998888',
company_name: 'Cell Center',
segment: 'celulares_eletronicos_assistencia'
});
assert.equal(celulares.lead.isTargetSegment, true);
const transportadora = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999999999',
company_name: 'Transportadora Recôncavo',
segment: 'transportadora_logistica'
});
assert.equal(transportadora.lead.isTargetSegment, true);
});
it('direciona lead de WhatsApp para pergunta de volume quando a dor ja existe', () => {
const result = processLead({
name: 'Mariana',
whatsapp: '75999992222',
company_name: 'Restaurante Sabor',
city: 'Santo Antonio de Jesus',
state: 'BA',
interest: 'autoatendimento WhatsApp',
pains: ['pedidos', 'triagem']
});
assert.equal(result.lead.service, 'whatsapp');
assert.ok(buildNextQuestion(result.lead).includes('quantas mensagens'));
});
});
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
const SEGMENTS = [
{ name: 'mercados_de_bairro', label: 'Mercados De Bairro', interest: 'ERP / sistema de gestao', queries: ['mercado'], cnaeCodes: ['4711302'], overpassTags: [], pains: ['estoque'] },
{ name: 'clinicas_medicas', label: 'Clinicas Medicas', interest: 'gestao de TI', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [], pains: ['agenda'] },
{ name: 'restaurantes', label: 'Restaurantes', interest: 'autoatendimento WhatsApp', queries: ['restaurante'], cnaeCodes: ['5611201'], overpassTags: [], pains: ['pedidos'] }
];
function captureParams() {
return {
sources: ['mock'],
uf: 'BA',
cities: ['Santo Antonio de Jesus'],
segments: SEGMENTS,
limits: { maxPerQuery: 10, queryLimit: 0 }
};
}
test('runCapture insere leads do mock e deduplica no rerun', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const first = await runCapture({ db, runId, params: captureParams() });
assert.equal(first.raw, 3);
assert.equal(first.new, 3);
assert.equal(first.dup, 0);
assert.equal(first.status, 'done');
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
assert.equal(run.status, 'done');
assert.equal(run.new_count, 3);
assert.ok(run.finished_at);
const lead = db.prepare("SELECT * FROM leads WHERE segmento = 'restaurantes'").get();
assert.equal(lead.uf, 'BA');
assert.equal(lead.cidade, 'Santo Antonio de Jesus');
assert.equal(lead.origem, 'mock');
assert.equal(lead.telefone_valido, 1);
assert.ok(lead.dedup_key.startsWith('phone:'));
assert.equal(lead.capture_run_id, runId);
// rerun: tudo duplicado
const runId2 = createCaptureRun(db, {});
const second = await runCapture({ db, runId: runId2, params: captureParams() });
assert.equal(second.new, 0);
assert.equal(second.dup, 3);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 3);
db.close();
});
test('runCapture respeita cancelamento', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const result = await runCapture({
db,
runId,
params: captureParams(),
isCancelled: () => true
});
assert.equal(result.status, 'cancelled');
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'cancelled');
db.close();
});
+122
View File
@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
validatePhone, validateCnpj, validateEmpresa, dedupKey, extractDdd, segmentLabel,
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral
} from '../src/scoring.mjs';
test('validatePhone aceita celular e fixo com 55', () => {
assert.equal(validatePhone('557193335451').valid, true);
assert.equal(validatePhone('5571993335451').valid, true);
});
test('validatePhone rejeita vazio, curto e ddd invalido', () => {
assert.deepEqual(validatePhone(''), { valid: false, motivo: 'telefone vazio' });
assert.equal(validatePhone('7199999').valid, false);
assert.equal(validatePhone('550599999999').valid, false);
assert.equal(validatePhone('5571000000000').valid, false);
});
test('validateCnpj valida digitos verificadores', () => {
// CNPJ real da base legada (PREMIUM EXPRESS)
assert.equal(validateCnpj('57557263000113'), true);
assert.equal(validateCnpj('57557263000114'), false);
assert.equal(validateCnpj('11111111111111'), false);
assert.equal(validateCnpj('123'), false);
});
test('validateEmpresa aplica regra critica do relatorio legado', () => {
assert.equal(validateEmpresa('PREMIUM EXPRESS').valid, true);
assert.deepEqual(validateEmpresa('57.563.316 JHONATAS SOUZA'), { valid: false, motivo: 'empresa comeca com numero' });
assert.deepEqual(validateEmpresa('-'), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.deepEqual(validateEmpresa(''), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.equal(validateEmpresa('...***...').valid, false);
});
test('scoreQualidade: lead completo com website bate 100', () => {
const result = scoreQualidade({
whatsapp: '557193335451',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: 'https://premiumexpress.com.br'
});
assert.equal(result.score, 100);
assert.equal(result.telefoneValido, true);
assert.equal(result.cnpjValido, true);
assert.equal(result.empresaValida, true);
});
test('scoreQualidade: sem telefone/website perde 30+5 pontos', () => {
const result = scoreQualidade({
whatsapp: '',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: ''
});
assert.equal(result.score, 65);
assert.equal(result.telefoneValido, false);
});
test('scoreComercial: nicho alta aderencia + tudo valido bate 100', () => {
const score = scoreComercial({
whatsapp: '557193335451',
cnpj: '57557263000113',
website: 'https://premiumexpress.com.br',
email: 'contato@premiumexpress.com.br',
affinity: 'alta'
});
assert.equal(score, 100);
});
test('scoreComercial: nicho baixa aderencia sem website/email/whatsapp/cnpj fica so com os pontos do nicho', () => {
const score = scoreComercial({ whatsapp: '', cnpj: '', website: '', email: '', affinity: 'baixa' });
assert.equal(score, 20);
});
test('scoreGeral combina 60% comercial + 40% qualidade', () => {
assert.equal(scoreGeral(100, 100), 100);
assert.equal(scoreGeral(50, 50), 50);
assert.equal(scoreGeral(0, 100), 60);
assert.equal(scoreGeral(100, 0), 40);
});
test('classificarScoreGeral aplica as 5 faixas', () => {
assert.equal(classificarScoreGeral(95).id, 'excelente');
assert.equal(classificarScoreGeral(80).id, 'bom');
assert.equal(classificarScoreGeral(60).id, 'medio');
assert.equal(classificarScoreGeral(40).id, 'baixo');
assert.equal(classificarScoreGeral(10).id, 'muito_baixo');
});
test('dedupKey segue precedencia cnpj > phone > empresa+cidade', () => {
assert.equal(
dedupKey({ cnpj: '57557263000113', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'cnpj:57557263000113'
);
assert.equal(
dedupKey({ cnpj: '123', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'phone:557193335451'
);
assert.equal(
dedupKey({ cnpj: '', whatsapp: '', empresa: 'Premium Express', cidade: 'Salvador' }),
'name:premium express:salvador'
);
});
test('extractDdd retira o ddd do numero normalizado', () => {
assert.equal(extractDdd('557193335451'), '71');
assert.equal(extractDdd('999'), '');
});
test('segmentLabel formata igual ao segmento_legivel legado', () => {
assert.equal(segmentLabel('lojas_de_calcados'), 'Lojas De Calcados');
assert.equal(segmentLabel('saloes_de_beleza'), 'Saloes De Beleza');
});
+70
View File
@@ -0,0 +1,70 @@
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 { streamJsonArray } from '../src/util/stream-json.mjs';
async function collect(filePath, options) {
const items = [];
for await (const item of streamJsonArray(filePath, options)) {
items.push(item);
}
return items;
}
async function withTempFile(content, fn) {
const file = path.join(os.tmpdir(), `gti-test-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
await fs.writeFile(file, content, 'utf8');
try {
return await fn(file);
} finally {
await fs.rm(file, { force: true });
}
}
test('streamJsonArray le array pretty-printed com chaves e aspas dentro de strings', async () => {
const data = [
{ nome: 'Empresa {teste} "aspas"', valor: 10, aninhado: { lista: [1, 2, { x: '}' }] } },
{ nome: 'Segunda \\ barra', vazio: null }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray funciona com JSON compacto e BOM', async () => {
const data = [{ a: 1 }, { b: '[{teste}]' }, { c: [{ d: 2 }] }];
await withTempFile('' + JSON.stringify(data), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray processa elementos maiores que o chunk', async () => {
const data = [
{ texto: 'x'.repeat(500), fim: 'ok' },
{ texto: 'y'.repeat(300) }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file, { highWaterMark: 64 });
assert.deepEqual(items, data);
});
});
test('streamJsonArray retorna vazio para array vazio', async () => {
await withTempFile('[]', async (file) => {
const items = await collect(file);
assert.deepEqual(items, []);
});
});
test('streamJsonArray falha com erro claro em arquivo truncado', async () => {
await withTempFile('[\n { "a": 1 },\n { "b": 2', async (file) => {
await assert.rejects(() => collect(file), /JSON truncado/);
});
});