Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: gti-maquina-comercial git-subtree-mainline:93ca82979egit-subtree-split:b77484946f
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
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();
|
|
});
|