Files
COMERCIAL-GTI/tests/campaign-worker.test.mjs
T
Gustavo 0c36f27cb3 Fix opt-out asymmetric 8/9-digit bypass letting sends reach blocked numbers
isOptedOut/addOptOut/removeOptOut used equivalentPhoneNumbers, which is not
symmetric for a 12-digit local outside the 6-9 range vs its 13-digit
counterpart (same root cause already fixed for shared-number detection).
An opt-out registered against one form of such a number silently failed to
block sends to a lead whose number was stored in the other form, on both
the manual send path (reserveOutreachSend) and, independently, in two
inlined checks that bypassed isOptedOut entirely: the last-mile recheck in
campaign-worker.mjs's reserveCampaignQueueSend, and the opt-out NOT EXISTS
clause in campaign-queue.mjs's candidate query. All three now use the
symmetric canonicalPhoneVariants key already introduced for shared-number
overrides, and the worker's last-mile check now calls isOptedOut directly
instead of re-deriving its own number list.

Found via a full-project audit; reproduced and verified before fixing.
2026-07-31 22:04:19 -03:00

762 lines
32 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Worker } from 'node:worker_threads';
import {
addOptOut,
addSharedNumberOverride,
openDb,
recoverPendingOutreach,
removeSharedNumberOverride,
reserveOutreachSend,
saveWhatsAppVerification,
setSetting
} from '../src/db.mjs';
import { seedCampaigns } from '../src/outreach/campaign-store.mjs';
async function workerModule() {
return import('../src/outreach/campaign-worker.mjs');
}
function setup() {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
return { db, campaign };
}
function addQueue(db, campaign, {
suffix,
status = 'scheduled',
scheduledAt = '2026-07-29T12:00:00.000Z',
confirmed = true
}) {
const numero = `55119999${String(suffix).padStart(5, '0')}`;
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES (?, ?, ?, 'restaurantes', 1)`
).run(`worker-${suffix}`, `Empresa ${suffix}`, numero).lastInsertRowid);
if (confirmed) saveWhatsAppVerification(db, { numero, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, ?, 'restaurantes', ?, 'teste', ?, ?)`
).run(
campaign.id,
leadId,
variantId,
campaign.produto,
numero,
`Mensagem ${suffix}`,
status,
scheduledAt
).lastInsertRowid);
return { queueId, leadId, numero };
}
function whatsappFake({ state = 'READY', beforeProvider = async () => {}, outcome = 'success' } = {}) {
let providerCalls = 0;
return {
get providerCalls() { return providerCalls; },
getStatus: () => ({ state }),
sendText: async (args) => {
await beforeProvider(args);
const context = await args.beforeSend();
providerCalls += 1;
if (outcome === 'success') {
const result = { messageId: `message-${providerCalls}` };
await args.afterSend(result, null, context);
return result;
}
const error = Object.assign(new Error(outcome), {
deliveryUnknown: outcome === 'unknown'
});
await args.afterSend(null, error, context);
throw error;
}
};
}
test('worker processa somente o primeiro item scheduled vencido', async () => {
const { runDueCampaignQueue } = await workerModule();
assert.equal(typeof runDueCampaignQueue, 'function');
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 1 });
const second = addQueue(db, campaign, { suffix: 2 });
const future = addQueue(db, campaign, { suffix: 3, scheduledAt: '2026-07-29T12:01:00.000Z' });
const approved = addQueue(db, campaign, { suffix: 4, status: 'approved', scheduledAt: null });
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 1, erros: 0, pulados: 0 });
assert.equal(whatsapp.providerCalls, 1);
const statuses = new Map(db.prepare(
'SELECT id, status FROM outreach_campaign_queue ORDER BY id'
).all().map((row) => [row.id, row.status]));
assert.equal(statuses.get(first.queueId), 'sent');
assert.equal(statuses.get(second.queueId), 'scheduled');
assert.equal(statuses.get(future.queueId), 'scheduled');
assert.equal(statuses.get(approved.queueId), 'approved');
db.close();
});
test('worker cancela item permanentemente inelegivel e continua no proximo vencido', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const poisoned = addQueue(db, campaign, { suffix: 5, confirmed: false });
const eligible = addQueue(db, campaign, { suffix: 6 });
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 2, enviados: 1, erros: 0, pulados: 1 });
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(poisoned.queueId) },
{ status: 'cancelled', cancel_reason: 'whatsapp_not_confirmed' }
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(eligible.queueId).status, 'sent');
assert.equal(whatsapp.providerCalls, 1);
db.close();
});
test('worker mantem itens agendados e para no primeiro bloqueio transitorio de limite', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 7 });
const second = addQueue(db, campaign, { suffix: 8 });
db.prepare('UPDATE outreach_campaigns SET hourly_limit = 0 WHERE id = ?').run(campaign.id);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 1, pulados: 0 });
assert.deepEqual(
db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(first.queueId, second.queueId).map((row) => row.status),
['scheduled', 'scheduled']
);
assert.equal(whatsapp.providerCalls, 0);
db.close();
});
test('worker mantem dois itens agendados e para quando conexao cai antes da reserva', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 12 });
const second = addQueue(db, campaign, { suffix: 13 });
let state = 'READY';
let providerCalls = 0;
const whatsapp = {
getStatus: () => ({ state }),
sendText: async (args) => {
state = 'DISCONNECTED';
await args.beforeSend();
providerCalls += 1;
}
};
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 1, pulados: 0 });
assert.deepEqual(
db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(first.queueId, second.queueId).map((row) => row.status),
['scheduled', 'scheduled']
);
assert.equal(providerCalls, 0);
db.close();
});
test('worker cancela numero rejeitado no lookup antes de beforeSend e continua', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const rejected = addQueue(db, campaign, { suffix: 9 });
const eligible = addQueue(db, campaign, { suffix: 11 });
let calls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
calls += 1;
if (calls === 1) {
throw Object.assign(new Error('numero nao cadastrado'), {
status: 'nao_cadastrado',
registered: false
});
}
const context = await args.beforeSend();
const result = { messageId: 'second-message' };
await args.afterSend(result, null, context);
return result;
}
};
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 2, enviados: 1, erros: 0, pulados: 1 });
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(rejected.queueId) },
{ status: 'cancelled', cancel_reason: 'whatsapp_not_confirmed' }
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(eligible.queueId).status, 'sent');
db.close();
});
test('reserva atomica vincula fila e send sem consumir limite manual', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 10 });
setSetting(db, 'outreach.mensagensPorDia', '1');
reserveOutreachSend(db, { numero: '5511999999999', mensagem: 'manual' });
setSetting(db, 'outreach.ativo', 'true');
assert.throws(
() => reserveOutreachSend(db, {
leadId: item.leadId,
produto: campaign.produto,
numero: item.numero,
mensagem: 'atalho automatico',
origem: 'automatico'
}),
/fila supervisionada/i
);
const reserved = reserveCampaignQueueSend(db, {
queueId: item.queueId,
now: new Date('2026-07-29T12:00:00.000Z')
});
assert.deepEqual(reserved, { queueId: item.queueId, sendId: 2 });
assert.deepEqual(
{ ...db.prepare('SELECT status, outreach_send_id FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
{ status: 'reserved', outreach_send_id: 2 }
);
assert.deepEqual(
{ ...db.prepare('SELECT lead_id, produto, numero, mensagem_enviada, status, origem FROM outreach_sends WHERE id = 2').get() },
{
lead_id: item.leadId,
produto: campaign.produto,
numero: item.numero,
mensagem_enviada: 'Mensagem 10',
status: 'pendente',
origem: 'automatico'
}
);
db.close();
});
test('reserva aplica limite diario da campanha e quatro tentativas na hora rolante', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const { db, campaign } = setup();
const due = addQueue(db, campaign, { suffix: 20 });
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10, hourly_limit = 4 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 4; index += 1) {
const old = addQueue(db, campaign, { suffix: 21 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', ?)`
).run(old.leadId, campaign.produto, old.numero, `2026-07-29 11:${String(10 + index).padStart(2, '0')}:00`).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, old.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(old.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(db, { queueId: due.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite.*hora/i
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(due.queueId).status, 'scheduled');
db.prepare("UPDATE outreach_sends SET sent_at = '2026-07-29 10:59:59'").run();
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 6; index += 1) {
const old = addQueue(db, campaign, { suffix: 30 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29 09:00:00')`
).run(old.leadId, campaign.produto, old.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, old.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(old.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(db, { queueId: due.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite diario/i
);
db.close();
});
test('duas conexoes nao reservam a mesma fila nem ultrapassam o limite diario', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-worker-'));
const dbPath = path.join(dir, 'worker.sqlite');
const db = openDb(dbPath);
const { campaign } = (() => {
seedCampaigns(db);
const seeded = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(seeded.id);
return { campaign: seeded };
})();
const first = addQueue(db, campaign, { suffix: 70 });
const second = addQueue(db, campaign, { suffix: 71 });
const other = openDb(dbPath);
const sameQueue = await Promise.allSettled([
Promise.resolve().then(() => reserveCampaignQueueSend(db, { queueId: first.queueId, now: new Date('2026-07-29T12:00:00.000Z') })),
Promise.resolve().then(() => reserveCampaignQueueSend(other, { queueId: first.queueId, now: new Date('2026-07-29T12:00:00.000Z') }))
]);
assert.deepEqual(sameQueue.map((result) => result.status).sort(), ['fulfilled', 'rejected']);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 1);
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 9; index += 1) {
const item = addQueue(db, campaign, { suffix: 80 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29T08:00:00.000Z')`
).run(item.leadId, campaign.produto, item.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(item.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(other, { queueId: second.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite diario/i
);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 10);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(second.queueId).status, 'scheduled');
other.close();
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
test('segunda conexao aguarda lock e reconta o limite apos commit concorrente', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-cap-lock-'));
const dbPath = path.join(dir, 'worker.sqlite');
const db = openDb(dbPath);
seedCampaigns(db);
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(campaign.id);
const committing = addQueue(db, campaign, { suffix: 90 });
const blocked = addQueue(db, campaign, { suffix: 91 });
for (let index = 0; index < 9; index += 1) {
const item = addQueue(db, campaign, { suffix: 920 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29T08:00:00.000Z')`
).run(item.leadId, campaign.produto, item.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(item.queueId);
}
const other = openDb(dbPath);
const worker = new Worker(`
const { parentPort, workerData } = require('node:worker_threads');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.dbPath);
db.exec('PRAGMA busy_timeout = 5000; BEGIN IMMEDIATE;');
const inserted = db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem, sent_at) VALUES (?, ?, ?, 'concorrente', 'pendente', 'local', 'automatico', '2026-07-29T12:00:00.000Z')"
).run(workerData.leadId, workerData.produto, workerData.numero);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?")
.run(Number(inserted.lastInsertRowid), workerData.queueId);
parentPort.postMessage('locked');
setTimeout(() => {
db.exec('COMMIT');
db.close();
parentPort.postMessage('committed');
}, 150);
`, {
eval: true,
workerData: {
dbPath,
queueId: committing.queueId,
leadId: committing.leadId,
numero: committing.numero,
produto: campaign.produto
}
});
const workerExit = new Promise((resolve, reject) => {
worker.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`worker encerrou com ${code}`)));
worker.once('error', reject);
});
try {
await new Promise((resolve, reject) => {
worker.once('message', resolve);
worker.once('error', reject);
});
const startedAt = Date.now();
assert.throws(
() => reserveCampaignQueueSend(other, {
queueId: blocked.queueId,
now: new Date('2026-07-29T12:00:00.000Z')
}),
/limite diario/i
);
assert.ok(Date.now() - startedAt >= 75, 'segunda conexao nao aguardou o lock concorrente');
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 10);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(blocked.queueId).status, 'scheduled');
await workerExit;
} finally {
await worker.terminate();
other.close();
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedupe', async () => {
const { runDueCampaignQueue } = await workerModule();
const cases = [
['campanha pausada', null, ({ db, campaign }) => db.prepare("UPDATE outreach_campaigns SET status = 'paused' WHERE id = ?").run(campaign.id)],
['fila deixou de estar agendada', null, ({ db, item }) => db.prepare("UPDATE outreach_campaign_queue SET status = 'cancelled' WHERE id = ?").run(item.queueId)],
['numero deixou de estar confirmado', 'whatsapp_not_confirmed', ({ db, item }) => saveWhatsAppVerification(db, { numero: item.numero, status: 'nao_cadastrado' })],
['opt-out equivalente', 'opt_out', ({ db, item }) => addOptOut(db, item.numero, 'pare antes do envio')],
['resposta inbound', 'inbound_reply', ({ db, item }) => db.prepare(
'INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES (?, ?, ?, ?)'
).run('inbound-1', item.leadId, item.numero, '2026-07-29T11:59:00.000Z')],
['envio ativo do mesmo produto', 'duplicate_product_send', ({ db, campaign, item }) => db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')`
).run(item.leadId, campaign.produto, item.numero)],
['numero virou compartilhado', 'shared_number', ({ db, item }) => db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-compartilhado', 'Outro lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(item.numero)]
];
for (const [name, cancelReason, mutate] of cases) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 100 + cases.findIndex(([candidate]) => candidate === name) });
let state = 'READY';
const whatsapp = whatsappFake({
beforeProvider: async () => mutate({ db, campaign, item }),
get state() { return state; }
});
if (name === 'campanha pausada') state = 'READY';
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(whatsapp.providerCalls, 0, name);
assert.equal(summary.enviados, 0, name);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, name === 'envio ativo do mesmo produto' ? 1 : 0, name);
if (cancelReason) {
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
{ status: 'cancelled', cancel_reason: cancelReason },
name
);
}
db.close();
}
const { db, campaign } = setup();
addQueue(db, campaign, { suffix: 199 });
let state = 'READY';
const whatsapp = {
getStatus: () => ({ state }),
sendText: async (args) => {
state = 'DISCONNECTED';
await args.beforeSend();
throw new Error('provider nao deveria ser chamado');
}
};
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
// Cenario real do operador: numero compartilhado liberado em Configuracoes,
// item entra na fila e e agendado, operador reconsidera e clica "Bloquear de
// novo". Sem a revalidacao de ultima milha o envio ja agendado sairia mesmo
// assim, porque a checagem de compartilhamento so acontecia na selecao.
test('libera, agenda e rebloqueia numero compartilhado cancela o envio ja agendado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [name, reblock, expected] of [
['override mantido', false, { tentados: 1, enviados: 1, erros: 0, pulados: 0 }],
['override removido antes do envio', true, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }]
]) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 600 });
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-600-b', 'Cliente da mesma contabilidade', ?, 'restaurantes', 1)`
).run(item.numero);
addSharedNumberOverride(db, item.numero, 'contabilidade confirmada');
if (reblock) assert.equal(removeSharedNumberOverride(db, item.numero), true, name);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, expected, name);
assert.equal(whatsapp.providerCalls, reblock ? 0 : 1, name);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
reblock ? { status: 'cancelled', cancel_reason: 'shared_number' } : { status: 'sent', cancel_reason: null },
name
);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, reblock ? 0 : 1, name);
db.close();
}
});
// Par assimetrico: 557533334444 (local '33334444', fora da faixa 6-9) e
// 5575933334444. equivalentPhoneNumbers so relaciona o segundo com o primeiro,
// entao antes da chave canonica o lead de 12 digitos passava batido aqui.
test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, outro] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-fila', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-outro', 'Lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(outro);
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'shared_number' },
queued
);
db.close();
}
});
// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam
// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma
// do numero podia nao bloquear o envio quando a fila tinha a outra forma.
test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, optedOutForm] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-optout', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
addOptOut(db, optedOutForm, 'pediu para nao receber mais');
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(summary.enviados, 0, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'opt_out' },
queued
);
db.close();
}
});
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [outcome, queueStatus, sendStatus] of [
['success', 'sent', 'aceito'],
['unknown', 'unknown', 'desconhecido'],
['rejected', 'error', 'erro']
]) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: `30${outcome.length}` });
db.exec(`
CREATE TABLE finalize_audit (queue_status TEXT);
CREATE TRIGGER audit_queue_finalize AFTER UPDATE OF status ON outreach_campaign_queue
WHEN OLD.status = 'reserved'
BEGIN INSERT INTO finalize_audit VALUES (NEW.status); END;
`);
const whatsapp = whatsappFake({ outcome });
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status, queueStatus);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, sendStatus);
assert.deepEqual(db.prepare('SELECT queue_status FROM finalize_audit').all().map((row) => row.queue_status), [queueStatus]);
db.close();
}
});
test('resposta do provedor sem messageId fica unknown e nunca tenta a mesma fila novamente', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 399 });
let providerCalls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend();
providerCalls += 1;
const error = Object.assign(new Error('WhatsApp nao retornou o identificador da mensagem'), {
code: 'WHATSAPP_MESSAGE_ID_MISSING',
deliveryUnknown: true
});
await args.afterSend(null, error, context);
throw error;
}
};
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(providerCalls, 1);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status, 'unknown');
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'desconhecido');
db.close();
});
test('restart pausa campanhas, desliga legado, preserva scheduled e recupera reserved pelo send', async () => {
const { pauseCampaignsAfterRestart, recoverReservedCampaignQueue } = await workerModule();
const { db, campaign } = setup();
setSetting(db, 'outreach.ativo', 'true');
const scheduled = addQueue(db, campaign, { suffix: 401 });
const mappings = [
['pendente', 'unknown'],
['aceito', 'unknown'],
['enviado', 'sent'],
['entregue', 'sent'],
['lido', 'sent'],
['desconhecido', 'unknown'],
['erro', 'error']
];
const reserved = [];
for (const [index, [sendStatus]] of mappings.entries()) {
const item = addQueue(db, campaign, { suffix: 410 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, ?, ?, 'reservada', ?, 'automatico')`
).run(item.leadId, campaign.produto, item.numero, sendStatus).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
reserved.push(item);
}
assert.equal(pauseCampaignsAfterRestart(db), 1);
assert.deepEqual(
{ ...db.prepare('SELECT status, pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id) },
{ status: 'paused', pause_reason: 'server_restart' }
);
assert.equal(db.prepare("SELECT value FROM settings WHERE key = 'outreach.ativo'").get().value, 'false');
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(scheduled.queueId).status, 'scheduled');
assert.equal(recoverReservedCampaignQueue(db), mappings.length);
assert.deepEqual(
reserved.map((item) => db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status),
mappings.map(([, queueStatus]) => queueStatus)
);
db.close();
});
test('restart reconcilia fila sent quando recovery converte aceite sem ACK em desconhecido', async () => {
const { recoverReservedCampaignQueue, runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 499 });
await runDueCampaignQueue({
db,
whatsapp: whatsappFake(),
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(
{ ...db.prepare(
`SELECT queue.status AS queue_status, sends.status AS send_status
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.id = ?`
).get(item.queueId) },
{ queue_status: 'sent', send_status: 'aceito' }
);
assert.equal(recoverPendingOutreach(db), 1);
assert.equal(recoverReservedCampaignQueue(db), 1);
assert.deepEqual(
{ ...db.prepare(
`SELECT queue.status AS queue_status, sends.status AS send_status
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.id = ?`
).get(item.queueId) },
{ queue_status: 'unknown', send_status: 'desconhecido' }
);
db.close();
});