Add WABA outreach automation (Z-PRO integration)
Per-product message templates, official/não-oficial channel selection, manual per-number sending, and a 20-minute internal scheduler for the existing automatic outreach batch — all pre-existing local work being committed as-is.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user