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:
@@ -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') = ?`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user