chore: import gti-maquina-comercial history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: gti-maquina-comercial git-subtree-mainline:93ca82979egit-subtree-split:b77484946f
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs';
|
||||
|
||||
const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }];
|
||||
|
||||
test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => {
|
||||
const primeira = variantForLead(variants, 42);
|
||||
const segunda = variantForLead(variants, 42);
|
||||
assert.equal(primeira.code, segunda.code);
|
||||
});
|
||||
|
||||
test('variantForLead distribui entre variantes diferentes conforme o id', () => {
|
||||
assert.equal(variantForLead(variants, 0).code, 'A');
|
||||
assert.equal(variantForLead(variants, 1).code, 'B');
|
||||
assert.equal(variantForLead(variants, 2).code, 'C');
|
||||
assert.equal(variantForLead(variants, 3).code, 'A');
|
||||
});
|
||||
|
||||
test('variantForLead lanca erro sem variantes ativas', () => {
|
||||
assert.throws(() => variantForLead([], 1), /sem variantes/);
|
||||
});
|
||||
|
||||
test('renderCampaignMessage substitui variaveis do lead', () => {
|
||||
const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' });
|
||||
assert.equal(texto, 'Ola Oficina do Ze, sobre ERP');
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
|
||||
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
|
||||
|
||||
function inserirLead(db, dedup) {
|
||||
const inserir = createLeadInserter(db);
|
||||
inserir({
|
||||
dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`,
|
||||
ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '',
|
||||
cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1,
|
||||
cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '',
|
||||
score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP',
|
||||
produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null
|
||||
});
|
||||
return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
|
||||
}
|
||||
|
||||
function campanhaRodando(db) {
|
||||
const campanha = createCampaign(db, {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
||||
});
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
return campanha;
|
||||
}
|
||||
|
||||
function simularEnvio(db, campanhaId, variantId, leadId, numero, status) {
|
||||
const sendId = db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)`
|
||||
).run(leadId, numero, status).lastInsertRowid;
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
|
||||
VALUES (?, ?, ?, ?, 'oi', ?, ?)`
|
||||
).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId);
|
||||
}
|
||||
|
||||
test('evaluateCampaignHealth nao pausa campanha saudavel', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const lead = inserirLead(db, `l${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
}
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const lead = inserirLead(db, `ok${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const lead = inserirLead(db, `err${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||
}
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, true);
|
||||
assert.equal(resultado.reason, 'high_failure_rate');
|
||||
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
|
||||
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||
assert.equal(semMutacao.status, 'rodando');
|
||||
|
||||
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
|
||||
const aplicado = applyCampaignHealth(db, campanha.id);
|
||||
assert.equal(aplicado.pause, true);
|
||||
assert.equal(aplicado.reason, 'high_failure_rate');
|
||||
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||
assert.equal(atualizada.status, 'pausado');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const lead = inserirLead(db, `puro-ok${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const lead = inserirLead(db, `puro-err${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
|
||||
}
|
||||
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
const lead = inserirLead(db, 'so1');
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const lead = inserirLead(db, `neg${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
if (i < 3) {
|
||||
db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id);
|
||||
}
|
||||
}
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, true);
|
||||
assert.equal(resultado.reason, 'high_negative_rate');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const lead = inserirLead(db, `rep${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
}
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, true);
|
||||
assert.equal(resultado.reason, 'low_reply_rate');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
setCampaignStatus(db, campanha.id, 'pausado', 'manual');
|
||||
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||
assert.equal(resultado.pause, false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaRodando(db);
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const lead = inserirLead(db, `v${i}`);
|
||||
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||
if (i < 1) {
|
||||
db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id);
|
||||
}
|
||||
}
|
||||
const stats = variantStats(db, campanha.id);
|
||||
assert.equal(stats.length, 1);
|
||||
assert.equal(stats[0].enviados, 5);
|
||||
assert.equal(stats[0].respostas, 1);
|
||||
assert.equal(stats[0].taxaResposta, 0.2);
|
||||
assert.equal(stats[0].elegivelParaComparacao, false);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||
import { createCampaign } from '../src/outreach/campaign-store.mjs';
|
||||
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs';
|
||||
|
||||
function inserirLead(db, overrides = {}) {
|
||||
const inserir = createLeadInserter(db);
|
||||
const dedup = overrides.dedup_key || `lead-${Math.random()}`;
|
||||
inserir({
|
||||
dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001',
|
||||
ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '',
|
||||
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste',
|
||||
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom',
|
||||
motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1,
|
||||
produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null,
|
||||
...overrides
|
||||
});
|
||||
return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup);
|
||||
}
|
||||
|
||||
function campanhaComVariantes(db, overrides = {}) {
|
||||
return createCampaign(db, {
|
||||
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'],
|
||||
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' });
|
||||
inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' });
|
||||
const campanha = campanhaComVariantes(db);
|
||||
|
||||
const resultado = generateCampaignQueue(db, campanha.id);
|
||||
assert.equal(resultado.candidatosEncontrados, 1);
|
||||
assert.equal(resultado.adicionadosNaFila, 1);
|
||||
|
||||
const fila = listQueue(db, campanha.id);
|
||||
assert.equal(fila.length, 1);
|
||||
assert.equal(fila[0].status, 'preview');
|
||||
assert.equal(fila[0].rendered_message, 'Ola Oficina Teste');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => {
|
||||
const db = openDb(':memory:');
|
||||
const lead = inserirLead(db);
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')`
|
||||
).run(lead.id, lead.whatsapp);
|
||||
const campanha = campanhaComVariantes(db);
|
||||
|
||||
const resultado = generateCampaignQueue(db, campanha.id);
|
||||
assert.equal(resultado.candidatosEncontrados, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id, produto vazio, como o envio manual real grava)', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { whatsapp: '5511999990009' });
|
||||
// Reproduz exatamente como src/server/routes/outreach.mjs grava um envio
|
||||
// manual: lead_id NULL, produto '' (nunca o produto da campanha) e
|
||||
// origem='manual'. A exclusao precisa valer mesmo assim, por numero.
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES (NULL, '', '5511999990009', 'oi', 'enviado', 'manual')`
|
||||
).run();
|
||||
const campanha = campanhaComVariantes(db);
|
||||
|
||||
const resultado = generateCampaignQueue(db, campanha.id);
|
||||
assert.equal(resultado.candidatosEncontrados, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue exclui numero em opt-out', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { whatsapp: '5511999990001' });
|
||||
db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run();
|
||||
const campanha = campanhaComVariantes(db);
|
||||
|
||||
const resultado = generateCampaignQueue(db, campanha.id);
|
||||
assert.equal(resultado.candidatosEncontrados, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' });
|
||||
const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' });
|
||||
generateCampaignQueue(db, campanha1.id);
|
||||
|
||||
const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' });
|
||||
const resultado = generateCampaignQueue(db, campanha2.id);
|
||||
assert.equal(resultado.candidatosEncontrados, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue respeita o parametro quantidade', () => {
|
||||
const db = openDb(':memory:');
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` });
|
||||
}
|
||||
const campanha = campanhaComVariantes(db);
|
||||
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 });
|
||||
assert.equal(resultado.adicionadosNaFila, 3);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('generateCampaignQueue reporta candidatosEncontrados como o pool elegivel completo, mesmo com quantidade menor', () => {
|
||||
const db = openDb(':memory:');
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
inserirLead(db, { dedup_key: `pool-${i}`, whatsapp: `551199998000${i}` });
|
||||
}
|
||||
const campanha = campanhaComVariantes(db);
|
||||
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 2 });
|
||||
assert.equal(resultado.candidatosEncontrados, 5);
|
||||
assert.equal(resultado.adicionadosNaFila, 2);
|
||||
assert.equal(listQueue(db, campanha.id).length, 2);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('approveQueueRows so aprova linhas em preview da campanha certa', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { whatsapp: '5511999990001' });
|
||||
const campanha = campanhaComVariantes(db);
|
||||
generateCampaignQueue(db, campanha.id);
|
||||
const [linha] = listQueue(db, campanha.id);
|
||||
|
||||
const total = approveQueueRows(db, campanha.id, [linha.id]);
|
||||
assert.equal(total, 1);
|
||||
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||
});
|
||||
|
||||
test('cancelQueueRows cancela e registra motivo', () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, { whatsapp: '5511999990001' });
|
||||
const campanha = campanhaComVariantes(db);
|
||||
generateCampaignQueue(db, campanha.id);
|
||||
const [linha] = listQueue(db, campanha.id);
|
||||
|
||||
const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa');
|
||||
assert.equal(total, 1);
|
||||
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
||||
assert.equal(cancelada.cancel_reason, 'nao interessa');
|
||||
});
|
||||
|
||||
test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = campanhaComVariantes(db);
|
||||
assert.equal(approveQueueRows(db, campanha.id, []), 0);
|
||||
assert.equal(cancelQueueRows(db, campanha.id, []), 0);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||
import { createRouter } from '../src/server/router.mjs';
|
||||
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
|
||||
|
||||
function setupRouter(db) {
|
||||
const router = createRouter();
|
||||
registerCampaignRoutes(router, { db });
|
||||
return router;
|
||||
}
|
||||
|
||||
function fakeJson() {
|
||||
const calls = [];
|
||||
return { json: (status, data) => calls.push({ status, data }), calls };
|
||||
}
|
||||
|
||||
function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') {
|
||||
const inserir = createLeadInserter(db);
|
||||
inserir({
|
||||
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11',
|
||||
email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
||||
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
||||
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
||||
score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]',
|
||||
capture_run_id: null, captured_at: null, exported_at: null
|
||||
});
|
||||
}
|
||||
|
||||
test('POST /api/campaigns cria campanha com segmentos e variantes', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/campaigns');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json, body: {
|
||||
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'],
|
||||
variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||
} });
|
||||
|
||||
assert.equal(calls[0].status, 200);
|
||||
assert.equal(calls[0].data.produto, 'ERP');
|
||||
assert.equal(calls[0].data.variants.length, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns rejeita sem segmentos', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/campaigns');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } });
|
||||
assert.equal(calls[0].status, 400);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||
inserirLead(db, '2', 'ERP', 'oficinas');
|
||||
inserirLead(db, '3', 'ERP', 'padarias');
|
||||
inserirLead(db, '4', 'G-Chat', 'padarias');
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('GET', '/api/campaigns/segmentos');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) });
|
||||
assert.equal(calls[0].status, 200);
|
||||
const segmentos = calls[0].data.segmentos;
|
||||
assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2);
|
||||
assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||
const router = setupRouter(db);
|
||||
|
||||
const criar = router.match('POST', '/api/campaigns');
|
||||
const respCriar = fakeJson();
|
||||
await criar.handler(null, null, { json: respCriar.json, body: {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||
} });
|
||||
const campaignId = respCriar.calls[0].data.id;
|
||||
|
||||
const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`);
|
||||
const respFila = fakeJson();
|
||||
await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} });
|
||||
assert.equal(respFila.calls[0].data.adicionadosNaFila, 1);
|
||||
|
||||
const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`);
|
||||
const respLista = fakeJson();
|
||||
await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() });
|
||||
const queueId = respLista.calls[0].data.fila[0].id;
|
||||
|
||||
const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`);
|
||||
const respAprovar = fakeJson();
|
||||
await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } });
|
||||
assert.equal(respAprovar.calls[0].data.aprovados, 1);
|
||||
|
||||
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
|
||||
const respSaude = fakeJson();
|
||||
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
|
||||
assert.equal(respSaude.calls[0].data.pause, false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, '1');
|
||||
const lead = db.prepare('SELECT id FROM leads LIMIT 1').get();
|
||||
const router = setupRouter(db);
|
||||
|
||||
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
|
||||
const respMarcar = fakeJson();
|
||||
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } });
|
||||
assert.equal(respMarcar.calls[0].status, 200);
|
||||
|
||||
const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio');
|
||||
const respConsultar = fakeJson();
|
||||
await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } });
|
||||
assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado');
|
||||
assert.equal(respConsultar.calls[0].data.historico.length, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
|
||||
const adicionar = router.match('POST', '/api/campaigns/opt-out');
|
||||
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } });
|
||||
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
|
||||
|
||||
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
|
||||
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } });
|
||||
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST/DELETE /api/campaigns/opt-out normalizam o numero pra digitos, igual ao resto do app', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
|
||||
const adicionar = router.match('POST', '/api/campaigns/opt-out');
|
||||
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '+55 (11) 99999-0001', motivo: 'pediu' } });
|
||||
// Tem que ter gravado so digitos, senao isOptedOut/candidateQuery (que comparam
|
||||
// contra leads.whatsapp, ja digit-only) nunca vao casar com este registro.
|
||||
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
|
||||
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('+55 (11) 99999-0001'), undefined);
|
||||
|
||||
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
|
||||
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '+55 (11) 99999-0001' } });
|
||||
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/campaigns/leads/:leadId/estagio com stage=opt_out tambem grava em outreach_opt_out (numero digit-only)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, '1');
|
||||
const lead = db.prepare('SELECT id, whatsapp FROM leads LIMIT 1').get();
|
||||
const router = setupRouter(db);
|
||||
|
||||
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
|
||||
const respMarcar = fakeJson();
|
||||
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'opt_out' } });
|
||||
assert.equal(respMarcar.calls[0].status, 200);
|
||||
|
||||
assert.ok(
|
||||
db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(lead.whatsapp),
|
||||
'marcar estagio opt_out deveria ter chamado addOptOut com o numero do lead'
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('GET /:id/saude nao muta o status da campanha (leitura pura); o worker e quem aplica a pausa', async () => {
|
||||
const db = openDb(':memory:');
|
||||
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||
const router = setupRouter(db);
|
||||
|
||||
const criar = router.match('POST', '/api/campaigns');
|
||||
const respCriar = fakeJson();
|
||||
await criar.handler(null, null, { json: respCriar.json, body: {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||
} });
|
||||
const campaignId = respCriar.calls[0].data.id;
|
||||
|
||||
const iniciar = router.match('POST', `/api/campaigns/${campaignId}/iniciar`);
|
||||
await iniciar.handler(null, null, { json: fakeJson().json, params: { id: String(campaignId) } });
|
||||
|
||||
// Simula 10 tentativas hoje, todas com erro (100% > 10%) -> evaluateCampaignHealth
|
||||
// deveria retornar pause:true, mas a rota GET nao pode persistir essa pausa.
|
||||
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campaignId);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const dedup = `saude-${i}`;
|
||||
inserirLead(db, dedup, 'ERP', 'oficinas');
|
||||
const l = db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
|
||||
const sendId = db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'erro')`
|
||||
).run(l.id, l.whatsapp).lastInsertRowid;
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
|
||||
VALUES (?, ?, ?, ?, 'oi', 'error', ?)`
|
||||
).run(campaignId, l.id, variant.id, l.whatsapp, sendId);
|
||||
}
|
||||
|
||||
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
|
||||
const respSaude = fakeJson();
|
||||
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
|
||||
assert.equal(respSaude.calls[0].data.pause, true, 'a avaliacao deveria sinalizar pause:true dado 100% de erro');
|
||||
|
||||
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||
assert.equal(aindaRodando.status, 'rodando', 'abrir o painel de saude (GET) nao deveria ter pausado a campanha');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.mjs';
|
||||
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
|
||||
|
||||
test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => {
|
||||
const db = openDb(':memory:');
|
||||
const stop = startCampaignScheduler({ db, log: () => {} });
|
||||
assert.equal(typeof stop, 'function');
|
||||
assert.doesNotThrow(() => stop());
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.mjs';
|
||||
import {
|
||||
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
|
||||
escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut
|
||||
} from '../src/outreach/campaign-store.mjs';
|
||||
|
||||
function campanhaBase(overrides = {}) {
|
||||
return {
|
||||
nome: 'ERP oficinas',
|
||||
produto: 'ERP',
|
||||
segmentos: ['oficinas_mecanicas', 'autopecas'],
|
||||
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('createCampaign grava campanha, segmentos e variantes', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase());
|
||||
assert.equal(campanha.status, 'rascunho');
|
||||
assert.equal(campanha.produto, 'ERP');
|
||||
assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']);
|
||||
assert.equal(campanha.variants.length, 2);
|
||||
assert.equal(campanha.variants[0].code, 'A');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('listCampaigns retorna mais recente primeiro', () => {
|
||||
const db = openDb(':memory:');
|
||||
createCampaign(db, campanhaBase({ nome: 'Primeira' }));
|
||||
createCampaign(db, campanhaBase({ nome: 'Segunda' }));
|
||||
const campanhas = listCampaigns(db);
|
||||
assert.equal(campanhas.length, 2);
|
||||
assert.equal(campanhas[0].nome, 'Segunda');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase());
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
|
||||
assert.equal(pausada.status, 'pausado');
|
||||
assert.equal(pausada.pause_reason, 'high_failure_rate');
|
||||
const retomada = resumeCampaign(db, campanha.id);
|
||||
assert.equal(retomada.status, 'rodando');
|
||||
assert.equal(retomada.pause_reason, null);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('setCampaignStatus rodando stamps semana_iniciada_em on first transition', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase());
|
||||
assert.equal(campanha.semana_iniciada_em, null);
|
||||
|
||||
const rodando = setCampaignStatus(db, campanha.id, 'rodando');
|
||||
assert.notEqual(rodando.semana_iniciada_em, null, 'semana_iniciada_em should be set on first rodando transition');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('setCampaignStatus rodando does not reset already-set semana_iniciada_em', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase());
|
||||
|
||||
// Set to rodando first time, which stamps semana_iniciada_em
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
|
||||
// Manually set to a known past value via raw SQL
|
||||
const knownTime = '2026-07-15 10:00:00';
|
||||
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = ? WHERE id = ?`).run(knownTime, campanha.id);
|
||||
|
||||
// Call setCampaignStatus again while already 'rodando'
|
||||
const unchanged = setCampaignStatus(db, campanha.id, 'rodando');
|
||||
|
||||
// Verify semana_iniciada_em was not changed
|
||||
assert.equal(unchanged.semana_iniciada_em, knownTime, 'semana_iniciada_em should not be reset on subsequent rodando calls');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 }));
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
|
||||
// Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em
|
||||
// backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento.
|
||||
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id);
|
||||
|
||||
resumeCampaign(db, campanha.id);
|
||||
const escaladas = escalateWeeklyLimits(db);
|
||||
assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente');
|
||||
const atualizada = getCampaign(db, campanha.id);
|
||||
assert.equal(atualizada.limite_diario_atual, 10);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase());
|
||||
const resultado = resumeCampaign(db, campanha.id);
|
||||
assert.equal(resultado.status, 'rascunho');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => {
|
||||
const db = openDb(':memory:');
|
||||
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 }));
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id);
|
||||
|
||||
const escaladas = escalateWeeklyLimits(db);
|
||||
assert.equal(escaladas, 1);
|
||||
const atualizada = getCampaign(db, campanha.id);
|
||||
assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => {
|
||||
const db = openDb(':memory:');
|
||||
const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' }));
|
||||
setCampaignStatus(db, rodando.id, 'rodando');
|
||||
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id);
|
||||
|
||||
const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' }));
|
||||
setCampaignStatus(db, pausada.id, 'rodando');
|
||||
setCampaignStatus(db, pausada.id, 'pausado', 'manual');
|
||||
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id);
|
||||
|
||||
const escaladas = escalateWeeklyLimits(db);
|
||||
assert.equal(escaladas, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('opt-out: addOptOut bloqueia, removeOptOut libera', () => {
|
||||
const db = openDb(':memory:');
|
||||
assert.equal(isOptedOut(db, '5511999990001'), false);
|
||||
addOptOut(db, '5511999990001', 'pediu pra parar');
|
||||
assert.equal(isOptedOut(db, '5511999990001'), true);
|
||||
removeOptOut(db, '5511999990001');
|
||||
assert.equal(isOptedOut(db, '5511999990001'), false);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
|
||||
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
|
||||
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
|
||||
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
|
||||
|
||||
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
|
||||
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
|
||||
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
|
||||
// horario real em que o teste roda.
|
||||
function ativarWorker(db) {
|
||||
setSetting(db, 'outreach.ativo', 'true');
|
||||
setSetting(db, 'outreach.horaInicio', '00:00');
|
||||
setSetting(db, 'outreach.horaFim', '23:59');
|
||||
}
|
||||
|
||||
function inserirLead(db, dedup, whatsapp) {
|
||||
const inserir = createLeadInserter(db);
|
||||
inserir({
|
||||
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '',
|
||||
cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
||||
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
||||
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
||||
score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]',
|
||||
capture_run_id: null, captured_at: null, exported_at: null
|
||||
});
|
||||
}
|
||||
|
||||
function campanhaAprovada(db, quantidadeLeads = 1) {
|
||||
for (let i = 0; i < quantidadeLeads; i += 1) {
|
||||
inserirLead(db, `lead-${i}`, `551199999000${i}`);
|
||||
}
|
||||
const campanha = createCampaign(db, {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
||||
limiteDiarioInicial: 10
|
||||
});
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
generateCampaignQueue(db, campanha.id);
|
||||
const fila = listQueue(db, campanha.id, 'preview');
|
||||
approveQueueRows(db, campanha.id, fila.map((r) => r.id));
|
||||
return campanha;
|
||||
}
|
||||
|
||||
function fakeZproClient(overrides = {}) {
|
||||
const enviados = [];
|
||||
return {
|
||||
enviados,
|
||||
sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
const campanha = campanhaAprovada(db);
|
||||
const zproClient = fakeZproClient();
|
||||
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(zproClient.enviados.length, 1);
|
||||
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
|
||||
|
||||
const fila = listQueue(db, campanha.id, 'sent');
|
||||
assert.equal(fila.length, 1);
|
||||
assert.ok(fila[0].outreach_send_id);
|
||||
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id);
|
||||
assert.equal(send.status, 'enviado');
|
||||
assert.equal(send.origem, 'automatico');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
const campanha = campanhaAprovada(db, 2);
|
||||
let chamada = 0;
|
||||
const zproClient = fakeZproClient({
|
||||
sendText: async (args) => {
|
||||
chamada += 1;
|
||||
if (chamada === 1) throw new Error('Z-PRO falhou');
|
||||
return { data: { ticketId: 1 } };
|
||||
}
|
||||
});
|
||||
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(resumo.erros, 1);
|
||||
const erro = listQueue(db, campanha.id, 'error')[0];
|
||||
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id);
|
||||
assert.equal(send.status, 'erro');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue respeita o limite diario da campanha', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
|
||||
const campanha = createCampaign(db, {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
|
||||
limiteDiarioInicial: 1
|
||||
});
|
||||
setCampaignStatus(db, campanha.id, 'rodando');
|
||||
generateCampaignQueue(db, campanha.id);
|
||||
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(zproClient.enviados.length, 1);
|
||||
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
const campanha = campanhaAprovada(db);
|
||||
addOptOut(db, '5511999990000', 'pediu pra parar');
|
||||
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.pulados, 1);
|
||||
assert.equal(zproClient.enviados.length, 0);
|
||||
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
||||
assert.equal(cancelada.cancel_reason, 'opt_out');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue so processa campanhas com status rodando', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
inserirLead(db, 'l1', '5511999990001');
|
||||
const campanha = createCampaign(db, {
|
||||
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
||||
});
|
||||
generateCampaignQueue(db, campanha.id);
|
||||
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
||||
// campanha continua em 'rascunho' — nunca foi iniciada
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.enviados.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
|
||||
const db = openDb(':memory:');
|
||||
ativarWorker(db);
|
||||
const campanha = campanhaAprovada(db, 10);
|
||||
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
|
||||
let chamada = 0;
|
||||
const zproClient = fakeZproClient({
|
||||
sendText: async () => {
|
||||
chamada += 1;
|
||||
if (chamada <= 8) return { data: { ticketId: chamada } };
|
||||
throw new Error('falha simulada');
|
||||
}
|
||||
});
|
||||
|
||||
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
|
||||
assert.equal(atualizada.status, 'pausado');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
|
||||
const campanha = campanhaAprovada(db);
|
||||
const zproClient = fakeZproClient();
|
||||
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.enviados.length, 0);
|
||||
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'outreach.ativo', 'true');
|
||||
setSetting(db, 'outreach.horaInicio', '08:00');
|
||||
setSetting(db, 'outreach.horaFim', '18:00');
|
||||
const campanha = campanhaAprovada(db);
|
||||
const zproClient = fakeZproClient();
|
||||
|
||||
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
|
||||
const foraDoHorario = new Date();
|
||||
foraDoHorario.setHours(3, 0, 0, 0);
|
||||
|
||||
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.enviados.length, 0);
|
||||
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||
db.close();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings, seedMessageTemplates, updateMessageTemplate } 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('message_templates salva variaveis WABA automaticas por produto', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
|
||||
const updated = updateMessageTemplate(db, 'ERP', {
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'gti_inicio_atendimento_solicitado',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateWabaVariaveis: ['{empresa}', 'Assunto: {produto}', '']
|
||||
});
|
||||
|
||||
assert.equal(updated.template_waba_nome, 'gti_inicio_atendimento_solicitado');
|
||||
assert.deepEqual(JSON.parse(updated.template_waba_variaveis_json), ['{empresa}', 'Assunto: {produto}']);
|
||||
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();
|
||||
});
|
||||
|
||||
test('migracao v8 cria as tabelas de campanha de outreach', () => {
|
||||
const db = openDb(':memory:');
|
||||
const tabelas = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'`
|
||||
).all().map((r) => r.name);
|
||||
for (const nome of [
|
||||
'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue',
|
||||
'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out'
|
||||
]) {
|
||||
assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`);
|
||||
}
|
||||
db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run();
|
||||
const campanha = db.prepare('SELECT * FROM outreach_campaigns').get();
|
||||
assert.equal(campanha.status, 'rascunho');
|
||||
assert.equal(campanha.limite_diario_atual, 10);
|
||||
db.close();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, setSetting } from '../src/db.mjs';
|
||||
import { createRouter } from '../src/server/router.mjs';
|
||||
import { registerOutreachRoutes } from '../src/server/routes/outreach.mjs';
|
||||
|
||||
function setupRouter(db) {
|
||||
const router = createRouter();
|
||||
registerOutreachRoutes(router, { db });
|
||||
return router;
|
||||
}
|
||||
|
||||
function fakeJson() {
|
||||
const calls = [];
|
||||
const json = (status, data) => calls.push({ status, data });
|
||||
return { json, calls };
|
||||
}
|
||||
|
||||
function configurarCredenciais(db) {
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-teste');
|
||||
}
|
||||
|
||||
test('POST /api/outreach/enviar-manual exige numero', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json, body: { numero: '', mensagem: 'oi' } });
|
||||
|
||||
assert.equal(calls[0].status, 400);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual rejeita numero mal formatado antes de chamar o Z-PRO', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchChamado = false;
|
||||
global.fetch = async () => { fetchChamado = true; return { ok: true, text: async () => '{}' }; };
|
||||
try {
|
||||
await handler(null, null, { json, body: { numero: '55759990917800', canal: 'nao_oficial', mensagem: 'oi' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls[0].status, 400);
|
||||
assert.match(calls[0].data.erro, /numero invalido/);
|
||||
assert.equal(fetchChamado, false);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM outreach_sends').get().c, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual manda texto livre (nao-oficial) e registra origem=manual', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({ ok: true, text: async () => JSON.stringify({ data: { ticketId: 999 } }) });
|
||||
try {
|
||||
await handler(null, null, { json, body: { numero: '5511999990001', canal: 'nao_oficial', mensagem: 'Oi, tudo bem?' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(calls[0], { status: 200, data: { ok: true, ticketId: '999' } });
|
||||
const row = db.prepare('SELECT * FROM outreach_sends').get();
|
||||
assert.equal(row.origem, 'manual');
|
||||
assert.equal(row.lead_id, null);
|
||||
assert.equal(row.numero, '5511999990001');
|
||||
assert.equal(row.mensagem_enviada, 'Oi, tudo bem?');
|
||||
assert.equal(row.canal, 'nao_oficial');
|
||||
assert.equal(row.zpro_ticket_id, '999');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual bloqueia quando limite diario acabou', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
setSetting(db, 'outreach.mensagensPorDia', '1');
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem)
|
||||
VALUES (NULL, '', '5511999990001', 'ja enviado', 'enviado', 'nao_oficial', 'manual')`
|
||||
).run();
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchChamado = false;
|
||||
global.fetch = async () => { fetchChamado = true; return { ok: true, text: async () => '{}' }; };
|
||||
try {
|
||||
await handler(null, null, { json, body: { numero: '5511999990002', canal: 'nao_oficial', mensagem: 'Oi' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls[0].status, 429);
|
||||
assert.equal(calls[0].data.ok, false);
|
||||
assert.match(calls[0].data.erro, /limite diario atingido/);
|
||||
assert.equal(fetchChamado, false);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM outreach_sends').get().c, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual canal oficial exige nome do template', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json, body: { numero: '5511999990001', canal: 'oficial' } });
|
||||
|
||||
assert.equal(calls[0].status, 400);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual canal oficial envia multiplas variaveis do template', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let payload;
|
||||
global.fetch = async (url, options) => {
|
||||
payload = JSON.parse(options.body);
|
||||
return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":321}}' };
|
||||
};
|
||||
try {
|
||||
await handler(null, null, {
|
||||
json,
|
||||
body: {
|
||||
numero: '5511999990001',
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'gti_inicio_atendimento_solicitado',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateVariaveis: ['Gustavo', 'Chamado #123', '']
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(calls[0], { status: 200, data: { ok: true, ticketId: '321' } });
|
||||
assert.deepEqual(payload.templateData.template.components, [
|
||||
{ type: 'body', parameters: [{ type: 'text', text: 'Gustavo' }, { type: 'text', text: 'Chamado #123' }] }
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual resolve variaveis internas do GChat', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let payload;
|
||||
global.fetch = async (url, options) => {
|
||||
payload = JSON.parse(options.body);
|
||||
return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":321}}' };
|
||||
};
|
||||
try {
|
||||
await handler(null, null, {
|
||||
json,
|
||||
body: {
|
||||
numero: '5511999990001',
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'gti_teste',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateVariaveis: ['{{name}}', '{{protocol}}', '{{phoneNumber}}']
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(payload.templateData.template.components, [
|
||||
{ type: 'body', parameters: [
|
||||
{ type: 'text', text: 'Gustavo' },
|
||||
{ type: 'text', text: 'Atendimento comercial' },
|
||||
{ type: 'text', text: '5511999990001' }
|
||||
] }
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('POST /api/outreach/enviar-manual registra erro quando o Z-PRO falha', async () => {
|
||||
const db = openDb(':memory:');
|
||||
configurarCredenciais(db);
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('POST', '/api/outreach/enviar-manual');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({ ok: false, status: 400, text: async () => '{"erro":"numero nao existe no whatsapp"}' });
|
||||
try {
|
||||
await handler(null, null, { json, body: { numero: '5511999990001', canal: 'nao_oficial', mensagem: 'teste' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls[0].data.ok, false);
|
||||
const row = db.prepare('SELECT * FROM outreach_sends').get();
|
||||
assert.equal(row.status, 'erro');
|
||||
assert.match(row.erro, /numero nao existe/);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('GET /api/outreach/status inclui envios manuais em "ultimos" (LEFT JOIN, sem lead)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem)
|
||||
VALUES (NULL, '', '5511999990001', 'oi', 'enviado', 'nao_oficial', 'manual')`
|
||||
).run();
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('GET', '/api/outreach/status');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json });
|
||||
|
||||
const ultimo = calls[0].data.ultimos[0];
|
||||
assert.equal(ultimo.origem, 'manual');
|
||||
assert.equal(ultimo.numero, '5511999990001');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('GET /api/outreach/variables lista variaveis comerciais', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const router = setupRouter(db);
|
||||
const { handler } = router.match('GET', '/api/outreach/variables');
|
||||
const { json, calls } = fakeJson();
|
||||
|
||||
await handler(null, null, { json });
|
||||
|
||||
assert.equal(calls[0].status, 200);
|
||||
assert.ok(calls[0].data.variables.some((variable) => variable.key === 'empresa'));
|
||||
assert.ok(calls[0].data.variables.some((variable) => variable.key === 'name'));
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, setSetting, seedMessageTemplates, updateMessageTemplate } from '../src/db.mjs';
|
||||
import { renderTemplate } from '../src/outreach/templates.mjs';
|
||||
import { runOutreachBatch } from '../src/outreach/run.mjs';
|
||||
|
||||
function baseLead(overrides) {
|
||||
return {
|
||||
legal_name: '', ddd: '', email: '', segmento: '', segmento_label: '',
|
||||
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
|
||||
origem: 'cnpj-base', telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1,
|
||||
score: 80, classificacao: 'bom', motivo_classificacao: '', capture_run_id: null,
|
||||
captured_at: null, exported_at: null, prioridade: 1,
|
||||
produtos_json: JSON.stringify([{ nome: 'ERP', estrelas: 5 }, { nome: 'G-Chat', estrelas: 3 }]),
|
||||
produto_principal: 'ERP',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// libera a checagem de horario comercial em qualquer hora que o teste rode
|
||||
function ativarOutreach(db, extra = {}) {
|
||||
setSetting(db, 'outreach.ativo', 'true');
|
||||
setSetting(db, 'outreach.horaInicio', '00:00');
|
||||
setSetting(db, 'outreach.horaFim', '23:59');
|
||||
setSetting(db, 'outreach.mensagensPorDia', '1');
|
||||
setSetting(db, 'outreach.tentarProximoProduto', 'false');
|
||||
setSetting(db, 'outreach.diasEntreProdutos', '5');
|
||||
for (const [key, value] of Object.entries(extra)) setSetting(db, key, value);
|
||||
}
|
||||
|
||||
function fakeZproClient(overrides = {}) {
|
||||
const calls = { sendText: [], bulkIndividual: [], addTagContact: [], createTag: [], sendTemplateWaba: [] };
|
||||
return {
|
||||
calls,
|
||||
sendText: async (args) => {
|
||||
calls.sendText.push(args);
|
||||
return { data: { ticketId: 123 } };
|
||||
},
|
||||
bulkIndividual: async (args) => {
|
||||
calls.sendText.push(args);
|
||||
return { data: { ticketId: 123 } };
|
||||
},
|
||||
sendTemplateWaba: async (args) => {
|
||||
calls.sendTemplateWaba.push(args);
|
||||
return { data: { ticketId: 456 } };
|
||||
},
|
||||
listTags: async () => ({ data: [] }),
|
||||
createTag: async (args) => {
|
||||
calls.createTag.push(args);
|
||||
return { id: calls.createTag.length };
|
||||
},
|
||||
addTagContact: async (args) => {
|
||||
calls.addTagContact.push(args);
|
||||
return {};
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('renderTemplate substitui {empresa}/{cidade}/{segmento}', () => {
|
||||
const lead = { empresa: 'Padaria Sol', cidade: 'Ilheus', segmento_label: 'Padarias' };
|
||||
const texto = renderTemplate('Ola {empresa} de {cidade} ({segmento})', lead);
|
||||
assert.equal(texto, 'Ola Padaria Sol de Ilheus (Padarias)');
|
||||
});
|
||||
|
||||
test('renderTemplate usa fallback quando o lead nao tem o campo', () => {
|
||||
const texto = renderTemplate('Ola {empresa} de {cidade}', {});
|
||||
assert.equal(texto, 'Ola sua empresa de sua regiao');
|
||||
});
|
||||
|
||||
test('renderTemplate substitui {produto}', () => {
|
||||
const texto = renderTemplate('Oferta: {produto}', { produto: 'ERP' });
|
||||
assert.equal(texto, 'Oferta: ERP');
|
||||
});
|
||||
|
||||
test('renderTemplate substitui variaveis internas do GChat', () => {
|
||||
const texto = renderTemplate('{{greeting}} {{name}} {{protocol}} {{businessName}} {{phoneNumber}} {{user}}', {
|
||||
empresa: 'Padaria Sol', produto: 'ERP', whatsapp: '5511999990001'
|
||||
});
|
||||
assert.equal(texto, 'Ola Padaria Sol ERP Padaria Sol 5511999990001 Grupo GTI');
|
||||
});
|
||||
|
||||
test('runOutreachBatch nao faz nada quando outreach.ativo=false', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
setSetting(db, 'outreach.ativo', 'false');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch manda o produto principal, registra o envio e aplica tags', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'Padaria Sol', whatsapp: '5511999990001', cidade: 'Ilheus', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP', 'G-Chat']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(resumo.erros, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 1);
|
||||
assert.equal(zproClient.calls.sendText[0].number, '5511999990001');
|
||||
assert.match(zproClient.calls.sendText[0].message, /Padaria Sol/);
|
||||
assert.equal(zproClient.calls.addTagContact.length, 1);
|
||||
assert.deepEqual(zproClient.calls.addTagContact[0].number, '5511999990001');
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.produto, 'ERP');
|
||||
assert.equal(row.lead_id, 1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch nao manda de novo o mesmo produto pro mesmo lead (mesmo com orcamento sobrando)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
// ja existe um envio 'enviado' pra esse lead+produto, simulando uma rodada anterior
|
||||
// (sem chamar runOutreachBatch de novo, que dispararia o intervalo real de 3-8s).
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status) VALUES (1, 'ERP', 'msg', 'enviado')`
|
||||
).run();
|
||||
setSetting(db, 'outreach.mensagensPorDia', '10');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch ignora leads ja exportados', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({
|
||||
lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA',
|
||||
exported_at: new Date().toISOString()
|
||||
}));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch respeita o limite diario', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
setSetting(db, 'outreach.mensagensPorDia', '0');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch pula lead cujo template do produto esta inativo', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', { ativo: false });
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch registra erro e segue quando o Z-PRO falha', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient({
|
||||
sendText: async () => {
|
||||
throw new Error('falha simulada');
|
||||
}
|
||||
});
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.erros, 1);
|
||||
assert.equal(resumo.enviados, 0);
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'erro'").get();
|
||||
assert.match(row.erro, /falha simulada/);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch usa sendTemplateWaba (nao texto livre) quando o canal e oficial', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', {
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'promo_erp',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateWabaVariaveis: ['{empresa}', 'Assunto: {produto} em {cidade}']
|
||||
});
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
assert.equal(zproClient.calls.sendTemplateWaba.length, 1);
|
||||
assert.deepEqual(zproClient.calls.sendTemplateWaba[0], {
|
||||
number: '5511999990001', templateName: 'promo_erp', languageCode: 'pt_BR', variables: ['A', 'Assunto: ERP em Salvador']
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.canal, 'oficial');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch pula lead quando o canal e oficial mas o template nao foi configurado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', { canal: 'oficial' }); // sem templateWabaNome
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
assert.equal(zproClient.calls.sendTemplateWaba.length, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch grava canal=nao_oficial por padrao', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.canal, 'nao_oficial');
|
||||
db.close();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, getSetting, setSetting } from '../src/db.mjs';
|
||||
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../src/outreach/waba-template-library.mjs';
|
||||
|
||||
test('listWabaTemplateLibrary retorna modelos padrao', () => {
|
||||
const db = openDb(':memory:');
|
||||
const templates = listWabaTemplateLibrary(db);
|
||||
assert.ok(templates.some((template) => template.name === 'gti_inicio_atendimento_solicitado'));
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('syncWabaTemplateLibrary importa templates de resposta do GChat', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'gchat.templatesUrl', 'https://gchat.example/templates');
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
data: [{
|
||||
name: 'novo_template',
|
||||
language: 'pt_BR',
|
||||
category: 'UTILITY',
|
||||
components: [{ type: 'BODY', text: 'Ola {{1}}, assunto {{2}}' }]
|
||||
}]
|
||||
})
|
||||
});
|
||||
try {
|
||||
const result = await syncWabaTemplateLibrary(db);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.total, 1);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
const saved = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson'));
|
||||
assert.deepEqual(saved[0].variables, ['{empresa}', '{produto}']);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, setSetting } from '../src/db.mjs';
|
||||
import { zproConfigured, createZproClient } from '../src/outreach/zpro-client.mjs';
|
||||
|
||||
test('zproConfigured e false sem credenciais na tela nem no .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
delete process.env.ZPRO_BASE_URL;
|
||||
delete process.env.ZPRO_API_ID;
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
assert.equal(zproConfigured(db), false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('zproConfigured fica true com credenciais salvas na tela (settings)', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-secreto');
|
||||
assert.equal(zproConfigured(db), true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('zpro.bearerToken vazio na tela cai pro ZPRO_BEARER_TOKEN do .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
process.env.ZPRO_BEARER_TOKEN = 'token-do-env';
|
||||
assert.equal(zproConfigured(db), true);
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('valor preenchido na tela tem prioridade sobre o .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
process.env.ZPRO_BEARER_TOKEN = 'token-do-env';
|
||||
assert.equal(zproConfigured(db), true);
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('baseUrl colado com https:// na frente nao vira URL duplicada (https://https://...)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'https://apigchat.exemplo.com.br/');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let urlChamada;
|
||||
global.fetch = async (url) => {
|
||||
urlChamada = String(url);
|
||||
return { ok: true, text: async () => '{}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).listTags();
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/listTags?isActive=true');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('token colado com Bearer e normalizado antes de enviar', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'Bearer token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let authorization;
|
||||
global.fetch = async (url, options) => {
|
||||
authorization = options.headers.authorization;
|
||||
return { ok: true, text: async () => '{}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).listTags();
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(authorization, 'Bearer token-da-tela');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('erro de auth do Z-PRO vira mensagem acionavel', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => '{"error":"ERR_SESSION_NOT_AUTH_TOKEN"}'
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => createZproClient(db).listTags(),
|
||||
/Token e o API ID sao os de Admin > API/
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createTicket extrai ticketId de resposta com JSON embutido em message', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
success: true,
|
||||
data: { message: 'Ticket create successfully {"id":444202,"status":"pending"}', error: false }
|
||||
})
|
||||
});
|
||||
try {
|
||||
const resposta = await createZproClient(db).createTicket({
|
||||
number: '5511999990001',
|
||||
message: 'oi',
|
||||
externalKey: 'teste',
|
||||
userId: 15,
|
||||
channelId: 207
|
||||
});
|
||||
assert.equal(resposta.ticketId, '444202');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createTicket tenta sem channelId quando Z-PRO rejeita o canal informado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const bodies = [];
|
||||
global.fetch = async (url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
bodies.push(body);
|
||||
if (body.channelId) {
|
||||
return { ok: false, status: 404, text: async () => '{"success":false,"error":true}' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ success: true, data: { message: 'Ticket create successfully {"id":444206}' } })
|
||||
};
|
||||
};
|
||||
try {
|
||||
const resposta = await createZproClient(db).createTicket({
|
||||
number: '5511999990001',
|
||||
message: 'oi',
|
||||
externalKey: 'teste',
|
||||
userId: 15,
|
||||
channelId: 207
|
||||
});
|
||||
assert.equal(resposta.ticketId, '444206');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0].channelId, 207);
|
||||
assert.equal('channelId' in bodies[1], false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('sendTemplateWaba inclui multiplas variaveis no components.body.parameters', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let payload;
|
||||
global.fetch = async (url, options) => {
|
||||
payload = JSON.parse(options.body);
|
||||
return { ok: true, status: 200, text: async () => '{"success":true,"data":{"ticketId":123}}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).sendTemplateWaba({
|
||||
number: '5511999990001',
|
||||
templateName: 'confirma_suporte',
|
||||
languageCode: 'pt_BR',
|
||||
variables: ['Gustavo', 'Chamado #123']
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(payload.templateData.template.components, [
|
||||
{ type: 'body', parameters: [{ type: 'text', text: 'Gustavo' }, { type: 'text', text: 'Chamado #123' }] }
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('sendTemplateWaba permite escolher rota templateBody', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let urlChamada;
|
||||
global.fetch = async (url) => {
|
||||
urlChamada = String(url);
|
||||
return { ok: true, status: 200, text: async () => '{"success":true}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).sendTemplateWaba({
|
||||
number: '5511999990001',
|
||||
templateName: 'confirma_suporte',
|
||||
route: 'templateBody'
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/templateBody');
|
||||
db.close();
|
||||
});
|
||||
Reference in New Issue
Block a user