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.
This commit is contained in:
Gustavo
2026-07-31 22:35:21 -03:00
parent d6fdfc6e2d
commit 3e55b843e2
2 changed files with 54 additions and 8 deletions
+8 -2
View File
@@ -200,10 +200,16 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const todayKey = dateKey(now);
// Junta pelo vinculo real da fila (outreach_send_id), nao por
// lead_id+campaign_id: o catalogo de campanhas de hoje usa segmentos
// mutuamente exclusivos, entao um lead nunca esta em duas campanhas ao
// mesmo tempo — mas se isso mudar, lead_id+campaign_id contaria um envio
// automatico de OUTRA campanha (mesmo lead, historico anterior) nas
// metricas desta.
const attemptsToday = Number(db.prepare(
`SELECT COUNT(DISTINCT s.id) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id AND q.campaign_id = ?
WHERE s.origem = 'automatico'
AND s.status IN ('aceito', 'erro')
AND date(s.sent_at, 'localtime') = ?`
@@ -213,7 +219,7 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const failureCount = Number(db.prepare(
`SELECT COUNT(DISTINCT s.id) AS total
FROM outreach_sends s
JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ?
JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id AND q.campaign_id = ?
WHERE s.origem = 'automatico'
AND s.status = 'erro'
AND date(s.sent_at, 'localtime') = ?`
+46 -6
View File
@@ -289,16 +289,17 @@ test('evaluateCampaignHealth pausa por high_failure_rate quando > 10%', async ()
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);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 100 + i });
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' });
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' });
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
const result = evaluateCampaignHealth(db, campaign.id, now);
@@ -313,15 +314,16 @@ test('evaluateCampaignHealth NAO pausa por failure rate exatamente 10%', async (
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);
addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 200 + i });
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' });
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' }); // 1/10 = 10% → not > 10%
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);
@@ -537,6 +539,44 @@ test('F1: campaignMetrics sends nao contaminam entre campanhas (usa outreach_sen
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 () => {