import test from 'node:test'; import assert from 'node:assert/strict'; import { openDb, setSetting } from '../src/db.mjs'; import { listCampaigns, saveAppointment, seedCampaigns, setLeadCommercialStage } from '../src/outreach/campaign-store.mjs'; import { createRouter } from '../src/server/router.mjs'; import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs'; function setup({ state = 'READY', now = new Date(2026, 6, 29, 12), random = () => 0 } = {}) { const db = openDb(':memory:'); seedCampaigns(db); setSetting(db, 'outreach.mensagensPorDia', '7'); let checks = 0; let sends = 0; const whatsapp = { getStatus: () => ({ state, connected: state === 'READY' }), checkNumber: async (number) => { checks += 1; return { registered: true, numero: number, numero_resolvido: number }; }, sendText: async () => { sends += 1; throw new Error('rota de campanha nao pode enviar mensagens'); } }; const router = createRouter(); registerCampaignRoutes(router, { db, whatsapp, random, now: () => now }); return { db, router, whatsapp, counts: () => ({ checks, sends }) }; } async function invoke(router, method, pathname, { body, user = { role: 'admin' }, query = new URLSearchParams() } = {}) { const matched = router.match(method, pathname); assert.ok(matched, `rota ausente: ${method} ${pathname}`); const calls = []; await matched.handler(null, null, { params: matched.params, body, user, query, json: (status, data) => calls.push({ status, data }) }); assert.equal(calls.length, 1); return calls[0]; } function campaignIds(db) { return db.prepare('SELECT id FROM outreach_campaigns ORDER BY id').all().map((row) => row.id); } function addPreview(db, campaignId, suffix) { const campaign = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(campaignId); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1').get(campaignId); const lead = db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score) VALUES (?, ?, ?, 'restaurantes', 1, 90)` ).run(`lead-${campaignId}-${suffix}`, `Empresa ${suffix}`, `551199999${String(suffix).padStart(4, '0')}`); return Number(db.prepare( `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (?, ?, ?, ?, ?, 'restaurantes', 'mensagem aprovada', 'teste', 'preview')` ).run(campaignId, Number(lead.lastInsertRowid), variant.id, campaign.produto, `551199999${String(suffix).padStart(4, '0')}`).lastInsertRowid); } function schedulePreview(db, queueId, hour = 9, minute = 30) { const scheduledAt = new Date(2026, 6, 29, hour, minute).toISOString(); db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?") .run(scheduledAt, queueId); } test('lista, detalha e filtra fila sem misturar uso manual e automatico', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); addPreview(db, campaignId, 1); db.prepare( `INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('G-Delivery', '5511999999001', 'manual', 'aceito', 'manual')` ).run(); for (const [index, status] of ['pendente', 'enviado', 'entregue', 'lido', 'desconhecido', 'erro'].entries()) { db.prepare( `INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('G-Delivery', ?, 'manual', ?, 'manual')` ).run(`55118888000${index}`, status); } const automaticSendId = Number(db.prepare( `INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('G-Delivery', '5511999999002', 'campanha', 'aceito', 'automatico')` ).run().lastInsertRowid); const queued = addPreview(db, campaignId, 2); schedulePreview(db, queued); db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(queued); db.prepare("UPDATE outreach_campaign_queue SET status = 'sent', outreach_send_id = ? WHERE id = ?") .run(automaticSendId, queued); const listed = await invoke(router, 'GET', '/api/campaigns', { user: { role: 'comercial' } }); assert.equal(listed.status, 200); assert.equal(listed.data.campaigns.length, 3); const campaign = listed.data.campaigns.find((item) => item.id === campaignId); assert.deepEqual(campaign.usage, { manual: { limit: 7, used: 6, remaining: 1 }, campaign: { limit: 10, used: 1, remaining: 9 } }); const detail = await invoke(router, 'GET', `/api/campaigns/${campaignId}`, { user: { role: 'comercial' } }); assert.equal(detail.data.campaign.id, campaignId); assert.deepEqual(detail.data.usage, campaign.usage); const queue = await invoke(router, 'GET', `/api/campaigns/${campaignId}/queue`, { user: { role: 'comercial' }, query: new URLSearchParams({ status: 'preview' }) }); assert.deepEqual(queue.data.queue.map((row) => row.status), ['preview']); db.close(); }); test('todas as mutacoes rejeitam usuario nao-admin antes de alterar estado', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); const queueId = addPreview(db, campaignId, 3); const requests = [ ['POST', `/api/campaigns/${campaignId}/prepare`, {}], ['POST', `/api/campaigns/${campaignId}/approve`, { queueIds: [queueId], date: '2026-07-27' }], ['DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`, undefined], ['POST', `/api/campaigns/${campaignId}/start`, {}], ['POST', `/api/campaigns/${campaignId}/pause`, { reason: 'revisao' }], ['PUT', `/api/campaigns/${campaignId}/week`, { week: 2 }] ]; for (const [method, path, body] of requests) { const response = await invoke(router, method, path, { body, user: { role: 'comercial' } }); assert.equal(response.status, 403, `${method} ${path}`); } assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'preview'); assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1); db.close(); }); test('preparacao usa verificacao gradual e nunca chama envio', async () => { const { db, router, counts } = setup(); const [campaignId] = campaignIds(db); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score) VALUES ('eligible', 'Hamburgueria Sol', '5511999990001', 'hamburguerias', 1, 100)` ).run(); const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/prepare`, { body: {} }); assert.equal(response.status, 200); assert.equal(response.data.summary.previewed, 1); assert.deepEqual(counts(), { checks: 1, sends: 0 }); db.close(); }); test('aprovacao exige IDs explicitos da propria campanha e faz transicao atomica', async () => { const { db, router } = setup(); const [campaignId, otherCampaignId] = campaignIds(db); const selected = addPreview(db, campaignId, 10); const removed = addPreview(db, campaignId, 11); const foreign = addPreview(db, otherCampaignId, 12); const originalPayload = db.prepare( 'SELECT variant_id, rendered_message FROM outreach_campaign_queue WHERE id = ?' ).get(selected); const missing = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { date: '2026-07-27' } }); assert.equal(missing.status, 400); const mixed = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { queueIds: [selected, foreign], date: '2026-07-27' } }); assert.equal(mixed.status, 409); assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(selected, removed).map((row) => row.status), ['preview', 'preview']); const approved = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { queueIds: [selected], date: '2026-07-27' } }); assert.equal(approved.status, 200); assert.equal(approved.data.scheduled, 1); const rows = db.prepare( 'SELECT id, status, variant_id, rendered_message, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id' ).all(campaignId); assert.equal(rows[0].status, 'scheduled'); assert.match(rows[0].scheduled_at, /^2026-07-27T/); assert.equal(rows[0].variant_id, originalPayload.variant_id); assert.equal(rows[0].rendered_message, originalPayload.rendered_message); const { variant_id: ignoredVariant, rendered_message: ignoredMessage, ...removedRow } = rows[1]; void ignoredVariant; void ignoredMessage; assert.deepEqual({ ...removedRow }, { id: removed, status: 'cancelled', scheduled_at: null, cancel_reason: 'removed_by_operator' }); assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'ready'); db.close(); }); test('aprovacao reverte tudo quando a data ou janelas nao fornecem slots suficientes', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); const first = addPreview(db, campaignId, 20); const second = addPreview(db, campaignId, 21); db.prepare("UPDATE outreach_campaigns SET windows_json = '[{\"start\":\"09:30\",\"end\":\"09:30\"}]' WHERE id = ?").run(campaignId); const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { queueIds: [first, second], date: '2026-07-27' } }); assert.equal(response.status, 409); assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id').all(campaignId).map((row) => row.status), ['preview', 'preview']); assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused'); db.close(); }); test('aprovacao rejeita excesso somando fila ja ativa no mesmo dia local', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); const existing = Array.from({ length: 10 }, (_, index) => addPreview(db, campaignId, 100 + index)); existing.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index)); const selected = addPreview(db, campaignId, 111); const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { queueIds: [selected], date: '2026-07-29' } }); assert.equal(response.status, 409); assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(selected).status, 'preview'); assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 10); db.close(); }); test('remocao cancela somente item mutavel pertencente a campanha', async () => { const { db, router } = setup(); const [campaignId, otherCampaignId] = campaignIds(db); const removable = addPreview(db, campaignId, 30); const foreign = addPreview(db, otherCampaignId, 31); const sent = addPreview(db, campaignId, 32); schedulePreview(db, sent); db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(sent); db.prepare("UPDATE outreach_campaign_queue SET status = 'sent' WHERE id = ?").run(sent); assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${foreign}`)).status, 404); assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${sent}`)).status, 409); assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${removable}`)).status, 200); assert.deepEqual({ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(removable) }, { status: 'cancelled', cancel_reason: 'removed_by_operator' }); db.close(); }); test('remocao de item agendado preserva timestamp imutavel de auditoria', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); const queueId = addPreview(db, campaignId, 33); schedulePreview(db, queueId); const before = db.prepare('SELECT scheduled_at FROM outreach_campaign_queue WHERE id = ?').get(queueId).scheduled_at; const response = await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`); assert.equal(response.status, 200); assert.deepEqual({ ...db.prepare('SELECT status, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) }, { status: 'cancelled', scheduled_at: before, cancel_reason: 'removed_by_operator' }); db.close(); }); test('inicio exige fila agendada, WhatsApp READY e estado ready ou paused', async () => { const noSchedule = setup(); const [campaignId] = campaignIds(noSchedule.db); assert.equal((await invoke(noSchedule.router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} })).status, 409); noSchedule.db.close(); const disconnected = setup({ state: 'DISCONNECTED' }); const [disconnectedId] = campaignIds(disconnected.db); const queueId = addPreview(disconnected.db, disconnectedId, 40); disconnected.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(queueId); assert.equal((await invoke(disconnected.router, 'POST', `/api/campaigns/${disconnectedId}/start`, { body: {} })).status, 409); assert.equal(disconnected.db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(disconnectedId).status, 'paused'); disconnected.db.close(); const ready = setup(); const [readyId] = campaignIds(ready.db); const scheduled = addPreview(ready.db, readyId, 41); ready.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(scheduled); const started = await invoke(ready.router, 'POST', `/api/campaigns/${readyId}/start`, { body: {} }); assert.equal(started.status, 200); assert.equal(started.data.campaign.status, 'running'); ready.db.close(); }); test('inicio rejeita dia local com tentativas ativas acima do limite aprovado', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 200 + index)); queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index)); const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} }); assert.equal(response.status, 409); assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused'); db.close(); }); test('reducao de semana rejeita limite abaixo da fila ativa do dia sem alterar controles', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); db.prepare('UPDATE outreach_campaigns SET week_number = 4, approved_daily_limit = 18 WHERE id = ?').run(campaignId); const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 300 + index)); queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index)); const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 1 } }); assert.equal(response.status, 409); assert.deepEqual({ ...db.prepare('SELECT week_number, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaignId) }, { week_number: 4, approved_daily_limit: 18 }); assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 11); db.close(); }); test('pausa exige motivo e semana muda somente pelo mapa explicito', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); assert.equal((await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: {} })).status, 400); const paused = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: { reason: 'revisao diaria' } }); assert.equal(paused.status, 200); assert.equal(paused.data.campaign.pause_reason, 'revisao diaria'); assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: {} })).status, 400); assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1); for (const [week, limit] of [[1, 10], [2, 12], [3, 15], [4, 18]]) { const changed = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week } }); assert.equal(changed.status, 200); assert.equal(changed.data.campaign.week_number, week); assert.equal(changed.data.campaign.approved_daily_limit, limit); } assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 5 } })).status, 400); db.close(); }); test('pausa aceita somente ready, running ou paused e preserva estados terminais', async () => { const { db, router } = setup(); const [, draftId, completedId] = campaignIds(db); db.prepare("UPDATE outreach_campaigns SET status = 'draft' WHERE id = ?").run(draftId); db.prepare("UPDATE outreach_campaigns SET status = 'completed' WHERE id = ?").run(completedId); for (const campaignId of [draftId, completedId]) { const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: { reason: 'nao deve alterar' } }); assert.equal(response.status, 409); } assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(draftId).status, 'draft'); assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(completedId).status, 'completed'); db.close(); }); test('falha operacional retorna 500 generico sem vazar mensagem SQLite', async () => { const { db, router } = setup(); const [campaignId] = campaignIds(db); db.exec(` CREATE TRIGGER campaign_operational_failure BEFORE UPDATE ON outreach_campaigns BEGIN SELECT RAISE(FAIL, 'sqlite-secret-detail'); END; `); const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 2 } }); assert.deepEqual(response, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } }); assert.doesNotMatch(JSON.stringify(response), /sqlite-secret-detail/i); db.close(); }); test('aprovacao separa validacao conhecida de falha inesperada do agendador', async () => { const invalid = setup(); const [invalidCampaignId] = campaignIds(invalid.db); const invalidQueueId = addPreview(invalid.db, invalidCampaignId, 500); const validation = await invoke(invalid.router, 'POST', `/api/campaigns/${invalidCampaignId}/approve`, { body: { queueIds: [invalidQueueId], date: '2026-02-30' } }); assert.equal(validation.status, 400); assert.match(validation.data.erro, /data local invalida/i); invalid.db.close(); const secret = 'SCHEDULE-SENTINEL-SECRET'; const unexpected = setup({ random: () => { throw new Error(secret); } }); const [campaignId] = campaignIds(unexpected.db); const first = addPreview(unexpected.db, campaignId, 501); const second = addPreview(unexpected.db, campaignId, 502); const failure = await invoke(unexpected.router, 'POST', `/api/campaigns/${campaignId}/approve`, { body: { queueIds: [first, second], date: '2026-07-29' } }); assert.deepEqual(failure, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } }); assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret, 'i')); assert.deepEqual(unexpected.db.prepare('SELECT status FROM outreach_campaign_queue ORDER BY id').all().map((row) => row.status), ['preview', 'preview']); unexpected.db.close(); }); test('GET /api/campaigns/replies/pending retorna lista de leads que responderam', async () => { const { db, router } = setup(); const campaigns = listCampaigns(db); const leadId1 = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-1', 'Lead Respondeu', '5511999991001')" ).run().lastInsertRowid); const leadId2 = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-2', 'Lead Avancado', '5511999991002')" ).run().lastInsertRowid); setLeadCommercialStage(db, { leadId: leadId1, campaignId: campaigns[0].id, stage: 'respondeu', note: 'quer info' }); setLeadCommercialStage(db, { leadId: leadId2, campaignId: campaigns[0].id, stage: 'enviado' }); const response = await invoke(router, 'GET', '/api/campaigns/replies/pending'); assert.equal(response.status, 200); assert.equal(response.data.pending.length, 1); assert.equal(response.data.pending[0].lead_id, leadId1); db.close(); }); test('PUT /api/campaigns/leads/:leadId/stage atualiza etapa comercial', async () => { const { db, router } = setup(); const leadId = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-1', 'Lead Stage', '5511999991010')" ).run().lastInsertRowid); const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, { body: { stage: 'interessado_gti_vendas', note: 'demonstracao marcada' } }); assert.equal(response.status, 200); assert.equal(response.data.state.stage, 'interessado_gti_vendas'); db.close(); }); test('PUT /api/campaigns/leads/:leadId/stage rejeita etapa invalida', async () => { const { db, router } = setup(); const leadId = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-2', 'Lead Inv', '5511999991020')" ).run().lastInsertRowid); const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, { body: { stage: 'etapa_inexistente' } }); assert.equal(response.status, 400); assert.match(JSON.stringify(response.data), /etapa comercial invalida/i); db.close(); }); test('POST /api/campaigns/leads/:leadId/appointments cria agendamento', async () => { const { db, router } = setup(); const campaigns = listCampaigns(db); const leadId = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-1', 'Lead App', '5511999991030')" ).run().lastInsertRowid); const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, { body: { campaignId: campaigns[0].id, kind: 'demonstracao', participantName: 'Maria', companyName: 'Empresa App', preferredDay: '2026-08-10', preferredTime: '15:00' } }); assert.equal(response.status, 201); assert.equal(response.data.appointment.kind, 'demonstracao'); assert.equal(response.data.appointment.participant_name, 'Maria'); db.close(); }); test('POST /api/campaigns/leads/:leadId/appointments rejeita demonstracao sem campos obrigatorios', async () => { const { db, router } = setup(); const leadId = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-2', 'Lead App Fail', '5511999991040')" ).run().lastInsertRowid); const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, { body: { kind: 'demonstracao' } }); assert.equal(response.status, 400); db.close(); }); test('GET /api/campaigns/replies/pending funciona sem autenticacao de admin', async () => { const { db, router } = setup(); const response = await invoke(router, 'GET', '/api/campaigns/replies/pending', { user: { role: 'comercial' } }); assert.equal(response.status, 200); assert.ok(Array.isArray(response.data.pending)); db.close(); });