Carry over pre-existing WABA outreach automation WIP
This work already existed uncommitted in the main working tree (not authored by this branch) and is required as a runtime dependency for the campaign outreach port (zpro-client, templates, outreach routes/UI). Copied in as-is, untouched, so this feature branch has a working baseline; the original uncommitted state in the main checkout is left exactly as it was.
This commit is contained in:
+17
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings } from '../src/db.mjs';
|
||||
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings, seedMessageTemplates, updateMessageTemplate } from '../src/db.mjs';
|
||||
|
||||
function sampleLead(overrides = {}) {
|
||||
return {
|
||||
@@ -90,6 +90,22 @@ test('settings: get/set e seed com defaults Z-PRO', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('message_templates salva variaveis WABA automaticas por produto', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
|
||||
const updated = updateMessageTemplate(db, 'ERP', {
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'gti_inicio_atendimento_solicitado',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateWabaVariaveis: ['{empresa}', 'Assunto: {produto}', '']
|
||||
});
|
||||
|
||||
assert.equal(updated.template_waba_nome, 'gti_inicio_atendimento_solicitado');
|
||||
assert.deepEqual(JSON.parse(updated.template_waba_variaveis_json), ['{empresa}', 'Assunto: {produto}']);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('seeds de segments, municipios e geo_cache', () => {
|
||||
const db = openDb(':memory:');
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, setSetting, seedMessageTemplates, updateMessageTemplate } from '../src/db.mjs';
|
||||
import { renderTemplate } from '../src/outreach/templates.mjs';
|
||||
import { runOutreachBatch } from '../src/outreach/run.mjs';
|
||||
|
||||
function baseLead(overrides) {
|
||||
return {
|
||||
legal_name: '', ddd: '', email: '', segmento: '', segmento_label: '',
|
||||
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
|
||||
origem: 'cnpj-base', telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1,
|
||||
score: 80, classificacao: 'bom', motivo_classificacao: '', capture_run_id: null,
|
||||
captured_at: null, exported_at: null, prioridade: 1,
|
||||
produtos_json: JSON.stringify([{ nome: 'ERP', estrelas: 5 }, { nome: 'G-Chat', estrelas: 3 }]),
|
||||
produto_principal: 'ERP',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// libera a checagem de horario comercial em qualquer hora que o teste rode
|
||||
function ativarOutreach(db, extra = {}) {
|
||||
setSetting(db, 'outreach.ativo', 'true');
|
||||
setSetting(db, 'outreach.horaInicio', '00:00');
|
||||
setSetting(db, 'outreach.horaFim', '23:59');
|
||||
setSetting(db, 'outreach.mensagensPorDia', '1');
|
||||
setSetting(db, 'outreach.tentarProximoProduto', 'false');
|
||||
setSetting(db, 'outreach.diasEntreProdutos', '5');
|
||||
for (const [key, value] of Object.entries(extra)) setSetting(db, key, value);
|
||||
}
|
||||
|
||||
function fakeZproClient(overrides = {}) {
|
||||
const calls = { sendText: [], bulkIndividual: [], addTagContact: [], createTag: [], sendTemplateWaba: [] };
|
||||
return {
|
||||
calls,
|
||||
sendText: async (args) => {
|
||||
calls.sendText.push(args);
|
||||
return { data: { ticketId: 123 } };
|
||||
},
|
||||
bulkIndividual: async (args) => {
|
||||
calls.sendText.push(args);
|
||||
return { data: { ticketId: 123 } };
|
||||
},
|
||||
sendTemplateWaba: async (args) => {
|
||||
calls.sendTemplateWaba.push(args);
|
||||
return { data: { ticketId: 456 } };
|
||||
},
|
||||
listTags: async () => ({ data: [] }),
|
||||
createTag: async (args) => {
|
||||
calls.createTag.push(args);
|
||||
return { id: calls.createTag.length };
|
||||
},
|
||||
addTagContact: async (args) => {
|
||||
calls.addTagContact.push(args);
|
||||
return {};
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('renderTemplate substitui {empresa}/{cidade}/{segmento}', () => {
|
||||
const lead = { empresa: 'Padaria Sol', cidade: 'Ilheus', segmento_label: 'Padarias' };
|
||||
const texto = renderTemplate('Ola {empresa} de {cidade} ({segmento})', lead);
|
||||
assert.equal(texto, 'Ola Padaria Sol de Ilheus (Padarias)');
|
||||
});
|
||||
|
||||
test('renderTemplate usa fallback quando o lead nao tem o campo', () => {
|
||||
const texto = renderTemplate('Ola {empresa} de {cidade}', {});
|
||||
assert.equal(texto, 'Ola sua empresa de sua regiao');
|
||||
});
|
||||
|
||||
test('renderTemplate substitui {produto}', () => {
|
||||
const texto = renderTemplate('Oferta: {produto}', { produto: 'ERP' });
|
||||
assert.equal(texto, 'Oferta: ERP');
|
||||
});
|
||||
|
||||
test('renderTemplate substitui variaveis internas do GChat', () => {
|
||||
const texto = renderTemplate('{{greeting}} {{name}} {{protocol}} {{businessName}} {{phoneNumber}} {{user}}', {
|
||||
empresa: 'Padaria Sol', produto: 'ERP', whatsapp: '5511999990001'
|
||||
});
|
||||
assert.equal(texto, 'Ola Padaria Sol ERP Padaria Sol 5511999990001 Grupo GTI');
|
||||
});
|
||||
|
||||
test('runOutreachBatch nao faz nada quando outreach.ativo=false', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
setSetting(db, 'outreach.ativo', 'false');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch manda o produto principal, registra o envio e aplica tags', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'Padaria Sol', whatsapp: '5511999990001', cidade: 'Ilheus', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP', 'G-Chat']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(resumo.erros, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 1);
|
||||
assert.equal(zproClient.calls.sendText[0].number, '5511999990001');
|
||||
assert.match(zproClient.calls.sendText[0].message, /Padaria Sol/);
|
||||
assert.equal(zproClient.calls.addTagContact.length, 1);
|
||||
assert.deepEqual(zproClient.calls.addTagContact[0].number, '5511999990001');
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.produto, 'ERP');
|
||||
assert.equal(row.lead_id, 1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch nao manda de novo o mesmo produto pro mesmo lead (mesmo com orcamento sobrando)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
// ja existe um envio 'enviado' pra esse lead+produto, simulando uma rodada anterior
|
||||
// (sem chamar runOutreachBatch de novo, que dispararia o intervalo real de 3-8s).
|
||||
db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status) VALUES (1, 'ERP', 'msg', 'enviado')`
|
||||
).run();
|
||||
setSetting(db, 'outreach.mensagensPorDia', '10');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch ignora leads ja exportados', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({
|
||||
lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA',
|
||||
exported_at: new Date().toISOString()
|
||||
}));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch respeita o limite diario', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
setSetting(db, 'outreach.mensagensPorDia', '0');
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.deepEqual(resumo, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch pula lead cujo template do produto esta inativo', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', { ativo: false });
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch registra erro e segue quando o Z-PRO falha', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient({
|
||||
sendText: async () => {
|
||||
throw new Error('falha simulada');
|
||||
}
|
||||
});
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.erros, 1);
|
||||
assert.equal(resumo.enviados, 0);
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'erro'").get();
|
||||
assert.match(row.erro, /falha simulada/);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch usa sendTemplateWaba (nao texto livre) quando o canal e oficial', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', {
|
||||
canal: 'oficial',
|
||||
templateWabaNome: 'promo_erp',
|
||||
templateWabaIdioma: 'pt_BR',
|
||||
templateWabaVariaveis: ['{empresa}', 'Assunto: {produto} em {cidade}']
|
||||
});
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 1);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
assert.equal(zproClient.calls.sendTemplateWaba.length, 1);
|
||||
assert.deepEqual(zproClient.calls.sendTemplateWaba[0], {
|
||||
number: '5511999990001', templateName: 'promo_erp', languageCode: 'pt_BR', variables: ['A', 'Assunto: ERP em Salvador']
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.canal, 'oficial');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch pula lead quando o canal e oficial mas o template nao foi configurado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
updateMessageTemplate(db, 'ERP', { canal: 'oficial' }); // sem templateWabaNome
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(resumo.pulados, 1);
|
||||
assert.equal(zproClient.calls.sendTemplateWaba.length, 0);
|
||||
assert.equal(zproClient.calls.sendText.length, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runOutreachBatch grava canal=nao_oficial por padrao', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
insert(baseLead({ lead_uid: 'A', dedup_key: 'k1', empresa: 'A', whatsapp: '5511999990001', cidade: 'Salvador', uf: 'BA' }));
|
||||
seedMessageTemplates(db, ['ERP']);
|
||||
ativarOutreach(db);
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
await runOutreachBatch({ db, zproClient, log: () => {} });
|
||||
|
||||
const row = db.prepare("SELECT * FROM outreach_sends WHERE status = 'enviado'").get();
|
||||
assert.equal(row.canal, 'nao_oficial');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, getSetting, setSetting } from '../src/db.mjs';
|
||||
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../src/outreach/waba-template-library.mjs';
|
||||
|
||||
test('listWabaTemplateLibrary retorna modelos padrao', () => {
|
||||
const db = openDb(':memory:');
|
||||
const templates = listWabaTemplateLibrary(db);
|
||||
assert.ok(templates.some((template) => template.name === 'gti_inicio_atendimento_solicitado'));
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('syncWabaTemplateLibrary importa templates de resposta do GChat', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'gchat.templatesUrl', 'https://gchat.example/templates');
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
data: [{
|
||||
name: 'novo_template',
|
||||
language: 'pt_BR',
|
||||
category: 'UTILITY',
|
||||
components: [{ type: 'BODY', text: 'Ola {{1}}, assunto {{2}}' }]
|
||||
}]
|
||||
})
|
||||
});
|
||||
try {
|
||||
const result = await syncWabaTemplateLibrary(db);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.total, 1);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
const saved = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson'));
|
||||
assert.deepEqual(saved[0].variables, ['{empresa}', '{produto}']);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, setSetting } from '../src/db.mjs';
|
||||
import { zproConfigured, createZproClient } from '../src/outreach/zpro-client.mjs';
|
||||
|
||||
test('zproConfigured e false sem credenciais na tela nem no .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
delete process.env.ZPRO_BASE_URL;
|
||||
delete process.env.ZPRO_API_ID;
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
assert.equal(zproConfigured(db), false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('zproConfigured fica true com credenciais salvas na tela (settings)', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-secreto');
|
||||
assert.equal(zproConfigured(db), true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('zpro.bearerToken vazio na tela cai pro ZPRO_BEARER_TOKEN do .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
process.env.ZPRO_BEARER_TOKEN = 'token-do-env';
|
||||
assert.equal(zproConfigured(db), true);
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('valor preenchido na tela tem prioridade sobre o .env', () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'exemplo.zpro.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
process.env.ZPRO_BEARER_TOKEN = 'token-do-env';
|
||||
assert.equal(zproConfigured(db), true);
|
||||
delete process.env.ZPRO_BEARER_TOKEN;
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('baseUrl colado com https:// na frente nao vira URL duplicada (https://https://...)', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'https://apigchat.exemplo.com.br/');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let urlChamada;
|
||||
global.fetch = async (url) => {
|
||||
urlChamada = String(url);
|
||||
return { ok: true, text: async () => '{}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).listTags();
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/listTags?isActive=true');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('token colado com Bearer e normalizado antes de enviar', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'Bearer token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let authorization;
|
||||
global.fetch = async (url, options) => {
|
||||
authorization = options.headers.authorization;
|
||||
return { ok: true, text: async () => '{}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).listTags();
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(authorization, 'Bearer token-da-tela');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('erro de auth do Z-PRO vira mensagem acionavel', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => '{"error":"ERR_SESSION_NOT_AUTH_TOKEN"}'
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => createZproClient(db).listTags(),
|
||||
/Token e o API ID sao os de Admin > API/
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createTicket extrai ticketId de resposta com JSON embutido em message', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
success: true,
|
||||
data: { message: 'Ticket create successfully {"id":444202,"status":"pending"}', error: false }
|
||||
})
|
||||
});
|
||||
try {
|
||||
const resposta = await createZproClient(db).createTicket({
|
||||
number: '5511999990001',
|
||||
message: 'oi',
|
||||
externalKey: 'teste',
|
||||
userId: 15,
|
||||
channelId: 207
|
||||
});
|
||||
assert.equal(resposta.ticketId, '444202');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createTicket tenta sem channelId quando Z-PRO rejeita o canal informado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const bodies = [];
|
||||
global.fetch = async (url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
bodies.push(body);
|
||||
if (body.channelId) {
|
||||
return { ok: false, status: 404, text: async () => '{"success":false,"error":true}' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ success: true, data: { message: 'Ticket create successfully {"id":444206}' } })
|
||||
};
|
||||
};
|
||||
try {
|
||||
const resposta = await createZproClient(db).createTicket({
|
||||
number: '5511999990001',
|
||||
message: 'oi',
|
||||
externalKey: 'teste',
|
||||
userId: 15,
|
||||
channelId: 207
|
||||
});
|
||||
assert.equal(resposta.ticketId, '444206');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0].channelId, 207);
|
||||
assert.equal('channelId' in bodies[1], false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('sendTemplateWaba inclui multiplas variaveis no components.body.parameters', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
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":123}}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).sendTemplateWaba({
|
||||
number: '5511999990001',
|
||||
templateName: 'confirma_suporte',
|
||||
languageCode: 'pt_BR',
|
||||
variables: ['Gustavo', 'Chamado #123']
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.deepEqual(payload.templateData.template.components, [
|
||||
{ type: 'body', parameters: [{ type: 'text', text: 'Gustavo' }, { type: 'text', text: 'Chamado #123' }] }
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('sendTemplateWaba permite escolher rota templateBody', async () => {
|
||||
const db = openDb(':memory:');
|
||||
setSetting(db, 'zpro.baseUrl', 'apigchat.exemplo.com.br');
|
||||
setSetting(db, 'zpro.apiId', 'abc123');
|
||||
setSetting(db, 'zpro.bearerToken', 'token-da-tela');
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let urlChamada;
|
||||
global.fetch = async (url) => {
|
||||
urlChamada = String(url);
|
||||
return { ok: true, status: 200, text: async () => '{"success":true}' };
|
||||
};
|
||||
try {
|
||||
await createZproClient(db).sendTemplateWaba({
|
||||
number: '5511999990001',
|
||||
templateName: 'confirma_suporte',
|
||||
route: 'templateBody'
|
||||
});
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(urlChamada, 'https://apigchat.exemplo.com.br/v2/api/external/abc123/templateBody');
|
||||
db.close();
|
||||
});
|
||||
Reference in New Issue
Block a user