Files
COMERCIAL-GTI/tests/campaign-metrics.test.mjs
T
Gustavo 3e55b843e2 Fix evaluateCampaignHealth counting another campaign's sends for the same lead
attemptsToday/failureCount joined outreach_sends to outreach_campaign_queue
by lead_id + campaign_id instead of the actual queue-to-send link
(outreach_send_id). campaignMetrics() already used outreach_send_id for
its own equivalent queries (see the existing F1 regression test) after a
prior fix; evaluateCampaignHealth's separate queries had not received the
same fix. Currently unreachable in practice since the campaign catalog
uses mutually-exclusive segments per lead, but a latent bug: if a lead
ever had automated-send history in two campaigns, one campaign's failures
could count toward another's high_failure_rate auto-pause threshold.

Fixed two existing test fixtures that happened to pass without actually
exercising the attempt/failure-rate path they were named for (their sends
weren't linked via outreach_send_id, so attemptsToday silently came out as
0 pre-fix). Added a regression test (F1b) mirroring the existing F1 test's
cross-campaign-contamination scenario for campaignMetrics, applied to
evaluateCampaignHealth instead; verified it fails against the pre-fix
query and passes against the fix.

Found via a full-project audit.
2026-07-31 22:35:21 -03:00

700 lines
30 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, addOptOut, saveWhatsAppVerification } from '../src/db.mjs';
import { seedCampaigns, setLeadCommercialStage } from '../src/outreach/campaign-store.mjs';
async function metricsModule() {
return import('../src/outreach/campaign-metrics.mjs');
}
function setup() {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
return { db, campaign };
}
function addLead(db, suffix, segmento) {
const numero = `55119999${String(suffix).padStart(5, '0')}`;
return Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES (?, ?, ?, ?, 1)`
).run(`metrics-lead-${suffix}`, `Empresa ${suffix}`, numero, segmento || 'restaurantes').lastInsertRowid);
}
function addQueue(db, campaign, overrides = {}) {
const {
leadId, suffix = Date.now(), status = 'preview', variantCode = 'A',
produto = campaign.produto, segmento = 'restaurantes'
} = overrides;
const lid = leadId !== undefined ? leadId : addLead(db, suffix, segmento);
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(lid);
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? AND code = ?'
).get(campaign.id, variantCode).id;
return Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status)
VALUES (?, ?, ?, ?, ?, ?, 'Msg', 'test', ?)`
).run(campaign.id, lid, variantId, produto, lead.whatsapp, segmento, status).lastInsertRowid);
}
function addSend(db, overrides = {}) {
const {
leadId, produto = 'G-Delivery', status = 'aceito',
sentAt = '2026-07-29T12:00:00.000Z', origem = 'automatico', queueId
} = overrides;
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(leadId, produto, '5511999990000', 'Msg', status, origem, sentAt).lastInsertRowid);
if (queueId) {
db.prepare('UPDATE outreach_campaign_queue SET outreach_send_id = ? WHERE id = ?').run(sendId, queueId);
}
return sendId;
}
function addReply(db, leadId, numero) {
return Number(db.prepare(
`INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at)
VALUES (?, ?, ?, ?)`
).run(`reply-${leadId}`, leadId, numero || '5511999990000', '2026-07-29T12:30:00.000Z').lastInsertRowid);
}
function addStageEvent(db, leadId, campaignId, stage) {
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
VALUES (?, ?, CASE WHEN ? = 'respondeu' THEN datetime('now') END, datetime('now'))
ON CONFLICT(lead_id) DO UPDATE SET
stage = excluded.stage,
first_reply_at = CASE
WHEN excluded.stage = 'respondeu' THEN COALESCE(outreach_lead_states.first_reply_at, datetime('now'))
ELSE outreach_lead_states.first_reply_at
END,
updated_at = datetime('now')`
).run(leadId, stage, stage);
db.prepare(
'INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, ?)'
).run(leadId, campaignId, stage);
}
function addAppointment(db, leadId, campaignId, kind) {
db.prepare(
`INSERT INTO outreach_appointments (lead_id, campaign_id, kind)
VALUES (?, ?, ?)`
).run(leadId, campaignId, kind);
}
// --- campaignMetrics tests ---
test('campaignMetrics retorna zeros para campanha vazia', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.previewed, 0);
assert.equal(metrics.approved, 0);
assert.equal(metrics.removed, 0);
assert.equal(metrics.attempted, 0);
assert.deepEqual(metrics.provider, { aceito: 0, erro: 0, desconhecido: 0 });
assert.equal(metrics.replies, 0);
assert.equal(metrics.firstResponseTimeAvg, null);
assert.equal(metrics.stages.positive, 0);
assert.equal(metrics.stages.negative, 0);
assert.equal(metrics.stages.optOut, 0);
assert.equal(metrics.appointments.demos, 0);
assert.equal(metrics.appointments.tests, 0);
assert.equal(metrics.sales, 0);
assert.deepEqual(metrics.groups.byProduct, {});
assert.deepEqual(metrics.groups.bySegment, {});
assert.deepEqual(metrics.groups.byVariant, {});
db.close();
});
test('campaignMetrics conta status da fila e provedor', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 1);
const lead2 = addLead(db, 2);
const lead3 = addLead(db, 3);
const lead4 = addLead(db, 4);
const lead5 = addLead(db, 5);
const lead6 = addLead(db, 6);
const lead7 = addLead(db, 7);
addQueue(db, campaign, { leadId: lead1, status: 'preview' });
addQueue(db, campaign, { leadId: lead2, status: 'preview' });
addQueue(db, campaign, { leadId: lead3, status: 'approved' });
addQueue(db, campaign, { leadId: lead4, status: 'approved' });
addQueue(db, campaign, { leadId: lead5, status: 'cancelled', suffix: 5 });
addQueue(db, campaign, { leadId: lead6, status: 'scheduled', suffix: 6 });
const q7 = addQueue(db, campaign, { leadId: lead7, status: 'sent', suffix: 7 });
addSend(db, { leadId: lead7, status: 'aceito', queueId: q7 });
const lead8 = addLead(db, 8);
const q8 = addQueue(db, campaign, { leadId: lead8, status: 'unknown', suffix: 8 });
addSend(db, { leadId: lead8, status: 'desconhecido', queueId: q8 });
const lead9 = addLead(db, 9);
const q9 = addQueue(db, campaign, { leadId: lead9, status: 'error', suffix: 9 });
addSend(db, { leadId: lead9, status: 'erro', queueId: q9 });
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.previewed, 2);
assert.equal(metrics.approved, 2);
assert.equal(metrics.removed, 1);
assert.equal(metrics.attempted, 4);
assert.deepEqual(metrics.provider, { aceito: 1, erro: 1, desconhecido: 1 });
db.close();
});
test('campaignMetrics conta replies e first-response time', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead = addLead(db, 10);
const q10 = addQueue(db, campaign, { leadId: lead, status: 'sent', suffix: 10 });
addSend(db, { leadId: lead, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: q10 });
addReply(db, lead, '55119999900010');
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.replies, 1);
assert.ok(metrics.firstResponseTimeAvg !== null);
assert.ok(typeof metrics.firstResponseTimeAvg === 'number');
db.close();
});
test('campaignMetrics conta stages positivos, negativos e opt-out', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 20);
const lead2 = addLead(db, 21);
const lead3 = addLead(db, 22);
const lead4 = addLead(db, 23);
addQueue(db, campaign, { leadId: lead1, status: 'sent', suffix: 20 });
addQueue(db, campaign, { leadId: lead2, status: 'sent', suffix: 21 });
addQueue(db, campaign, { leadId: lead3, status: 'sent', suffix: 22 });
addQueue(db, campaign, { leadId: lead4, status: 'sent', suffix: 23 });
addStageEvent(db, lead1, campaign.id, 'interessado_gti_vendas');
addStageEvent(db, lead2, campaign.id, 'venda_fechada');
addStageEvent(db, lead3, campaign.id, 'sem_interesse');
addStageEvent(db, lead4, campaign.id, 'opt_out');
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.stages.positive, 2);
assert.equal(metrics.stages.negative, 1);
assert.equal(metrics.stages.optOut, 1);
db.close();
});
test('campaignMetrics conta appointments e sales', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 30);
const lead2 = addLead(db, 31);
const lead3 = addLead(db, 32);
addQueue(db, campaign, { leadId: lead1, status: 'sent', suffix: 30 });
addQueue(db, campaign, { leadId: lead2, status: 'sent', suffix: 31 });
addQueue(db, campaign, { leadId: lead3, status: 'sent', suffix: 32 });
addAppointment(db, lead1, campaign.id, 'demonstracao');
addAppointment(db, lead2, campaign.id, 'teste_g_delivery');
addStageEvent(db, lead3, campaign.id, 'venda_fechada');
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.appointments.demos, 1);
assert.equal(metrics.appointments.tests, 1);
assert.equal(metrics.sales, 1);
db.close();
});
test('campaignMetrics filtra por segmento', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 40, 'restaurantes');
const lead2 = addLead(db, 41, 'pizzarias');
addQueue(db, campaign, { leadId: lead1, status: 'preview', segmento: 'restaurantes' });
addQueue(db, campaign, { leadId: lead2, status: 'preview', segmento: 'pizzarias', suffix: 41 });
const all = campaignMetrics(db, campaign.id);
assert.equal(all.previewed, 2);
const filtered = campaignMetrics(db, campaign.id, { segmento: 'restaurantes' });
assert.equal(filtered.previewed, 1);
db.close();
});
test('campaignMetrics filtra por variant', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 50);
const lead2 = addLead(db, 51);
addQueue(db, campaign, { leadId: lead1, status: 'preview', variantCode: 'A' });
addQueue(db, campaign, { leadId: lead2, status: 'preview', variantCode: 'B', suffix: 51 });
const filtered = campaignMetrics(db, campaign.id, { variant: 'B' });
assert.equal(filtered.previewed, 1);
db.close();
});
test('campaignMetrics agrupa por product, segment e variant', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const lead1 = addLead(db, 60, 'restaurantes');
const lead2 = addLead(db, 61, 'pizzarias');
addQueue(db, campaign, { leadId: lead1, status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
addQueue(db, campaign, { leadId: lead2, status: 'preview', variantCode: 'B', segmento: 'pizzarias', suffix: 61 });
const metrics = campaignMetrics(db, campaign.id);
assert.ok(metrics.groups.byProduct[campaign.produto]);
assert.ok(metrics.groups.bySegment.restaurantes);
assert.ok(metrics.groups.bySegment.pizzarias);
assert.ok(metrics.groups.byVariant.A);
assert.ok(metrics.groups.byVariant.B);
db.close();
});
// --- evaluateCampaignHealth tests ---
test('evaluateCampaignHealth retorna pause false para campanha saudavel', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false);
assert.equal(result.reason, null);
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
db.close();
});
test('evaluateCampaignHealth pausa por unknown_outcome quando unknownCount > 0', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const lead = addLead(db, 70);
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 70 });
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, true);
assert.equal(result.reason, 'unknown_outcome');
assert.equal(db.prepare('SELECT status, pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
assert.equal(db.prepare('SELECT pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id).pause_reason, 'unknown_outcome');
db.close();
});
test('evaluateCampaignHealth pausa por high_failure_rate quando > 10%', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
const queueIds = [];
for (let i = 0; i < 11; i++) {
const lid = addLead(db, 100 + i);
leads.push(lid);
queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 100 + i }));
}
for (let i = 0; i < 8; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
for (let i = 8; i < 10; i++) {
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, true);
assert.equal(result.reason, 'high_failure_rate');
db.close();
});
test('evaluateCampaignHealth NAO pausa por failure rate exatamente 10%', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
const queueIds = [];
for (let i = 0; i < 10; i++) {
const lid = addLead(db, 200 + i);
leads.push(lid);
queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 200 + i }));
}
for (let i = 0; i < 9; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
addSend(db, { leadId: leads[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[9] }); // 1/10 = 10% → not > 10%
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false);
assert.equal(result.reason, null);
db.close();
});
test('evaluateCampaignHealth pausa por high_negative_rate quando > 10%', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
for (let i = 0; i < 20; i++) {
const lid = addLead(db, 300 + i);
leads.push(lid);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 300 + i });
}
for (let i = 0; i < 17; i++) {
addStageEvent(db, leads[i], campaign.id, 'interessado_gti_vendas');
}
addStageEvent(db, leads[17], campaign.id, 'sem_interesse');
addStageEvent(db, leads[18], campaign.id, 'opt_out');
addStageEvent(db, leads[19], campaign.id, 'sem_interesse'); // 3/20 = 15% > 10%
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, true);
assert.equal(result.reason, 'high_negative_rate');
db.close();
});
test('evaluateCampaignHealth NAO pausa por negative rate exatamente 10%', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
for (let i = 0; i < 20; i++) {
const lid = addLead(db, 400 + i);
leads.push(lid);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 400 + i });
}
for (let i = 0; i < 18; i++) {
addStageEvent(db, leads[i], campaign.id, 'interessado_gti_vendas');
}
addStageEvent(db, leads[18], campaign.id, 'sem_interesse');
addStageEvent(db, leads[19], campaign.id, 'interessado_g_delivery'); // 1 negative/20 = 5% → not > 10%
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false);
assert.equal(result.reason, null);
db.close();
});
test('evaluateCampaignHealth pausa por low_reply_rate quando < 5% com contacts >= 50', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
for (let i = 0; i < 50; i++) {
const lid = addLead(db, 500 + i);
leads.push(lid);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 500 + i });
}
for (let i = 0; i < 50; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
}
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, true);
assert.equal(result.reason, 'low_reply_rate');
db.close();
});
test('evaluateCampaignHealth NAO pausa por reply rate exatamente 5%', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
for (let i = 0; i < 60; i++) {
const lid = addLead(db, 600 + i);
leads.push(lid);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 600 + i });
}
for (let i = 0; i < 60; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' });
}
for (let i = 0; i < 3; i++) {
addStageEvent(db, leads[i], campaign.id, 'respondeu');
db.prepare(
'INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES (?, ?, ?, ?)'
).run(`reply-exact-${i}`, leads[i], '5511999990000', '2026-07-29T12:30:00.000Z');
}
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false);
assert.equal(result.reason, null);
db.close();
});
test('evaluateCampaignHealth prioriza first-match: unknown > failure > negative > reply', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const lead = addLead(db, 80);
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 80 });
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.reason, 'unknown_outcome');
db.close();
});
test('evaluateCampaignHealth pause nunca volta para running automaticamente', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const lead = addLead(db, 90);
addQueue(db, campaign, { leadId: lead, status: 'unknown', suffix: 90 });
const first = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(first.pause, true);
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
db.prepare("UPDATE outreach_campaigns SET pause_reason = NULL WHERE id = ?").run(campaign.id);
const second = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(second.pause, true);
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaign.id).status, 'paused');
db.close();
});
// --- variant comparison tests ---
test('variant eligibleForComparison true at 40 sends for campaign/segment/variant', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const leadIds = [];
for (let i = 0; i < 40; i++) {
const lid = addLead(db, 2000 + i, 'restaurantes');
leadIds.push(lid);
const qid = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'A', suffix: 2000 + i, segmento: 'restaurantes' });
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qid });
}
const metrics = campaignMetrics(db, campaign.id);
const variantA = metrics.groups.byVariant.A;
assert.ok(variantA !== undefined);
assert.equal(variantA.sends, 40);
assert.equal(variantA.eligibleForComparison, true);
db.close();
});
test('declare winner only when all active compared variants reach 40 sends', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
const leadIdsA = [];
for (let i = 0; i < 40; i++) {
const lid = addLead(db, 3000 + i, 'restaurantes');
leadIdsA.push(lid);
const qidA = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'A', suffix: 3000 + i, segmento: 'restaurantes' });
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidA });
}
const leadIdsB = [];
for (let i = 0; i < 39; i++) {
const lid = addLead(db, 3100 + i, 'restaurantes');
leadIdsB.push(lid);
const qidB = addQueue(db, campaign, { leadId: lid, status: 'sent', variantCode: 'B', suffix: 3100 + i, segmento: 'restaurantes' });
addSend(db, { leadId: lid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidB });
}
const metricsBefore = campaignMetrics(db, campaign.id);
assert.equal(metricsBefore.groups.byVariant.A.eligibleForComparison, true);
assert.equal(metricsBefore.groups.byVariant.B.eligibleForComparison, false);
const extraLid = addLead(db, 3199, 'restaurantes');
const qidExtra = addQueue(db, campaign, { leadId: extraLid, status: 'sent', variantCode: 'B', suffix: 3199, segmento: 'restaurantes' });
addSend(db, { leadId: extraLid, status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: qidExtra });
const metricsAfter = campaignMetrics(db, campaign.id);
assert.equal(metricsAfter.groups.byVariant.A.eligibleForComparison, true);
assert.equal(metricsAfter.groups.byVariant.B.eligibleForComparison, true);
db.close();
});
// --- F1 regression: cross-campaign send contamination ---
test('F1: campaignMetrics sends nao contaminam entre campanhas (usa outreach_send_id)', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const campaign2 = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1 OFFSET 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign2.id);
const lead = addLead(db, 8000);
const qid1 = addQueue(db, campaign, { leadId: lead, status: 'sent', suffix: 8000 });
const qid2 = addQueue(db, campaign2, { leadId: lead, status: 'sent', suffix: 8000 });
addSend(db, { leadId: lead, status: 'aceito', queueId: qid2 });
const metrics1 = campaignMetrics(db, campaign.id);
assert.deepEqual(metrics1.provider, { aceito: 0, erro: 0, desconhecido: 0 },
'campanha 1 nao deve contar send vinculado a campanha 2');
const metrics2 = campaignMetrics(db, campaign2.id);
assert.deepEqual(metrics2.provider, { aceito: 1, erro: 0, desconhecido: 0 },
'campanha 2 deve contar seu proprio send');
db.close();
});
// Mesmo bug do F1, so que em evaluateCampaignHealth (que tem suas proprias
// consultas de attemptsToday/failureCount, separadas de campaignMetrics):
// antes, o join usava lead_id+campaign_id, entao um envio automatico com
// erro em OUTRA campanha (mesmo lead) podia contar como falha desta
// campanha e dispara-la a pausar por high_failure_rate indevidamente.
test('F1b: evaluateCampaignHealth nao conta falha de envio de outra campanha (mesmo lead)', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const campaign2 = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1 OFFSET 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign2.id);
const leads = [];
const queueIds = [];
for (let i = 0; i < 10; i++) {
const lid = addLead(db, 8100 + i);
leads.push(lid);
queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 8100 + i }));
}
for (let i = 0; i < 10; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
// Mesmos leads, agora com uma fila (e envio com erro) numa SEGUNDA campanha.
// Sem o vinculo por outreach_send_id, essas falhas contaminariam a campanha 1.
for (let i = 0; i < 5; i++) {
const qid2 = addQueue(db, campaign2, { leadId: leads[i], status: 'sent', suffix: 8200 + i });
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: qid2 });
}
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false, JSON.stringify(result));
assert.equal(result.reason, null);
db.close();
});
// --- F2 regression: negative/opt-out count uses COUNT(DISTINCT) ---
test('F2: evaluateCampaignHealth negative count usa COUNT(DISTINCT) para evitar cartesian', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const leads = [];
for (let i = 0; i < 19; i++) {
const lid = addLead(db, 9000 + i);
leads.push(lid);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 9000 + i });
addStageEvent(db, lid, campaign.id, 'interessado_gti_vendas');
}
// 1 sem_interesse / 20 = 5% -> NOT >10%, no pause
const lidNeg = addLead(db, 9019);
leads.push(lidNeg);
addQueue(db, campaign, { leadId: lidNeg, status: 'sent', suffix: 9019 });
addStageEvent(db, lidNeg, campaign.id, 'sem_interesse');
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false);
assert.equal(result.reason, null);
db.close();
});
// --- F3 regression: group aggregation ignores filters ---
test('F3: campaignMetrics groups respeitam filtro segmento', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
addQueue(db, campaign, { leadId: addLead(db, 7000, 'restaurantes'), status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
addQueue(db, campaign, { leadId: addLead(db, 7001, 'pizzarias'), status: 'sent', variantCode: 'A', segmento: 'pizzarias', suffix: 7001 });
const filtered = campaignMetrics(db, campaign.id, { segmento: 'restaurantes' });
assert.ok(filtered.groups.bySegment.restaurantes, 'restaurantes deve estar nos grupos');
assert.equal(filtered.groups.bySegment.pizzarias, undefined, 'pizzarias NAO deve estar nos grupos quando filtrado');
db.close();
});
test('F3: campaignMetrics groups respeitam filtro variant', async () => {
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
addQueue(db, campaign, { leadId: addLead(db, 7100, 'restaurantes'), status: 'sent', variantCode: 'A', segmento: 'restaurantes' });
addQueue(db, campaign, { leadId: addLead(db, 7101, 'restaurantes'), status: 'sent', variantCode: 'B', segmento: 'restaurantes', suffix: 7101 });
const filtered = campaignMetrics(db, campaign.id, { variant: 'A' });
assert.ok(filtered.groups.byVariant.A, 'variant A deve estar nos grupos');
assert.equal(filtered.groups.byVariant.B, undefined, 'variant B NAO deve estar nos grupos quando filtrado');
db.close();
});
// --- worker and store wiring tests ---
test('campaign-worker avalia saude apos finalizacao de envio', async () => {
const { runDueCampaignQueue } = await import('../src/outreach/campaign-worker.mjs');
const { campaignMetrics } = await metricsModule();
const { db, campaign } = setup();
saveWhatsAppVerification(db, { numero: '5511999900001', status: 'confirmado' });
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(campaign.id);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-metrics', 'Teste', '5511999900001', 'restaurantes', 1)`
).run();
const leadId = Number(db.prepare('SELECT id FROM leads').get().id);
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
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', 'Msg', 'test', 'scheduled', ?)`
).run(campaign.id, leadId, variantId, campaign.produto, '5511999900001',
'2026-07-29T12:00:00.000Z');
let afterSendCalled = false;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend();
const result = { messageId: 'metrics-test' };
await args.afterSend(result, null, context);
afterSendCalled = true;
return result;
}
};
await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(afterSendCalled, true);
const metrics = campaignMetrics(db, campaign.id);
assert.equal(metrics.provider.aceito, 1);
assert.equal(metrics.attempted, 1);
db.close();
});
test('campaign-store avalia saude apos mudanca manual de etapa', async () => {
const { evaluateCampaignHealth } = await metricsModule();
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);
db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('store-test', 'Teste')").run();
const leadId = Number(db.prepare('SELECT id FROM leads').get().id);
setLeadCommercialStage(db, { leadId, campaignId: campaign.id, stage: 'sem_interesse', note: 'nao quis' });
const health = evaluateCampaignHealth(db, campaign.id);
assert.equal(typeof health.pause, 'boolean');
db.close();
});