Files
COMERCIAL-GTI/tests/outreach-routes.test.mjs
T
Gustavo d330bb5ff0 Baseline commit of the existing Maquina Comercial Pessoal project
Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
2026-07-31 10:54:07 -03:00

501 lines
20 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, addOptOut, createLeadInserter, setSetting } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerOutreachRoutes } from '../src/server/routes/outreach.mjs';
function setup({ state = 'READY' } = {}) {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const sent = [];
const whatsapp = {
getStatus: () => ({ state, connected: state === 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend?.();
sent.push(args);
const result = { messageId: 'local-123' };
await args.afterSend?.(result, null, context);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
return { db, router, sent };
}
async function invoke(router, method, pathname, { body, user = { role: 'comercial' } } = {}) {
const calls = [];
const matched = router.match(method, pathname);
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
await matched.handler(null, null, {
params: matched.params,
body,
user,
json: (status, data) => calls.push({ status, data })
});
return calls[0];
}
test('status de outreach informa conexao WhatsApp local', async () => {
const { db, router } = setup();
const response = await invoke(router, 'GET', '/api/outreach/status');
assert.equal(response.status, 200);
assert.equal(response.data.whatsapp.state, 'READY');
assert.equal(response.data.configurado, true);
db.close();
});
test('resultado desconhecido conta no limite diario', async () => {
const { db, router, sent } = setup();
setSetting(db, 'outreach.mensagensPorDia', '1');
db.prepare("INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990001', 'oi', 'desconhecido', 'manual')").run();
const status = await invoke(router, 'GET', '/api/outreach/status');
assert.equal(status.data.enviadosHoje, 1);
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990002', mensagem: 'Oi' }
});
assert.equal(response.status, 429);
assert.equal(sent.length, 0);
db.close();
});
test('envio manual exige READY, numero valido e mensagem', async () => {
const disconnected = setup({ state: 'DISCONNECTED' });
const response = await invoke(disconnected.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 409);
disconnected.db.close();
const ready = setup();
assert.equal((await invoke(ready.router, 'POST', '/api/outreach/enviar-manual', { body: { numero: '123', mensagem: 'Oi' } })).status, 400);
assert.equal((await invoke(ready.router, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990001', mensagem: '' } })).status, 400);
ready.db.close();
});
test('envio manual renderiza lead, envia local e registra messageId generico', async () => {
const { db, router, sent } = setup();
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: {
numero: '+55 (11) 99999-0001',
mensagem: 'Ola {empresa}, oferta de {produto} em {cidade}',
lead: { empresa: 'Padaria Sol', produto_principal: 'ERP', cidade: 'Ilheus' }
}
});
assert.deepEqual(response, { status: 202, data: { ok: true, status: 'aceito', messageId: 'local-123' } });
assert.equal(sent[0].number, '5511999990001');
assert.equal(sent[0].message, 'Ola Padaria Sol, oferta de ERP em Ilheus');
const row = db.prepare('SELECT * FROM outreach_sends').get();
assert.equal(row.provider_message_id, 'local-123');
assert.equal(row.status, 'aceito');
assert.equal(row.canal, 'local');
assert.equal(row.origem, 'manual');
db.close();
});
test('envio manual bloqueia opt-out e limite diario', async () => {
const opted = setup();
addOptOut(opted.db, '5511999990001', 'nao contatar');
const blocked = await invoke(opted.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(blocked.status, 409);
assert.equal(opted.sent.length, 0);
opted.db.close();
const limited = setup();
setSetting(limited.db, 'outreach.mensagensPorDia', '0');
const response = await invoke(limited.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 429);
limited.db.close();
});
test('rotas opt-out listam e restringem mutacoes a admin', async () => {
const { db, router } = setup();
const denied = await invoke(router, 'POST', '/api/outreach/opt-outs', {
body: { numero: '5511999990001' }, user: { role: 'comercial' }
});
assert.equal(denied.status, 403);
const created = await invoke(router, 'POST', '/api/outreach/opt-outs', {
body: { numero: '5511999990001', motivo: 'solicitado' }, user: { role: 'admin' }
});
assert.equal(created.status, 201);
const listed = await invoke(router, 'GET', '/api/outreach/opt-outs');
assert.equal(listed.data.optOuts.length, 1);
const removed = await invoke(router, 'DELETE', '/api/outreach/opt-outs/5511999990001', { user: { role: 'admin' } });
assert.equal(removed.status, 200);
db.close();
});
test('envio com leadId honra destino visivel e usa contexto completo do banco', async () => {
const { db, router, sent } = setup();
createLeadInserter(db)({
dedup_key: 'real-1', empresa: 'Empresa Real', whatsapp: '5511999990002', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { leadId: 1, numero: '5511999990001', mensagem: 'Oi {empresa} de {cidade}' }
});
assert.equal(response.status, 202);
assert.equal(sent[0].number, '5511999990001');
assert.equal(sent[0].message, 'Oi Empresa Real de Santos');
assert.equal(db.prepare('SELECT lead_id FROM outreach_sends').get().lead_id, 1);
assert.equal(db.prepare('SELECT numero FROM outreach_sends').get().numero, '5511999990001');
assert.equal(db.prepare('SELECT whatsapp FROM leads WHERE id = 1').get().whatsapp, '5511999990002');
assert.equal((await invoke(router, 'POST', '/api/outreach/enviar-manual', { body: { leadId: 999, numero: '5511999990001', mensagem: 'Oi' } })).status, 404);
db.close();
});
test('envio manual rejeita leadId coercivel antes de cache, reserva ou envio', async () => {
const { db, router, sent } = setup();
createLeadInserter(db)({
dedup_key: 'canonical-1', empresa: 'Empresa Real', whatsapp: '5511999990002', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache nao deveria ser tocado'); END;
`);
for (const leadId of [true, '1', '01', '1e0', 1.5, 0, -1, NaN, 999]) {
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { leadId, numero: '5511999990001', mensagem: 'Oi' }
});
assert.deepEqual(response, { status: 404, data: { erro: 'lead nao encontrado' } });
}
assert.equal(sent.length, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 0);
db.close();
});
test('envio manual persiste numero confirmado e devolve metadados resolvidos', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const result = {
messageId: 'id-1',
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
};
await args.afterSend(result, null, sendId);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5575999091780', mensagem: 'Oi' }
});
assert.deepEqual(response, {
status: 202,
data: {
ok: true,
status: 'aceito',
messageId: 'id-1',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
}
});
assert.deepEqual(
{ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() },
{
numero: '5575999091780',
status: 'confirmado',
numero_resolvido: '557599091780',
variante: 'sem_nono_digito'
}
);
db.close();
});
test('numero nao cadastrado no envio retorna 422 e persiste verificacao', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('numero nao esta cadastrado no WhatsApp'), {
numero: '5511888880000',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 422);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
{ numero: '5511999990001', status: 'nao_cadastrado' }
);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('timeout de consulta no envio persiste erro temporario antes de retornar 504', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504,
code: 'WHATSAPP_LOOKUP_TIMEOUT',
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 504);
assert.deepEqual(
{ ...db.prepare('SELECT status, erro FROM whatsapp_number_verifications').get() },
{ status: 'erro_temporario', erro: 'consulta excedeu o prazo' }
);
db.close();
});
test('falha de cache no numero nao cadastrado preserva 422 e aviso local', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('numero nao cadastrado'), {
status: 'nao_cadastrado', registered: false, resolvedNumber: null, variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 422);
assert.equal(response.data.ok, false);
assert.match(response.data.erro, /nao cadastrado/);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('falha de cache no timeout de lookup preserva 504 e aviso local', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504, status: 'erro_temporario', registered: false, resolvedNumber: null, variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 504);
assert.equal(response.data.ok, false);
assert.match(response.data.erro, /prazo/);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('timeout apos reserva finaliza uma vez como desconhecido e orienta a nao reenviar', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TABLE outreach_send_update_audit (send_id INTEGER, status TEXT);
CREATE TRIGGER audit_outreach_send_update AFTER UPDATE ON outreach_sends
BEGIN
INSERT INTO outreach_send_update_audit (send_id, status) VALUES (NEW.id, NEW.status);
END;
`);
let callbackCalls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const error = Object.assign(new Error('envio excedeu o prazo'), {
statusCode: 504,
code: 'WHATSAPP_SEND_TIMEOUT',
deliveryUnknown: true,
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
});
callbackCalls += 1;
await args.afterSend(null, error, sendId);
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5575999091780', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'resultado_desconhecido');
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(callbackCalls, 1);
const row = db.prepare('SELECT status, erro FROM outreach_sends').get();
assert.equal(row.status, 'desconhecido');
assert.match(row.erro, /prazo/);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
{ numero: '5575999091780', status: 'confirmado' }
);
assert.deepEqual(
db.prepare('SELECT status FROM outreach_send_update_audit').all().map((audit) => audit.status),
['desconhecido']
);
db.close();
});
test('falha secundaria no cache preserva resposta aceita e orienta a nao reenviar', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const result = { messageId: 'aceita-1', resolvedNumber: '5511999990001', variant: 'exata' };
await args.afterSend(result, null, sendId);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'aceito');
assert.equal(response.data.messageId, 'aceita-1');
assert.match(response.data.aviso, /verificacao local/i);
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'aceito');
db.close();
});
test('falha secundaria no cache preserva resultado desconhecido e aviso de nao reenvio', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const error = Object.assign(new Error('envio excedeu o prazo'), {
deliveryUnknown: true,
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '5511999990001',
variant: 'exata'
});
await args.afterSend(null, error, sendId);
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'resultado_desconhecido');
assert.match(response.data.aviso, /verificacao local/i);
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'desconhecido');
db.close();
});
test('duas requisicoes manuais concorrentes respeitam limite dentro da fila', async () => {
const { db, router } = setup();
setSetting(db, 'outreach.mensagensPorDia', '1');
const calls = [];
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => { const context = await args.beforeSend(); const result = { messageId: 'id' }; await args.afterSend(result, null, context); return result; }
};
const concurrentRouter = createRouter();
registerOutreachRoutes(concurrentRouter, { db, whatsapp });
const responses = await Promise.all([
invoke(concurrentRouter, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990001', mensagem: 'um' } }),
invoke(concurrentRouter, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990002', mensagem: 'dois' } })
]);
calls.push(...responses.map((r) => r.status));
assert.deepEqual(calls.sort(), [202, 429]);
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_sends WHERE status = 'aceito'").get().total, 1);
db.close();
});
test('aceite com falha de persistencia orienta a nao reenviar', async () => {
const db = openDb(':memory:');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
const error = new Error('banco indisponivel');
error.deliverySucceeded = true;
error.messageId = 'possibly-sent-1';
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'persistencia_incerta');
assert.match(response.data.aviso, /nao reenvie/i);
db.close();
});