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(); });