Files
GustavoandClaude Sonnet 5 417da56bd6 chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
2026-09-28 15:50:17 -03:00

574 lines
23 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 manual bloqueia numero compartilhado por leads distintos ate liberacao', async () => {
const { db, router } = setup();
const insert = createLeadInserter(db);
insert({
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
insert({
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const blocked = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990009', mensagem: 'Oi' }
});
assert.equal(blocked.status, 409);
await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'confirmado contato direto' }, user: { role: 'admin' }
});
const allowed = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990009', mensagem: 'Oi' }
});
assert.equal(allowed.status, 202);
db.close();
});
test('rotas de numeros compartilhados listam e restringem liberacao a admin', async () => {
const { db, router } = setup();
const insert = createLeadInserter(db);
insert({
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
insert({
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const listed = await invoke(router, 'GET', '/api/outreach/shared-numbers');
assert.equal(listed.data.groups.length, 1);
assert.equal(listed.data.groups[0].leads.length, 2);
const denied = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'teste' }, user: { role: 'comercial' }
});
assert.equal(denied.status, 403);
const missingMotivo = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: {}, user: { role: 'admin' }
});
assert.equal(missingMotivo.status, 400);
const created = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'confirmado' }, user: { role: 'admin' }
});
assert.equal(created.status, 201);
const removeDenied = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'comercial' } });
assert.equal(removeDenied.status, 403);
const removed = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
assert.equal(removed.status, 200);
const removedAgain = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
assert.equal(removedAgain.status, 404);
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();
});