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);
|
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(
|
const attemptsToday = Number(db.prepare(
|
||||||
`SELECT COUNT(DISTINCT s.id) AS total
|
`SELECT COUNT(DISTINCT s.id) AS total
|
||||||
FROM outreach_sends s
|
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'
|
WHERE s.origem = 'automatico'
|
||||||
AND s.status IN ('aceito', 'erro')
|
AND s.status IN ('aceito', 'erro')
|
||||||
AND date(s.sent_at, 'localtime') = ?`
|
AND date(s.sent_at, 'localtime') = ?`
|
||||||
@@ -213,7 +219,7 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
|||||||
const failureCount = Number(db.prepare(
|
const failureCount = Number(db.prepare(
|
||||||
`SELECT COUNT(DISTINCT s.id) AS total
|
`SELECT COUNT(DISTINCT s.id) AS total
|
||||||
FROM outreach_sends s
|
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'
|
WHERE s.origem = 'automatico'
|
||||||
AND s.status = 'erro'
|
AND s.status = 'erro'
|
||||||
AND date(s.sent_at, 'localtime') = ?`
|
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');
|
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||||
const leads = [];
|
const leads = [];
|
||||||
|
const queueIds = [];
|
||||||
for (let i = 0; i < 11; i++) {
|
for (let i = 0; i < 11; i++) {
|
||||||
const lid = addLead(db, 100 + i);
|
const lid = addLead(db, 100 + i);
|
||||||
leads.push(lid);
|
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++) {
|
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++) {
|
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);
|
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');
|
const now = new Date('2026-07-29T12:00:00.000Z');
|
||||||
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
||||||
const leads = [];
|
const leads = [];
|
||||||
|
const queueIds = [];
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const lid = addLead(db, 200 + i);
|
const lid = addLead(db, 200 + i);
|
||||||
leads.push(lid);
|
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++) {
|
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);
|
const result = evaluateCampaignHealth(db, campaign.id, now);
|
||||||
assert.equal(result.pause, false);
|
assert.equal(result.pause, false);
|
||||||
@@ -537,6 +539,44 @@ test('F1: campaignMetrics sends nao contaminam entre campanhas (usa outreach_sen
|
|||||||
db.close();
|
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) ---
|
// --- F2 regression: negative/opt-out count uses COUNT(DISTINCT) ---
|
||||||
|
|
||||||
test('F2: evaluateCampaignHealth negative count usa COUNT(DISTINCT) para evitar cartesian', async () => {
|
test('F2: evaluateCampaignHealth negative count usa COUNT(DISTINCT) para evitar cartesian', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user