Tracks the app that was already in place (capture, treatment, outreach, WhatsApp integration, server, frontend, tests, docs) so future feature work can branch and use isolated worktrees.
660 lines
28 KiB
JavaScript
660 lines
28 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 = [];
|
|
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();
|
|
});
|