Add worker safety rails; split evaluateCampaignHealth into pure read + mutating apply

Two issues in the campaign worker path:

1. runCampaignQueue had none of the safety rails run.mjs's automatic sender
   has: no outreach.ativo gate, no business-hours window, no jitter between
   sends. A campaign left "rodando" would send around the clock even with
   outreach disabled or outside business hours, and would hammer the same
   WhatsApp number back-to-back. Added the same outreach.ativo/horaInicio/
   horaFim checks (duplicated, not imported, to keep the modules
   independent) plus a randomized 3-8s jitter between real send attempts.
   Sleep delay is injectable via an optional sleepMs param so tests can
   pass sleepMs: 0.

2. evaluateCampaignHealth mutated state (via applyPause) on every call,
   including from the read-only GET /api/campaigns/:id/saude route -- so
   simply opening the campaign detail panel in the UI could silently pause
   a healthy-looking campaign. Split it into a pure evaluateCampaignHealth
   (computes {pause, reason}, never writes) and a new applyCampaignHealth
   (evaluates, then persists the pause if warranted). The worker now calls
   applyCampaignHealth; the GET /saude route keeps calling the pure
   evaluateCampaignHealth.

Tests: worker sends nothing with outreach.ativo=false or outside business
hours; evaluateCampaignHealth confirmed not to mutate status even when it
would return pause:true; existing mutation assertions moved onto
applyCampaignHealth.
This commit is contained in:
Gustavo
2026-08-03 13:07:03 -03:00
parent 3a91ca4732
commit 6f6ed97206
4 changed files with 149 additions and 14 deletions
+31 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
function inserirLead(db, dedup) {
const inserir = createLeadInserter(db);
@@ -63,11 +63,41 @@ test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 1
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_failure_rate');
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(semMutacao.status, 'rodando');
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
const aplicado = applyCampaignHealth(db, campanha.id);
assert.equal(aplicado.pause, true);
assert.equal(aplicado.reason, 'high_failure_rate');
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 8; i += 1) {
const lead = inserirLead(db, `puro-ok${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
for (let i = 0; i < 2; i += 1) {
const lead = inserirLead(db, `puro-err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
for (let i = 0; i < 5; i += 1) {
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
}
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
db.close();
});
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
+55 -7
View File
@@ -1,10 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
// horario real em que o teste roda.
function ativarWorker(db) {
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '00:00');
setSetting(db, 'outreach.horaFim', '23:59');
}
function inserirLead(db, dedup, whatsapp) {
const inserir = createLeadInserter(db);
inserir({
@@ -43,10 +53,11 @@ function fakeZproClient(overrides = {}) {
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
@@ -62,6 +73,7 @@ test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 2);
let chamada = 0;
const zproClient = fakeZproClient({
@@ -72,7 +84,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
}
});
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(resumo.erros, 1);
const erro = listQueue(db, campanha.id, 'error')[0];
@@ -83,6 +95,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
test('runCampaignQueue respeita o limite diario da campanha', async () => {
const db = openDb(':memory:');
ativarWorker(db);
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
@@ -93,7 +106,7 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
@@ -102,12 +115,13 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
addOptOut(db, '5511999990000', 'pediu pra parar');
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.pulados, 1);
assert.equal(zproClient.enviados.length, 0);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
@@ -117,6 +131,7 @@ test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depo
test('runCampaignQueue so processa campanhas com status rodando', async () => {
const db = openDb(':memory:');
ativarWorker(db);
inserirLead(db, 'l1', '5511999990001');
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
@@ -126,7 +141,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
// campanha continua em 'rascunho' — nunca foi iniciada
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient });
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
db.close();
@@ -134,6 +149,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 10);
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
let chamada = 0;
@@ -145,9 +161,41 @@ test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa',
}
});
await runCampaignQueue({ db, zproClient });
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
const db = openDb(':memory:');
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '08:00');
setSetting(db, 'outreach.horaFim', '18:00');
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
const foraDoHorario = new Date();
foraDoHorario.setHours(3, 0, 0, 0);
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});