Files
COMERCIAL-GTI/tests/campaign-queue.test.mjs
T
Gustavo d85fb12991 Exclude leads sharing a WhatsApp number from the automatic campaign queue
candidateQuery now blocks a lead whose WhatsApp number is shared by 2+
leads, unless a shared-number override exists. The inner correlated
subquery compares the bare other.whatsapp column (not normalizedSql())
against the outer row's two computed number forms so SQLite can use
idx_leads_whatsapp instead of a full table scan, keeping the 1.3M-row
perf test within budget.

Also updates the pre-existing 'persiste erro temporario e nao repete
equivalente' test: its two leads use 8/9-digit equivalent numbers,
which the new filter now blocks by default, so an override is added
to keep exercising the intended equivalent-number dedup behavior.
2026-07-31 11:18:41 -03:00

596 lines
22 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 {
addOptOut,
addSharedNumberOverride,
openDb,
saveWhatsAppVerification
} from '../src/db.mjs';
import { prepareDailyQueue } from '../src/outreach/campaign-queue.mjs';
import {
getCampaign,
listCampaignQueue,
listCampaigns,
seedCampaigns
} from '../src/outreach/campaign-store.mjs';
function setupCampaign(code = 'gdelivery-food') {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = listCampaigns(db).find((item) => item.code === code);
return { db, campaign };
}
function insertLead(db, {
key,
empresa = '',
whatsapp,
segmento = 'pizzarias',
prioridade = 1,
score = 0
}) {
return Number(db.prepare(
`INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
VALUES (?, ?, ?, ?, ?, ?, 1)`
).run(key, empresa || key, whatsapp, segmento, prioridade, score).lastInsertRowid);
}
function confirmed(db, numero, {
resolvedNumber = numero,
checkedAt = null,
status = 'confirmado'
} = {}) {
saveWhatsAppVerification(db, {
numero,
status,
resolvedNumber: status === 'confirmado' ? resolvedNumber : null,
variant: 'exata'
});
if (checkedAt) {
db.prepare('UPDATE whatsapp_number_verifications SET checked_at = ? WHERE numero = ?')
.run(checkedAt, numero);
}
}
function noSendWhatsapp(checkNumber = async (numero) => ({
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
})) {
return {
checkNumber,
sendText() {
throw new Error('prepareDailyQueue nao pode enviar mensagens');
}
};
}
function traceCandidateQuery(db) {
const trace = { sql: null, args: null };
const tracedDb = new Proxy(db, {
get(target, property) {
if (property === 'prepare') {
return (sql) => {
const statement = target.prepare(sql);
if (!sql.includes('campaign-candidates')) return statement;
trace.sql = sql;
return new Proxy(statement, {
get(statementTarget, statementProperty) {
if (statementProperty === 'all') {
return (...args) => {
trace.args = args;
return statementTarget.all(...args);
};
}
const value = statementTarget[statementProperty];
return typeof value === 'function' ? value.bind(statementTarget) : value;
}
});
};
}
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
}
});
return { tracedDb, trace };
}
test('prepara confirmados na ordem comercial e exclui inelegiveis em SQL', async () => {
const { db, campaign } = setupCampaign();
const highScore = insertLead(db, {
key: 'high-score', empresa: 'Pizzaria Sol', whatsapp: '5575991234001', prioridade: 1, score: 90
});
const lowScore = insertLead(db, {
key: 'low-score', whatsapp: '5575991234002', prioridade: 1, score: 40
});
const lowerPriority = insertLead(db, {
key: 'lower-priority', whatsapp: '5575991234003', prioridade: 2, score: 100
});
const priorProduct = insertLead(db, {
key: 'prior-product', whatsapp: '5575991234004', prioridade: 1, score: 99
});
const optedOut = insertLead(db, {
key: 'opted-out', whatsapp: '5575991234005', prioridade: 1, score: 98
});
const replied = insertLead(db, {
key: 'replied', whatsapp: '5575991234006', prioridade: 1, score: 97
});
const queued = insertLead(db, {
key: 'queued', whatsapp: '5575991234007', prioridade: 1, score: 96
});
const staleConfirmation = insertLead(db, {
key: 'stale-confirmation', whatsapp: '5575991234008', prioridade: 1, score: 95
});
const outsideSegment = insertLead(db, {
key: 'outside-segment', whatsapp: '5575991234009', segmento: 'restaurantes', prioridade: 1, score: 100
});
for (const number of [
'5575991234001', '5575991234002', '5575991234003', '5575991234004',
'5575991234005', '5575991234006', '5575991234007', '5575991234008',
'5575991234009'
]) confirmed(db, number);
confirmed(db, '557591234008', {
status: 'nao_cadastrado', checkedAt: '2026-07-29 13:00:00'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00' WHERE numero = '5575991234008'").run();
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, 'G-Delivery', '5575991234004', 'anterior', 'erro', 'manual')`
).run(priorProduct);
addOptOut(db, '557591234005', 'pedido do contato');
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
VALUES (?, 'respondeu', '2026-07-29 10:00:00')`
).run(replied);
const variantA = campaign.variants.find((variant) => variant.code === 'A');
db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
VALUES (?, ?, ?, 'G-Delivery', '5575991234007', 'pizzarias', 'existente', 'existente', 'preview')`
).run(campaign.id, queued, variantA.id);
let checks = 0;
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => {
checks += 1;
throw new Error('confirmados existentes nao devem ser consultados');
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id, 'preview');
assert.deepEqual(summary, { checked: 0, previewed: 3, skipped: 0 });
assert.equal(checks, 0);
assert.deepEqual(queue.map((item) => item.lead_id), [queued, highScore, lowScore, lowerPriority]);
assert.deepEqual(queue.slice(1).map((item) => item.variant_code), ['B', 'C', 'A']);
assert.match(queue[1].rendered_message, /^Olá, pessoal da Pizzaria Sol, tudo bem\?/);
assert.deepEqual(queue.slice(1).map((item) => item.selection_reason), [
'gdelivery-food:prioridade-1',
'gdelivery-food:prioridade-1',
'gdelivery-food:prioridade-2'
]);
assert.equal(queue.some((item) => [priorProduct, optedOut, replied, staleConfirmation, outsideSegment].includes(item.lead_id)), false);
assert.equal(new Set(queue.map((item) => item.lead_id)).size, queue.length);
db.close();
});
test('estado mais recente de numero equivalente confirma e persiste o numero resolvido', async () => {
const { db, campaign } = setupCampaign();
const leadId = insertLead(db, {
key: 'equivalent-confirmed', whatsapp: '5575998765432', score: 50
});
confirmed(db, '5575998765432', {
status: 'nao_cadastrado', checkedAt: '2026-07-29 10:00:00'
});
confirmed(db, '557598765432', {
resolvedNumber: '557598765432', checkedAt: '2026-07-29 11:00:00'
});
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => {
throw new Error('estado equivalente confirmado nao deve consumir verificacao');
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
assert.equal(queue[0].lead_id, leadId);
assert.equal(queue[0].numero, '557598765432');
db.close();
});
test('verifica sequencialmente no maximo o teto diario sem cobrar confirmados existentes', async () => {
const { db, campaign } = setupCampaign();
const confirmedLead = insertLead(db, {
key: 'already-confirmed', whatsapp: '5575992222000', score: 100
});
confirmed(db, '5575992222000');
for (let index = 1; index <= 20; index += 1) {
insertLead(db, {
key: `unverified-${index}`,
whatsapp: `55759922${String(index).padStart(4, '0')}`,
score: 100 - index
});
}
const calls = [];
let inFlight = 0;
let maxInFlight = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
calls.push(numero);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
return {
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
};
});
const summary = await prepareDailyQueue({
db,
whatsapp,
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 15, previewed: 1, skipped: 15 });
assert.equal(summary.checked, Math.ceil(getCampaign(db, campaign.id).approved_daily_limit * 1.5));
assert.equal(maxInFlight, 1);
assert.equal(queue.length, 1);
assert.equal(queue[0].lead_id, confirmedLead);
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 16);
assert.deepEqual(calls, Array.from({ length: 15 }, (_, index) => `55759922${String(index + 1).padStart(4, '0')}`));
db.close();
});
test('limite de verificacao ignora multiplicador adulterado acima de 1.5', async () => {
const { db, campaign } = setupCampaign();
db.exec('PRAGMA ignore_check_constraints = ON');
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_insert');
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_update');
db.prepare('UPDATE outreach_campaigns SET verification_multiplier = 99 WHERE id = ?').run(campaign.id);
for (let index = 1; index <= 30; index += 1) {
insertLead(db, {
key: `malicious-cap-${index}`,
whatsapp: `55759888${String(index).padStart(4, '0')}`,
score: 100 - index
});
}
const calls = [];
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async (numero) => {
calls.push(numero);
return {
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
};
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.equal(summary.checked, 15);
assert.equal(calls.length, 15);
db.close();
});
test('persiste erro temporario e nao repete equivalente na mesma preparacao', async () => {
const { db, campaign } = setupCampaign();
insertLead(db, { key: 'temporary-a', whatsapp: '5575999091780', score: 100 });
insertLead(db, { key: 'temporary-b', whatsapp: '557599091780', score: 90 });
addSharedNumberOverride(db, '5575999091780', 'numeros equivalentes usados apenas para testar o dedup de verificacao');
const calls = [];
const whatsapp = noSendWhatsapp(async (numero) => {
calls.push(numero);
throw Object.assign(new Error('consulta excedeu o prazo'), {
numero,
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
});
const summary = await prepareDailyQueue({
db,
whatsapp,
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.deepEqual(summary, { checked: 1, previewed: 0, skipped: 2 });
assert.deepEqual(calls, ['5575999091780']);
assert.deepEqual({ ...db.prepare(
'SELECT numero, status, erro FROM whatsapp_number_verifications'
).get() }, {
numero: '5575999091780',
status: 'erro_temporario',
erro: 'consulta excedeu o prazo'
});
db.close();
});
test('duas conexoes reais recontam a mesma campanha sem SQLITE_BUSY ou excesso', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-queue-race-'));
const dbPath = path.join(tempDir, 'race.sqlite');
let firstDb;
let secondDb;
try {
firstDb = openDb(dbPath);
seedCampaigns(firstDb);
secondDb = openDb(dbPath);
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
for (let index = 0; index < 12; index += 1) {
insertLead(firstDb, {
key: `real-race-${index}`,
whatsapp: `5575993300${String(index).padStart(2, '0')}`,
segmento: 'restaurantes',
score: 100 - index
});
}
let release;
const gate = new Promise((resolve) => { release = resolve; });
let arrivals = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
return {
numero,
status: 'confirmado',
registered: true,
resolvedNumber: numero,
variant: 'exata',
candidatesChecked: 2
};
});
const results = await Promise.allSettled([
prepareDailyQueue({ first: true, db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
]);
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
const queue = firstDb.prepare(
"SELECT produto, lead_id FROM outreach_campaign_queue WHERE status = 'preview' ORDER BY lead_id"
).all();
assert.ok(queue.length <= 10);
assert.equal(new Set(queue.map(({ lead_id }) => lead_id)).size, queue.length);
assert.equal(queue.every(({ produto }) => produto === 'GTI Vendas'), true);
} finally {
secondDb?.close();
firstDb?.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('campanhas sobrepostas do mesmo produto nao criam duas filas ativas', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-product-race-'));
const dbPath = path.join(tempDir, 'race.sqlite');
let firstDb;
let secondDb;
try {
firstDb = openDb(dbPath);
seedCampaigns(firstDb);
secondDb = openDb(dbPath);
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
const secondCampaign = listCampaigns(secondDb).find(({ code }) => code === 'gti-retail');
secondDb.prepare("UPDATE outreach_campaigns SET segments_json = '[\"restaurantes\"]' WHERE id = ?")
.run(secondCampaign.id);
insertLead(firstDb, {
key: 'same-product-race', whatsapp: '557599771001', segmento: 'restaurantes', score: 100
});
let release;
const gate = new Promise((resolve) => { release = resolve; });
let arrivals = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
return {
numero,
status: 'confirmado',
registered: true,
resolvedNumber: numero,
variant: 'exata',
candidatesChecked: 2
};
});
const results = await Promise.allSettled([
prepareDailyQueue({ db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: secondCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
]);
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
assert.deepEqual(firstDb.prepare(
"SELECT produto, lead_id, COUNT(*) total FROM outreach_campaign_queue WHERE status = 'preview' GROUP BY produto, lead_id"
).all().map((row) => ({ ...row })), [{ produto: 'GTI Vendas', lead_id: 1, total: 1 }]);
} finally {
secondDb?.close();
firstDb?.close();
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
test('consulta 1.2m irrelevantes e 100k correspondentes com indice, limite e tempo pratico', async (t) => {
const { db, campaign } = setupCampaign();
const fixtureStartedAt = performance.now();
db.exec(`
WITH RECURSIVE sequence(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM sequence WHERE value < 1200000
)
INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
SELECT 'outside-' || value, 'Outside ' || value,
'5511' || printf('%08d', value), 'segmento_externo', 1, value % 100, 1
FROM sequence;
WITH RECURSIVE sequence(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM sequence WHERE value < 100000
)
INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
SELECT 'matching-' || value, 'Matching ' || value,
'5575' || printf('%08d', value), 'pizzarias', 1 + (value % 4), value % 101, 1
FROM sequence;
`);
const fixtureElapsedMs = performance.now() - fixtureStartedAt;
const { tracedDb, trace } = traceCandidateQuery(db);
const startedAt = performance.now();
const summary = await prepareDailyQueue({
db: tracedDb,
whatsapp: noSendWhatsapp(),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const elapsedMs = performance.now() - startedAt;
assert.equal(summary.checked, 15);
assert.ok(trace.sql, 'consulta real de candidatos nao foi observada');
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
.map((row) => row.detail).join('\n');
assert.match(plan, /idx_leads_campaign_order/i);
assert.match(trace.sql, /LIMIT\s+\?/i);
assert.ok(trace.args.at(-1) <= 25);
assert.ok(elapsedMs < 8_000, `consulta limitada levou ${elapsedMs.toFixed(1)}ms`);
t.diagnostic(`fixture_1_3m_ms=${fixtureElapsedMs.toFixed(1)} candidate_query_ms=${elapsedMs.toFixed(1)}`);
db.close();
});
test('exclui envios bem-sucedidos, desconhecidos e respostas inbound explicitas', async () => {
const { db, campaign } = setupCampaign();
const delivered = insertLead(db, { key: 'sent-delivered', whatsapp: '557599550001', score: 100 });
const unknown = insertLead(db, { key: 'sent-unknown', whatsapp: '557599550002', score: 99 });
const inboundLead = insertLead(db, { key: 'inbound-lead', whatsapp: '557599550003', score: 98 });
const inboundEquivalent = insertLead(db, { key: 'inbound-equivalent', whatsapp: '5575999550004', score: 97 });
const eligible = insertLead(db, { key: 'eligibility-control', whatsapp: '557599550005', score: 96 });
for (const numero of ['557599550001', '557599550002', '557599550003', '5575999550004', '557599550005']) {
confirmed(db, numero);
}
db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
).run(delivered, '557599550001', 'entregue');
db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
).run(unknown, '557599550002', 'desconhecido');
db.prepare(
"INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES ('inbound-by-lead', ?, '557599550003', '2026-07-29T12:00:00Z')"
).run(inboundLead);
db.prepare(
"INSERT INTO outreach_inbound_replies (provider_message_id, numero, received_at) VALUES ('inbound-by-number', '557599550004', '2026-07-29T12:01:00Z')"
).run();
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [eligible]);
assert.equal(queue.some(({ lead_id }) => [delivered, unknown, inboundLead, inboundEquivalent].includes(lead_id)), false);
db.close();
});
test('usa somente a lista de segmentos configurada na campanha', async () => {
const { db, campaign } = setupCampaign();
db.prepare("UPDATE outreach_campaigns SET segments_json = '[\"pizzarias\"]' WHERE id = ?").run(campaign.id);
const configured = insertLead(db, { key: 'configured-segment', whatsapp: '557599660001', segmento: 'pizzarias' });
const catalogButRemoved = insertLead(db, { key: 'removed-segment', whatsapp: '557599660002', segmento: 'hamburguerias' });
confirmed(db, '557599660001');
confirmed(db, '557599660002');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.deepEqual(listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id), [configured]);
assert.notEqual(configured, catalogButRemoved);
db.close();
});
test('numero compartilhado por mais de um lead fica fora da fila ate liberacao manual', async () => {
const { db, campaign } = setupCampaign();
const sharedA = insertLead(db, { key: 'shared-a', empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', score: 100 });
const sharedB = insertLead(db, { key: 'shared-b', empresa: 'Contabilidade Cliente B', whatsapp: '5575999091780', score: 90 });
const solo = insertLead(db, { key: 'solo', whatsapp: '5575992220000', score: 80 });
for (const numero of ['5575999091780', '5575992220000']) confirmed(db, numero);
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
let queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [solo]);
assert.equal(queue.some(({ lead_id }) => [sharedA, sharedB].includes(lead_id)), false);
addSharedNumberOverride(db, '5575999091780', 'confirmado contato direto');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(queue.map(({ lead_id }) => lead_id).sort((a, b) => a - b), [sharedA, sharedB, solo].sort((a, b) => a - b));
db.close();
});
test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila', async () => {
const { db, campaign } = setupCampaign();
const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 });
const eightDigits = insertLead(db, { key: 'eight-digits', whatsapp: '557599091780', score: 90 });
confirmed(db, '5575999091780');
confirmed(db, '557599091780');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false);
db.close();
});