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 = []; 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 }); } for (let i = 0; i < 8; i++) { addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' }); } for (let i = 8; i < 10; i++) { addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' }); } 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 = []; 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 }); } 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[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' }); // 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(); }); // --- 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(); });