Baseline commit of the existing Maquina Comercial Pessoal project

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.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, verifyPassword } from '../src/auth.mjs';
import { openDb, createUser, findUserByUsername, listUsers, ensureDefaultAdmin } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerAuthRoutes } from '../src/server/routes/auth.mjs';
import { createSession, getSession } from '../src/server/session.mjs';
test('hashPassword/verifyPassword aceita a senha certa e rejeita a errada', () => {
const stored = hashPassword('minhaSenha123');
assert.ok(verifyPassword('minhaSenha123', stored));
assert.ok(!verifyPassword('senhaErrada', stored));
});
test('hashPassword gera salt diferente a cada chamada (hashes distintos p/ mesma senha)', () => {
assert.notEqual(hashPassword('abc'), hashPassword('abc'));
});
test('ensureDefaultAdmin cria admin/admin somente no primeiro acesso', () => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
const admin = findUserByUsername(db, 'admin');
assert.ok(admin);
assert.equal(admin.role, 'admin');
assert.ok(verifyPassword('admin', admin.password_hash));
createUser(db, { username: 'vendedor1', passwordHash: hashPassword('123456'), role: 'comercial' });
ensureDefaultAdmin(db); // nao deve recriar nem duplicar
assert.equal(listUsers(db).length, 2);
db.close();
});
test('createUser rejeita username duplicado (constraint UNIQUE)', () => {
const db = openDb(':memory:');
createUser(db, { username: 'joao', passwordHash: hashPassword('123456'), role: 'comercial' });
assert.throws(() => createUser(db, { username: 'joao', passwordHash: hashPassword('outra'), role: 'admin' }));
db.close();
});
test('usuario autenticado troca a propria senha validando atual e minimo', async () => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
const router = createRouter();
registerAuthRoutes(router, { db });
const handler = router.match('PUT', '/api/auth/password').handler;
const invoke = async (body) => {
let response;
await handler({}, {}, { body, user: { id: 1, username: 'admin', role: 'admin' }, json: (status, data) => { response = { status, data }; } });
return response;
};
assert.equal((await invoke({ senhaAtual: 'errada', novaSenha: 'novaSenha123' })).status, 403);
assert.equal((await invoke({ senhaAtual: 'admin', novaSenha: 'curta' })).status, 400);
const token1 = createSession({ id: 1, username: 'admin', role: 'admin' });
const token2 = createSession({ id: 1, username: 'admin', role: 'admin' });
const changed = await invoke({ senhaAtual: 'admin', novaSenha: 'novaSenha123' });
assert.equal(changed.status, 200);
assert.equal(changed.data.relogin, true);
assert.equal(getSession(token1), null);
assert.equal(getSession(token2), null);
assert.ok(verifyPassword('novaSenha123', findUserByUsername(db, 'admin').password_hash));
db.close();
});
test('criacao de usuario exige senha com pelo menos 8 caracteres', async () => {
const db = openDb(':memory:');
const router = createRouter();
registerAuthRoutes(router, { db });
let response;
await router.match('POST', '/api/users').handler({}, {}, {
user: { role: 'admin' }, body: { username: 'novo', password: '1234567' },
json: (status, data) => { response = { status, data }; }
});
assert.equal(response.status, 400);
db.close();
});
+388
View File
@@ -0,0 +1,388 @@
import { test, expect } from '@playwright/test';
import { startTestServer } from './server.mjs';
let ctx;
test.beforeAll(async () => {
ctx = await startTestServer();
});
test.afterAll(async () => {
if (ctx) await ctx.teardown();
});
async function loginViaApi() {
const login = await ctx.apiRequest('POST', '/api/auth/login', {
body: { username: 'admin', password: ctx.adminPw }
});
expect(login.status).toBe(200);
const setCookie = login.headers.get('set-cookie');
return setCookie ? setCookie.split(';')[0] : '';
}
async function setCookieOnPage(page, cookie) {
const match = cookie ? cookie.match(/maquina_pessoal_session=([^;]+)/) : null;
if (!match) return;
await page.context().addCookies([{
name: 'maquina_pessoal_session',
value: match[1],
domain: '127.0.0.1',
path: '/',
httpOnly: true,
sameSite: 'Strict'
}]);
}
test.describe('Campaign Pilot Browser Tests', () => {
test.describe('Authentication', () => {
test('login with seeded admin credentials', async ({ page }) => {
await page.goto('/login.html');
await expect(page.locator('#form-login')).toBeVisible();
await page.fill('#login-username', 'admin');
await page.fill('#login-password', ctx.adminPw);
await page.click('button[type="submit"]');
await page.waitForURL('**/', { timeout: 10000 });
await expect(page.locator('#usuario-logado')).toContainText('admin');
});
});
test.describe('Campaign Cards', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('campaign tab shows cards with correct status/limits', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cards = page.locator('.campaign-card');
const count = await cards.count();
expect(count).toBe(3);
const cardText = await cards.first().textContent();
expect(cardText).toContain('G-Delivery');
expect(cardText).toContain('Semana 1');
expect(cardText).toContain('Limite: 10');
});
test('separate manual/automatic limits display', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cardText = await page.locator('.campaign-card').first().textContent();
expect(cardText).toMatch(/Manual|manual/);
expect(cardText).toMatch(/Automático|auto/i);
});
});
test.describe('Campaign Queue Operations (via API)', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('prepare creates queue items', async () => {
const { data } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = data.campaigns[0].id;
const response = await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
expect(response.status).toBe(200);
expect(response.data.summary.previewed).toBeGreaterThanOrEqual(0);
});
test('preview rows show server-rendered text', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = campaigns.campaigns[0].id;
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
const { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue`, {
headers: { cookie }
});
expect(queue.queue.length).toBeGreaterThanOrEqual(1);
expect(queue.queue[0].rendered_message).toBeTruthy();
expect(queue.queue[0].status).toBe('preview');
});
test('per-row removal works', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = campaigns.campaigns[0].id;
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
const { data: before } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
headers: { cookie }
});
if (before.queue.length === 0) return;
const firstId = before.queue[0].id;
const removeResponse = await ctx.apiRequest('DELETE', `/api/campaigns/${campaignId}/queue/${firstId}`, {
headers: { cookie }
});
expect(removeResponse.status).toBe(200);
const { data: after } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
headers: { cookie }
});
expect(after.queue.find((q) => q.id === firstId)).toBeUndefined();
});
test('approval flow works', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'paused');
if (!campaign) return;
let { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
headers: { cookie }
});
if (queue.queue.length === 0) {
await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/prepare`, {
headers: { cookie }, body: {}
});
const { data: refetched } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
headers: { cookie }
});
if (refetched.queue.length === 0) return;
queue.queue = refetched.queue;
}
const ids = queue.queue.slice(0, 1).map((q) => q.id);
const approveResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/approve`, {
headers: { cookie },
body: { queueIds: ids, date: '2026-07-29' }
});
expect(approveResponse.status).toBe(200);
expect(approveResponse.data.scheduled).toBeGreaterThanOrEqual(1);
const { data: updated } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const updatedCampaign = updated.campaigns.find((c) => c.id === campaign.id);
expect(['ready', 'running']).toContain(updatedCampaign.status);
});
test('pause shows reason', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'ready' || c.status === 'paused');
if (!campaign) return;
const pauseResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/pause`, {
headers: { cookie },
body: { reason: 'revisao manual do operador' }
});
expect(pauseResponse.status).toBe(200);
expect(pauseResponse.data.campaign.pause_reason).toBe('revisao manual do operador');
expect(pauseResponse.data.campaign.status).toBe('paused');
});
test('start succeeds on paused campaign with scheduled items', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'paused' || c.status === 'ready');
if (!campaign) return;
const startResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/start`, {
headers: { cookie }, body: {}
});
expect(startResponse.status).toBe(200);
expect(startResponse.data.campaign.status).toBe('running');
});
});
test.describe('Stage Classification', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('stage classification works via API', async () => {
const leadId = Number(ctx.db.prepare(
"SELECT id FROM leads WHERE empresa LIKE '%Restaurante%' LIMIT 1"
).get()?.id);
if (!leadId) return;
const stageResponse = await ctx.apiRequest('PUT', `/api/campaigns/leads/${leadId}/stage`, {
headers: { cookie },
body: { stage: 'interessado_gti_vendas', note: 'demonstracao agendada' }
});
expect(stageResponse.status).toBe(200);
expect(stageResponse.data.state.stage).toBe('interessado_gti_vendas');
});
test('appointment/demo scheduling form works via API', async () => {
const leadId = Number(ctx.db.prepare(
"SELECT id FROM leads WHERE empresa LIKE '%Pizzaria%' LIMIT 1"
).get()?.id);
if (!leadId) return;
const appointmentResponse = await ctx.apiRequest('POST', `/api/campaigns/leads/${leadId}/appointments`, {
headers: { cookie },
body: {
kind: 'demonstracao',
participantName: 'Maria Silva',
companyName: 'Pizzaria Teste',
preferredDay: '2026-08-05',
preferredTime: '15:30'
}
});
expect(appointmentResponse.status).toBe(201);
expect(appointmentResponse.data.appointment.kind).toBe('demonstracao');
});
test('pending replies section accessible via API', async () => {
const { data } = await ctx.apiRequest('GET', '/api/campaigns/replies/pending', {
headers: { cookie }
});
expect(Array.isArray(data.pending)).toBe(true);
});
test('metrics endpoint returns data', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
if (campaigns.campaigns.length === 0) return;
const campaignId = campaigns.campaigns[0].id;
const { data } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/metrics`, {
headers: { cookie }
});
expect(data.metrics).toBeDefined();
});
test('metrics supports segmento and variant filters', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
if (campaigns.campaigns.length === 0) return;
const campaignId = campaigns.campaigns[0].id;
const { data: filtered } = await ctx.apiRequest(
'GET',
`/api/campaigns/${campaignId}/metrics?segmento=hamburguerias&variant=A`,
{ headers: { cookie } }
);
expect(filtered.metrics).toBeDefined();
});
});
test.describe('UI Rendering', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('pending replies section visible on campaigns page', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const repliesSection = page.locator('#pending-replies');
await expect(repliesSection).toBeVisible();
const content = await repliesSection.textContent();
expect(content.length).toBeGreaterThan(0);
});
test('stage classification section renders', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const stageSection = page.locator('#stage-classification');
await expect(stageSection).toBeVisible();
});
test('appointment scheduling form renders', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const appointmentSection = page.locator('#appointment-form');
await expect(appointmentSection).toBeVisible();
});
test('metrics section renders with filters', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const metricsFilters = page.locator('#metrics-filters');
await expect(metricsFilters).toBeVisible();
await page.waitForTimeout(1000);
const filterSelects = metricsFilters.locator('select');
const selectCount = await filterSelects.count();
expect(selectCount).toBeGreaterThanOrEqual(1);
});
});
test.describe('Safety', () => {
test('fake provider send count remains zero', async () => {
expect(ctx.fakeSends.length).toBe(0);
});
});
test.describe('Mobile Viewport', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('mobile viewport renders campaign cards', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cards = page.locator('.campaign-card');
const count = await cards.count();
expect(count).toBe(3);
for (let i = 0; i < count; i++) {
await expect(cards.nth(i)).toBeVisible();
}
const queueSection = page.locator('#campaign-queue');
await expect(queueSection).toBeVisible();
});
test('mobile viewport shows full-width controls', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const section = page.locator('#tab-campanhas');
const box = await section.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(350);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { openDb, setSetting } from '../../src/db.mjs';
import { hashPassword } from '../../src/auth.mjs';
import { createUser } from '../../src/db.mjs';
import { createAppServer } from '../../src/server/index.mjs';
import { seedCampaigns } from '../../src/outreach/campaign-store.mjs';
const DEBUG = process.env.DEBUG_SERVER === '1';
const log = DEBUG ? (m) => process.stderr.write(`[test-server] ${m}\n`) : () => {};
function randomHex(length) {
const bytes = new Uint8Array(Math.ceil(length / 2));
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
}
export async function startTestServer() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-'));
const dbPath = path.join(tmpDir, 'test.db');
process.env.APP_DB_PATH = dbPath;
process.env.APP_WHATSAPP_AUTH_DIR = path.join(tmpDir, 'whatsapp-auth');
const db = openDb(dbPath);
const adminPw = 'admin-senha-teste';
createUser(db, { username: 'admin', passwordHash: hashPassword(adminPw), role: 'admin' });
seedCampaigns(db);
setSetting(db, 'outreach.mensagensPorDia', '10');
const campaignIds = db.prepare('SELECT id, code FROM outreach_campaigns ORDER BY id').all();
const leadIds = [];
const empresas = {
'1': ['Hamburgueria Bella', 'Pizzaria Famosa', 'Acaiteria Tropical', 'Marmitaria Sabor', 'Lanchonete do Ze'],
'2': ['Restaurante Chefe', 'Mercado Central', 'Mercearia Sao Jose', 'Minimercado Bom', 'Padaria Pao Quente'],
'3': ['Loja de Moda', 'Material Construcao', 'Autopecas Veloz', 'Oficina Mecanica']
};
let leadIdCounter = 1;
for (const c of campaignIds) {
const names = empresas[c.code] || ['Empresa Generica'];
for (const nome of names) {
const whatsapp = `5511999${String(leadIdCounter).padStart(6, '0')}`;
const r = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score, classificacao, telefone_valido)
VALUES (?, ?, ?, ?, ?, ?, 'bom', 1)`
).run(
`browser-test-lead-${leadIdCounter}`,
nome,
whatsapp,
c.code === 'gdelivery-food' ? 'hamburguerias' : c.code === 'gti-food' ? 'restaurantes' : 'lojas_de_roupa',
leadIdCounter <= 5 ? 1 : 2,
100 - leadIdCounter
);
const lid = Number(r.lastInsertRowid);
leadIds.push({ id: lid, empresa: nome, whatsapp, campaignId: c.id });
db.prepare(
`INSERT INTO whatsapp_number_verifications (numero, status, numero_resolvido, checked_at)
VALUES (?, 'confirmado', ?, datetime('now'))`
).run(whatsapp, whatsapp);
leadIdCounter += 1;
}
}
const firstLead = leadIds[0];
setSetting(db, 'outreach.ativo', 'true');
const firstCampaignId = campaignIds[0].id;
const variantIds = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1'
).all(firstCampaignId);
if (variantIds.length > 0 && firstLead) {
const variantId = variantIds[0].id;
const produto = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(firstCampaignId).produto;
db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'preview')`
).run(firstCampaignId, firstLead.id, variantId, produto, firstLead.whatsapp, 'hamburguerias',
'Ola! Teste de mensagem renderizada para campanha.', 'browser-test');
}
const stageLeadId = leadIds.length > 2 ? leadIds[2].id : (firstLead ? firstLead.id : null);
if (stageLeadId) {
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
VALUES (?, 'respondeu', datetime('now'))`
).run(stageLeadId);
db.prepare(
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note)
VALUES (?, ?, 'respondeu', 'cliente quer saber mais sobre G-Delivery')`
).run(stageLeadId, firstCampaignId);
}
const replyLeadId = leadIds.length > 1 ? leadIds[1].id : (firstLead ? firstLead.id : null);
if (replyLeadId) {
db.prepare(
`INSERT OR IGNORE INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at)
VALUES (?, ?, ?, datetime('now'))`
).run(`test-inbound-${Date.now()}`, replyLeadId, leadIds[1]?.whatsapp || '5511999990002');
}
let fakeSends = [];
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true }),
getQr: () => null,
connect: async () => {},
disconnect: async () => {},
checkNumber: async (number) => ({
registered: true,
numero: number,
numeroResolvido: number,
status: 'confirmado'
}),
sendText: async () => {
fakeSends.push({ at: new Date().toISOString() });
throw new Error('fake manager nao envia mensagens reais');
}
};
const jobs = { subscribe: () => null, activeJobs: () => [] };
const server = createAppServer({ db, jobs, whatsapp, log });
return new Promise((resolve, reject) => {
server.listen(3911, '127.0.0.1', () => {
log(`server listening on 3911`);
const apiRequest = async (method, path, options = {}) => {
const url = `http://127.0.0.1:3911${path}`;
const headers = { ...options.headers };
if (options.body && !headers['content-type']) {
headers['content-type'] = 'application/json';
}
const response = await fetch(url, {
method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return { status: response.status, headers: response.headers, data, raw: response };
};
const teardown = async () => {
await new Promise((r) => server.close(r));
db.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
log('test server teardown complete');
};
resolve({ server, db, tmpDir, whatsapp, fakeSends, apiRequest, teardown, adminPw });
});
server.on('error', reject);
});
}
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
managementFocus,
naturalBusinessName,
renderCampaignMessage,
variantForLead
} from '../src/outreach/campaign-messages.mjs';
const refusal = 'Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.';
const approvedCases = [
{
segment: 'restaurantes',
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? ${refusal}`,
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam vendas, estoque, comandas e emissão fiscal em um unico sistema? ${refusal}`
},
{
segment: 'restaurantes',
template: `Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? ${refusal}`,
expected: `Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje vendas, estoque, comandas e emissão fiscal? ${refusal}`
},
{
segment: 'restaurantes',
template: `Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? ${refusal}`,
expected: `Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para vendas, estoque, comandas e emissão fiscal? ${refusal}`
},
{
segment: 'pizzarias',
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? ${refusal}`,
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? ${refusal}`
},
{
segment: 'pizzarias',
template: `Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? ${refusal}`,
expected: `Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? ${refusal}`
},
{
segment: 'pizzarias',
template: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. ${refusal}`,
expected: `Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. ${refusal}`
}
];
test('nome natural aceita marcas curtas com pelo menos duas letras', () => {
assert.equal(naturalBusinessName({ empresa: 'OX' }), 'OX');
assert.equal(naturalBusinessName({ empresa: 'BR2' }), 'BR2');
assert.equal(naturalBusinessName({ empresa: 'Pizzaria Central' }), 'Pizzaria Central');
});
test('nome natural rejeita identificadores, nomes legais, controles, placeholders e ruido generico', () => {
const rejected = [
'',
'7',
'7 Mares',
'61.590.668 A ARAUJO',
'12.345.678/0001-90',
'A ARAUJO LTDA',
'Loja Sol MEI',
'Loja Sol ME',
'Loja Sol EPP',
'Loja Sol EIRELI',
'Loja Sol S/A',
'Loja\nSol',
'Loja\u0000Sol',
'Loja\u200bSol',
'Loja {nome}',
'Loja {{nome}}',
'Empresa',
'Nome Fantasia',
'Nao informado',
'Comercio',
'Pizzaria Central com um nome comercial excessivamente longo para uma saudacao natural e segura'
];
for (const empresa of rejected) {
assert.equal(naturalBusinessName({ empresa, legal_name: 'A ARAUJO LTDA' }), '', empresa);
}
});
test('foco de gestao segue os quatro grupos aprovados', () => {
assert.equal(managementFocus('restaurantes'), 'vendas, estoque, comandas e emissão fiscal');
assert.equal(managementFocus('mercados_de_bairro'), 'vendas, estoque e emissão fiscal');
assert.equal(managementFocus('oficinas_mecanicas'), 'ordens de serviço, estoque e financeiro');
assert.equal(managementFocus('lojas_de_roupa'), 'vendas, estoque, financeiro e notas fiscais');
});
test('variante permanece estavel e considera apenas variantes ativas', () => {
const variants = [{ code: 'A', ativo: 1 }, { code: 'B', ativo: 1 }, { code: 'C', ativo: 1 }];
assert.equal(variantForLead(variants, 4).code, 'B');
assert.equal(variantForLead(variants, 4).code, 'B');
assert.equal(variantForLead([{ code: 'A', ativo: 0 }, ...variants.slice(1)], 4).code, 'B');
assert.throws(() => variantForLead([], 4), /variante ativa/i);
});
test('variante usa a ordem ativa mesmo com ids nao contiguos', () => {
const variants = [
{ id: 8, code: 'A', ativo: 1 },
{ id: 37, code: 'B', ativo: 1 },
{ id: 104, code: 'C', ativo: 1 }
];
assert.equal(variantForLead(variants, 4), variants[1]);
});
test('variante rejeita array nao vazio quando todas estao inativas', () => {
assert.throws(() => variantForLead([
{ id: 8, code: 'A', ativo: 0 },
{ id: 37, code: 'B', ativo: false }
], 4), /variante ativa/i);
});
test('renderizacao preserva os seis textos aprovados fora das substituicoes permitidas', () => {
for (const { segment, template, expected } of approvedCases) {
assert.equal(renderCampaignMessage(template, {
empresa: '61.590.668 A ARAUJO',
legal_name: 'A ARAUJO LTDA',
segmento: segment
}), expected);
}
});
test('renderizacao personaliza somente a saudacao inicial com nome natural', () => {
const template = approvedCases[0].template;
const rendered = renderCampaignMessage(template, {
empresa: 'Pizzaria Central',
segmento: 'restaurantes'
});
assert.equal(rendered, `Olá, pessoal da Pizzaria Central, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam vendas, estoque, comandas e emissão fiscal em um unico sistema? ${refusal}`);
});
test('validacao aceita texto portugues legitimo sem remover diacriticos', () => {
const template = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Gestão ágil para comércio. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
assert.equal(renderCampaignMessage(template, { empresa: '', segmento: 'lojas_de_roupa' }), template);
const punctuation = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Gestão simples (vendas/estoque), ágil e segura. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
assert.equal(renderCampaignMessage(punctuation, { empresa: '' }), punctuation);
});
test('validacao rejeita placeholders simples ou duplos que restarem no texto', () => {
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
const placeholders = [
'{produto}',
'{{produto_2}}',
'{ produto }',
'{produto-nome}',
'{123}',
'{{ produto }}',
'{{produto-nome}}',
'{{123}}'
];
for (const placeholder of placeholders) {
const template = safe.replace('Grupo GTI.', `Grupo GTI. ${placeholder}`);
assert.throws(() => renderCampaignMessage(template, {}), /mensagem inicial insegura/i, placeholder);
}
});
test('validacao de placeholders preserva portugues normal sem chaves', () => {
const template = `Olá, tudo bem? Sou Gustavo, do Grupo GTI. Produto 123 - nome e gestão simples. Se não fizer sentido para sua empresa, é só me avisar que não volto a entrar em contato.`;
assert.equal(renderCampaignMessage(template, {}), template);
});
test('validacao rejeita localhost, enderecos IP e caminhos absolutos', () => {
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
const addresses = [
'localhost',
'localhost:3000/agenda',
'127.0.0.1',
'192.168.0.1:8080/agenda',
'[::1]',
'[2001:db8::1]:8080/agenda',
'2001:db8::1',
'/agenda/demonstracao'
];
for (const address of addresses) {
const template = safe.replace('Grupo GTI.', `Grupo GTI. ${address}`);
assert.throws(() => renderCampaignMessage(template, {}), /mensagem inicial insegura/i, address);
}
});
test('renderizacao e selecao nao alteram os objetos de entrada', () => {
const template = { mensagem: approvedCases[0].template };
const lead = { empresa: 'Pizzaria Central', segmento: 'restaurantes' };
const variants = [{ id: 8, code: 'A', ativo: 0 }, { id: 37, code: 'B', ativo: 1 }];
const before = structuredClone({ template, lead, variants });
renderCampaignMessage(template, lead);
variantForLead(variants, 7);
assert.deepEqual({ template, lead, variants }, before);
});
test('validacao rejeita URL, CNPJ, preco e requisitos obrigatorios ausentes', () => {
const safe = `Ola, tudo bem? Sou Gustavo, do Grupo GTI. ${refusal}`;
const unsafe = [
safe.replace('Grupo GTI.', 'Grupo GTI. https://gti.example'),
safe.replace('Grupo GTI.', 'Grupo GTI. gti.com.br'),
safe.replace('Grupo GTI.', 'Grupo GTI. 12.345.678/0001-90'),
safe.replace('Grupo GTI.', 'Grupo GTI. 12345678000190'),
safe.replace('Grupo GTI.', 'Grupo GTI. R$ 99'),
safe.replace('Gustavo', 'Consultor'),
safe.replace('Grupo GTI', 'nossa empresa'),
safe.replace(` ${refusal}`, '')
];
for (const template of unsafe) {
assert.throws(() => renderCampaignMessage(template, { empresa: '' }), /mensagem inicial insegura/i);
}
});
+659
View File
@@ -0,0 +1,659 @@
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();
});
+546
View File
@@ -0,0 +1,546 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
addOptOut,
openDb,
saveWhatsAppVerification
} from '../src/db.mjs';
import { prepareDailyQueue } from '../src/outreach/campaign-queue.mjs';
import {
getCampaign,
listCampaignQueue,
listCampaigns,
seedCampaigns
} from '../src/outreach/campaign-store.mjs';
function setupCampaign(code = 'gdelivery-food') {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = listCampaigns(db).find((item) => item.code === code);
return { db, campaign };
}
function insertLead(db, {
key,
empresa = '',
whatsapp,
segmento = 'pizzarias',
prioridade = 1,
score = 0
}) {
return Number(db.prepare(
`INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
VALUES (?, ?, ?, ?, ?, ?, 1)`
).run(key, empresa || key, whatsapp, segmento, prioridade, score).lastInsertRowid);
}
function confirmed(db, numero, {
resolvedNumber = numero,
checkedAt = null,
status = 'confirmado'
} = {}) {
saveWhatsAppVerification(db, {
numero,
status,
resolvedNumber: status === 'confirmado' ? resolvedNumber : null,
variant: 'exata'
});
if (checkedAt) {
db.prepare('UPDATE whatsapp_number_verifications SET checked_at = ? WHERE numero = ?')
.run(checkedAt, numero);
}
}
function noSendWhatsapp(checkNumber = async (numero) => ({
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
})) {
return {
checkNumber,
sendText() {
throw new Error('prepareDailyQueue nao pode enviar mensagens');
}
};
}
function traceCandidateQuery(db) {
const trace = { sql: null, args: null };
const tracedDb = new Proxy(db, {
get(target, property) {
if (property === 'prepare') {
return (sql) => {
const statement = target.prepare(sql);
if (!sql.includes('campaign-candidates')) return statement;
trace.sql = sql;
return new Proxy(statement, {
get(statementTarget, statementProperty) {
if (statementProperty === 'all') {
return (...args) => {
trace.args = args;
return statementTarget.all(...args);
};
}
const value = statementTarget[statementProperty];
return typeof value === 'function' ? value.bind(statementTarget) : value;
}
});
};
}
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
}
});
return { tracedDb, trace };
}
test('prepara confirmados na ordem comercial e exclui inelegiveis em SQL', async () => {
const { db, campaign } = setupCampaign();
const highScore = insertLead(db, {
key: 'high-score', empresa: 'Pizzaria Sol', whatsapp: '5575991234001', prioridade: 1, score: 90
});
const lowScore = insertLead(db, {
key: 'low-score', whatsapp: '5575991234002', prioridade: 1, score: 40
});
const lowerPriority = insertLead(db, {
key: 'lower-priority', whatsapp: '5575991234003', prioridade: 2, score: 100
});
const priorProduct = insertLead(db, {
key: 'prior-product', whatsapp: '5575991234004', prioridade: 1, score: 99
});
const optedOut = insertLead(db, {
key: 'opted-out', whatsapp: '5575991234005', prioridade: 1, score: 98
});
const replied = insertLead(db, {
key: 'replied', whatsapp: '5575991234006', prioridade: 1, score: 97
});
const queued = insertLead(db, {
key: 'queued', whatsapp: '5575991234007', prioridade: 1, score: 96
});
const staleConfirmation = insertLead(db, {
key: 'stale-confirmation', whatsapp: '5575991234008', prioridade: 1, score: 95
});
const outsideSegment = insertLead(db, {
key: 'outside-segment', whatsapp: '5575991234009', segmento: 'restaurantes', prioridade: 1, score: 100
});
for (const number of [
'5575991234001', '5575991234002', '5575991234003', '5575991234004',
'5575991234005', '5575991234006', '5575991234007', '5575991234008',
'5575991234009'
]) confirmed(db, number);
confirmed(db, '557591234008', {
status: 'nao_cadastrado', checkedAt: '2026-07-29 13:00:00'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00' WHERE numero = '5575991234008'").run();
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, 'G-Delivery', '5575991234004', 'anterior', 'erro', 'manual')`
).run(priorProduct);
addOptOut(db, '557591234005', 'pedido do contato');
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
VALUES (?, 'respondeu', '2026-07-29 10:00:00')`
).run(replied);
const variantA = campaign.variants.find((variant) => variant.code === 'A');
db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
VALUES (?, ?, ?, 'G-Delivery', '5575991234007', 'pizzarias', 'existente', 'existente', 'preview')`
).run(campaign.id, queued, variantA.id);
let checks = 0;
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => {
checks += 1;
throw new Error('confirmados existentes nao devem ser consultados');
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id, 'preview');
assert.deepEqual(summary, { checked: 0, previewed: 3, skipped: 0 });
assert.equal(checks, 0);
assert.deepEqual(queue.map((item) => item.lead_id), [queued, highScore, lowScore, lowerPriority]);
assert.deepEqual(queue.slice(1).map((item) => item.variant_code), ['B', 'C', 'A']);
assert.match(queue[1].rendered_message, /^Olá, pessoal da Pizzaria Sol, tudo bem\?/);
assert.deepEqual(queue.slice(1).map((item) => item.selection_reason), [
'gdelivery-food:prioridade-1',
'gdelivery-food:prioridade-1',
'gdelivery-food:prioridade-2'
]);
assert.equal(queue.some((item) => [priorProduct, optedOut, replied, staleConfirmation, outsideSegment].includes(item.lead_id)), false);
assert.equal(new Set(queue.map((item) => item.lead_id)).size, queue.length);
db.close();
});
test('estado mais recente de numero equivalente confirma e persiste o numero resolvido', async () => {
const { db, campaign } = setupCampaign();
const leadId = insertLead(db, {
key: 'equivalent-confirmed', whatsapp: '5575998765432', score: 50
});
confirmed(db, '5575998765432', {
status: 'nao_cadastrado', checkedAt: '2026-07-29 10:00:00'
});
confirmed(db, '557598765432', {
resolvedNumber: '557598765432', checkedAt: '2026-07-29 11:00:00'
});
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => {
throw new Error('estado equivalente confirmado nao deve consumir verificacao');
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
assert.equal(queue[0].lead_id, leadId);
assert.equal(queue[0].numero, '557598765432');
db.close();
});
test('verifica sequencialmente no maximo o teto diario sem cobrar confirmados existentes', async () => {
const { db, campaign } = setupCampaign();
const confirmedLead = insertLead(db, {
key: 'already-confirmed', whatsapp: '5575992222000', score: 100
});
confirmed(db, '5575992222000');
for (let index = 1; index <= 20; index += 1) {
insertLead(db, {
key: `unverified-${index}`,
whatsapp: `55759922${String(index).padStart(4, '0')}`,
score: 100 - index
});
}
const calls = [];
let inFlight = 0;
let maxInFlight = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
calls.push(numero);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
return {
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
};
});
const summary = await prepareDailyQueue({
db,
whatsapp,
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 15, previewed: 1, skipped: 15 });
assert.equal(summary.checked, Math.ceil(getCampaign(db, campaign.id).approved_daily_limit * 1.5));
assert.equal(maxInFlight, 1);
assert.equal(queue.length, 1);
assert.equal(queue[0].lead_id, confirmedLead);
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 16);
assert.deepEqual(calls, Array.from({ length: 15 }, (_, index) => `55759922${String(index + 1).padStart(4, '0')}`));
db.close();
});
test('limite de verificacao ignora multiplicador adulterado acima de 1.5', async () => {
const { db, campaign } = setupCampaign();
db.exec('PRAGMA ignore_check_constraints = ON');
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_insert');
db.exec('DROP TRIGGER outreach_campaigns_verification_multiplier_update');
db.prepare('UPDATE outreach_campaigns SET verification_multiplier = 99 WHERE id = ?').run(campaign.id);
for (let index = 1; index <= 30; index += 1) {
insertLead(db, {
key: `malicious-cap-${index}`,
whatsapp: `55759888${String(index).padStart(4, '0')}`,
score: 100 - index
});
}
const calls = [];
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async (numero) => {
calls.push(numero);
return {
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
};
}),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.equal(summary.checked, 15);
assert.equal(calls.length, 15);
db.close();
});
test('persiste erro temporario e nao repete equivalente na mesma preparacao', async () => {
const { db, campaign } = setupCampaign();
insertLead(db, { key: 'temporary-a', whatsapp: '5575999091780', score: 100 });
insertLead(db, { key: 'temporary-b', whatsapp: '557599091780', score: 90 });
const calls = [];
const whatsapp = noSendWhatsapp(async (numero) => {
calls.push(numero);
throw Object.assign(new Error('consulta excedeu o prazo'), {
numero,
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
});
const summary = await prepareDailyQueue({
db,
whatsapp,
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.deepEqual(summary, { checked: 1, previewed: 0, skipped: 2 });
assert.deepEqual(calls, ['5575999091780']);
assert.deepEqual({ ...db.prepare(
'SELECT numero, status, erro FROM whatsapp_number_verifications'
).get() }, {
numero: '5575999091780',
status: 'erro_temporario',
erro: 'consulta excedeu o prazo'
});
db.close();
});
test('duas conexoes reais recontam a mesma campanha sem SQLITE_BUSY ou excesso', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-queue-race-'));
const dbPath = path.join(tempDir, 'race.sqlite');
let firstDb;
let secondDb;
try {
firstDb = openDb(dbPath);
seedCampaigns(firstDb);
secondDb = openDb(dbPath);
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
for (let index = 0; index < 12; index += 1) {
insertLead(firstDb, {
key: `real-race-${index}`,
whatsapp: `5575993300${String(index).padStart(2, '0')}`,
segmento: 'restaurantes',
score: 100 - index
});
}
let release;
const gate = new Promise((resolve) => { release = resolve; });
let arrivals = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
return {
numero,
status: 'confirmado',
registered: true,
resolvedNumber: numero,
variant: 'exata',
candidatesChecked: 2
};
});
const results = await Promise.allSettled([
prepareDailyQueue({ first: true, db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
]);
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
const queue = firstDb.prepare(
"SELECT produto, lead_id FROM outreach_campaign_queue WHERE status = 'preview' ORDER BY lead_id"
).all();
assert.ok(queue.length <= 10);
assert.equal(new Set(queue.map(({ lead_id }) => lead_id)).size, queue.length);
assert.equal(queue.every(({ produto }) => produto === 'GTI Vendas'), true);
} finally {
secondDb?.close();
firstDb?.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('campanhas sobrepostas do mesmo produto nao criam duas filas ativas', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-product-race-'));
const dbPath = path.join(tempDir, 'race.sqlite');
let firstDb;
let secondDb;
try {
firstDb = openDb(dbPath);
seedCampaigns(firstDb);
secondDb = openDb(dbPath);
const firstCampaign = listCampaigns(firstDb).find(({ code }) => code === 'gti-food');
const secondCampaign = listCampaigns(secondDb).find(({ code }) => code === 'gti-retail');
secondDb.prepare("UPDATE outreach_campaigns SET segments_json = '[\"restaurantes\"]' WHERE id = ?")
.run(secondCampaign.id);
insertLead(firstDb, {
key: 'same-product-race', whatsapp: '557599771001', segmento: 'restaurantes', score: 100
});
let release;
const gate = new Promise((resolve) => { release = resolve; });
let arrivals = 0;
const whatsapp = noSendWhatsapp(async (numero) => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
return {
numero,
status: 'confirmado',
registered: true,
resolvedNumber: numero,
variant: 'exata',
candidatesChecked: 2
};
});
const results = await Promise.allSettled([
prepareDailyQueue({ db: firstDb, whatsapp, campaignId: firstCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') }),
prepareDailyQueue({ db: secondDb, whatsapp, campaignId: secondCampaign.id, now: new Date('2026-07-29T14:00:00-03:00') })
]);
assert.equal(results.every(({ status }) => status === 'fulfilled'), true, results.map((item) => item.reason?.message));
assert.deepEqual(firstDb.prepare(
"SELECT produto, lead_id, COUNT(*) total FROM outreach_campaign_queue WHERE status = 'preview' GROUP BY produto, lead_id"
).all().map((row) => ({ ...row })), [{ produto: 'GTI Vendas', lead_id: 1, total: 1 }]);
} finally {
secondDb?.close();
firstDb?.close();
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
test('consulta 1.2m irrelevantes e 100k correspondentes com indice, limite e tempo pratico', async (t) => {
const { db, campaign } = setupCampaign();
const fixtureStartedAt = performance.now();
db.exec(`
WITH RECURSIVE sequence(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM sequence WHERE value < 1200000
)
INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
SELECT 'outside-' || value, 'Outside ' || value,
'5511' || printf('%08d', value), 'segmento_externo', 1, value % 100, 1
FROM sequence;
WITH RECURSIVE sequence(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM sequence WHERE value < 100000
)
INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, prioridade, score, telefone_valido)
SELECT 'matching-' || value, 'Matching ' || value,
'5575' || printf('%08d', value), 'pizzarias', 1 + (value % 4), value % 101, 1
FROM sequence;
`);
const fixtureElapsedMs = performance.now() - fixtureStartedAt;
const { tracedDb, trace } = traceCandidateQuery(db);
const startedAt = performance.now();
const summary = await prepareDailyQueue({
db: tracedDb,
whatsapp: noSendWhatsapp(),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const elapsedMs = performance.now() - startedAt;
assert.equal(summary.checked, 15);
assert.ok(trace.sql, 'consulta real de candidatos nao foi observada');
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
.map((row) => row.detail).join('\n');
assert.match(plan, /idx_leads_campaign_order/i);
assert.match(trace.sql, /LIMIT\s+\?/i);
assert.ok(trace.args.at(-1) <= 25);
assert.ok(elapsedMs < 8_000, `consulta limitada levou ${elapsedMs.toFixed(1)}ms`);
t.diagnostic(`fixture_1_3m_ms=${fixtureElapsedMs.toFixed(1)} candidate_query_ms=${elapsedMs.toFixed(1)}`);
db.close();
});
test('exclui envios bem-sucedidos, desconhecidos e respostas inbound explicitas', async () => {
const { db, campaign } = setupCampaign();
const delivered = insertLead(db, { key: 'sent-delivered', whatsapp: '557599550001', score: 100 });
const unknown = insertLead(db, { key: 'sent-unknown', whatsapp: '557599550002', score: 99 });
const inboundLead = insertLead(db, { key: 'inbound-lead', whatsapp: '557599550003', score: 98 });
const inboundEquivalent = insertLead(db, { key: 'inbound-equivalent', whatsapp: '5575999550004', score: 97 });
const eligible = insertLead(db, { key: 'eligibility-control', whatsapp: '557599550005', score: 96 });
for (const numero of ['557599550001', '557599550002', '557599550003', '5575999550004', '557599550005']) {
confirmed(db, numero);
}
db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
).run(delivered, '557599550001', 'entregue');
db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'G-Delivery', ?, 'anterior', ?)"
).run(unknown, '557599550002', 'desconhecido');
db.prepare(
"INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES ('inbound-by-lead', ?, '557599550003', '2026-07-29T12:00:00Z')"
).run(inboundLead);
db.prepare(
"INSERT INTO outreach_inbound_replies (provider_message_id, numero, received_at) VALUES ('inbound-by-number', '557599550004', '2026-07-29T12:01:00Z')"
).run();
const summary = await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
const queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(summary, { checked: 0, previewed: 1, skipped: 0 });
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [eligible]);
assert.equal(queue.some(({ lead_id }) => [delivered, unknown, inboundLead, inboundEquivalent].includes(lead_id)), false);
db.close();
});
test('usa somente a lista de segmentos configurada na campanha', async () => {
const { db, campaign } = setupCampaign();
db.prepare("UPDATE outreach_campaigns SET segments_json = '[\"pizzarias\"]' WHERE id = ?").run(campaign.id);
const configured = insertLead(db, { key: 'configured-segment', whatsapp: '557599660001', segmento: 'pizzarias' });
const catalogButRemoved = insertLead(db, { key: 'removed-segment', whatsapp: '557599660002', segmento: 'hamburguerias' });
confirmed(db, '557599660001');
confirmed(db, '557599660002');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.deepEqual(listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id), [configured]);
assert.notEqual(configured, catalogButRemoved);
db.close();
});
+476
View File
@@ -0,0 +1,476 @@
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();
});
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateDailySchedule } from '../src/outreach/campaign-schedule.mjs';
const WINDOWS = [
{ start: '09:30', end: '11:30' },
{ start: '14:00', end: '17:00' }
];
const MIN_MS = 12 * 60 * 1000;
const MAX_MS = 25 * 60 * 1000;
function schedule(overrides = {}) {
return generateDailySchedule({
date: '2026-07-27',
count: 18,
windows: WINDOWS,
minMs: MIN_MS,
maxMs: MAX_MS,
hourlyLimit: 4,
random: () => 0,
...overrides
});
}
test('gera somente em dia util e preserva a data civil local', () => {
assert.deepEqual(schedule({ date: '2026-07-25' }), []);
assert.deepEqual(schedule({ date: '2026-07-26' }), []);
const slots = schedule({ date: '2026-07-27', count: 2 });
assert.equal(slots.length, 2);
assert.deepEqual(slots.map((slot) => [
slot.getFullYear(), slot.getMonth() + 1, slot.getDate(), slot.getHours(), slot.getMinutes()
]), [
[2026, 7, 27, 9, 30],
[2026, 7, 27, 9, 42]
]);
});
test('usa random injetado para espacamento inclusivo entre 12 e 25 minutos', () => {
const minimum = schedule({ count: 3, random: () => 0 });
assert.deepEqual(minimum.map((slot) => `${slot.getHours()}:${String(slot.getMinutes()).padStart(2, '0')}`), [
'9:30', '9:42', '9:54'
]);
const maximum = schedule({ count: 3, random: () => 1 });
assert.deepEqual(maximum.map((slot) => `${slot.getHours()}:${String(slot.getMinutes()).padStart(2, '0')}`), [
'9:30', '9:55', '10:20'
]);
});
test('respeita no maximo quatro slots em qualquer hora rolante, incluindo o limite exato', () => {
const slots = schedule({ count: 8, minMs: 1, maxMs: 1 });
assert.deepEqual(slots.slice(0, 5).map((slot) => slot.getTime() - slots[0].getTime()), [
0, 1, 2, 3, 60 * 60 * 1000
]);
for (let index = 4; index < slots.length; index += 1) {
assert.ok(slots[index].getTime() - slots[index - 4].getTime() >= 60 * 60 * 1000);
}
});
test('nao agenda durante almoco, fora das janelas ou em outra data', () => {
const slots = schedule({ count: 18 });
assert.ok(slots.length > 0);
for (const slot of slots) {
const minutes = slot.getHours() * 60 + slot.getMinutes();
assert.equal(slot.getDate(), 27);
assert.ok((minutes >= 570 && minutes <= 690) || (minutes >= 840 && minutes <= 1020));
assert.ok(minutes <= 690 || minutes >= 840);
}
});
test('limita a 18 e retorna menos quando as janelas nao comportam a quantidade', () => {
assert.equal(schedule({ count: 99 }).length, 18);
const short = schedule({
count: 18,
windows: [{ start: '09:30', end: '09:50' }],
minMs: 12 * 60 * 1000,
maxMs: 12 * 60 * 1000
});
assert.deepEqual(short.map((slot) => [slot.getHours(), slot.getMinutes()]), [[9, 30], [9, 42]]);
});
test('rejeita parametros inseguros sem produzir datas invalidas', () => {
assert.throws(() => schedule({ date: '2026-02-30' }), /data/i);
assert.throws(() => schedule({ windows: [{ start: '11:30', end: '09:30' }] }), /janela/i);
assert.throws(() => schedule({ random: () => 1.1 }), /random/i);
});
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import {
CAMPAIGN_DEFINITIONS,
CAMPAIGN_PRODUCTS,
CAMPAIGN_STAGES
} from '../src/outreach/campaign-catalog.mjs';
import {
getCampaign,
getLeadCommercialState,
listCampaigns,
seedCampaigns,
setLeadCommercialStage
} from '../src/outreach/campaign-store.mjs';
function insertLead(db) {
db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('campaign-test-lead', 'Empresa Teste')").run();
return Number(db.prepare('SELECT id FROM leads').get().id);
}
test('catalogo fixa produtos, etapas e os tres grupos de segmentos aprovados', () => {
assert.deepEqual(CAMPAIGN_PRODUCTS, ['GTI Vendas', 'G-Delivery']);
assert.deepEqual(CAMPAIGN_STAGES, [
'fila_preparada',
'aprovado_para_envio',
'enviado',
'respondeu',
'interessado_gti_vendas',
'interessado_g_delivery',
'demonstracao_agendada',
'teste_g_delivery_iniciado',
'sem_interesse',
'opt_out',
'venda_fechada'
]);
assert.deepEqual(CAMPAIGN_DEFINITIONS.map(({ code, produto, segments }) => ({ code, produto, segments })), [
{
code: 'gdelivery-food',
produto: 'G-Delivery',
segments: ['hamburguerias', 'pizzarias', 'acaiterias', 'marmitarias', 'lanchonetes']
},
{
code: 'gti-food',
produto: 'GTI Vendas',
segments: ['restaurantes', 'mercados_de_bairro', 'mercearias', 'minimercados', 'padarias', 'conveniencias']
},
{
code: 'gti-retail',
produto: 'GTI Vendas',
segments: ['lojas_de_roupa', 'material_de_construcao', 'autopecas', 'oficinas_mecanicas']
}
]);
});
test('seed cria tres campanhas e nove variantes usando seis textos unicos', () => {
const db = openDb(':memory:');
seedCampaigns(db);
seedCampaigns(db);
const campaigns = listCampaigns(db);
assert.deepEqual(campaigns.map(({ code, produto }) => ({ code, produto })), [
{ code: 'gdelivery-food', produto: 'G-Delivery' },
{ code: 'gti-food', produto: 'GTI Vendas' },
{ code: 'gti-retail', produto: 'GTI Vendas' }
]);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaign_variants').get().total, 9);
assert.equal(db.prepare('SELECT COUNT(DISTINCT mensagem) total FROM outreach_campaign_variants').get().total, 6);
assert.deepEqual(campaigns.map(({ variants }) => variants.map(({ code }) => code)), [
['A', 'B', 'C'],
['A', 'B', 'C'],
['A', 'B', 'C']
]);
const gDeliveryMessages = {
A: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Queria entender como voces recebem os pedidos de delivery hoje: possuem um canal proprio ou dependem de aplicativos que cobram comissao? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.',
B: 'Ola! Aqui e Gustavo, do Grupo GTI. Voces ja possuem um link proprio para receber pedidos, sem pagar comissao por venda a aplicativos? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.',
C: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Trabalho com uma solucao de pedidos online por canal proprio e queria primeiro entender como funciona o delivery de voces hoje. Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
};
const gtiVendasMessages = {
A: 'Ola, tudo bem? Sou Gustavo, representante comercial do Grupo GTI. Hoje voces controlam {foco_gestao} em um unico sistema? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.',
B: 'Ola! Aqui e Gustavo, do Grupo GTI. Trabalhamos com gestao para empresas de Santo Antonio de Jesus e regiao. Como voces organizam hoje {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.',
C: 'Ola, tudo bem? Sou Gustavo, do Grupo GTI. Ha mais de 7 anos atendemos empresas da regiao e ja temos mais de 100 clientes. Voces utilizam algum sistema para {foco_gestao}? Se nao fizer sentido para sua empresa, e so me avisar que nao volto a entrar em contato.'
};
assert.deepEqual(Object.fromEntries(campaigns[0].variants.map(({ code, mensagem }) => [code, mensagem])), gDeliveryMessages);
assert.deepEqual(Object.fromEntries(campaigns[1].variants.map(({ code, mensagem }) => [code, mensagem])), gtiVendasMessages);
assert.deepEqual(Object.fromEntries(campaigns[2].variants.map(({ code, mensagem }) => [code, mensagem])), gtiVendasMessages);
db.close();
});
test('seed completa ausencias sem sobrescrever controles editados pelo operador', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = listCampaigns(db)[0];
db.prepare("UPDATE outreach_campaigns SET status = 'running', week_number = 3, approved_daily_limit = 15 WHERE id = ?")
.run(campaign.id);
db.prepare("DELETE FROM outreach_campaign_variants WHERE campaign_id = ? AND code = 'C'").run(campaign.id);
seedCampaigns(db);
const reloaded = getCampaign(db, campaign.id);
assert.equal(reloaded.status, 'running');
assert.equal(reloaded.week_number, 3);
assert.equal(reloaded.approved_daily_limit, 15);
assert.deepEqual(reloaded.variants.map(({ code }) => code), ['A', 'B', 'C']);
assert.equal(getCampaign(db, 999999), null);
db.close();
});
test('seed recria campanha excluida e preserva controles das campanhas sobreviventes', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const removed = listCampaigns(db).find(({ code }) => code === 'gdelivery-food');
const survivor = listCampaigns(db).find(({ code }) => code === 'gti-food');
db.prepare("UPDATE outreach_campaigns SET status = 'running', week_number = 4, approved_daily_limit = 18 WHERE id = ?")
.run(survivor.id);
db.prepare('DELETE FROM outreach_campaigns WHERE id = ?').run(removed.id);
assert.equal(getCampaign(db, removed.id), null);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaign_variants WHERE campaign_id = ?').get(removed.id).total, 0);
seedCampaigns(db);
const restored = listCampaigns(db).find(({ code }) => code === 'gdelivery-food');
const unchanged = getCampaign(db, survivor.id);
assert.notEqual(restored.id, removed.id);
assert.deepEqual(restored.variants.map(({ code }) => code), ['A', 'B', 'C']);
assert.equal(unchanged.status, 'running');
assert.equal(unchanged.week_number, 4);
assert.equal(unchanged.approved_daily_limit, 18);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaigns').get().total, 3);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaign_variants').get().total, 9);
db.close();
});
test('mudanca de etapa atualiza estado comercial e registra historico', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db);
const campaignId = listCampaigns(db)[0].id;
assert.equal(getLeadCommercialState(db, leadId), null);
const state = setLeadCommercialStage(db, {
leadId,
campaignId,
stage: 'respondeu',
note: 'Resposta recebida no WhatsApp'
});
assert.equal(state.lead_id, leadId);
assert.equal(state.stage, 'respondeu');
assert.deepEqual(db.prepare(
'SELECT lead_id, campaign_id, stage, note FROM outreach_stage_events'
).all().map((row) => ({ ...row })), [{
lead_id: leadId,
campaign_id: campaignId,
stage: 'respondeu',
note: 'Resposta recebida no WhatsApp'
}]);
assert.throws(() => setLeadCommercialStage(db, { leadId, stage: 'etapa_invalida' }), /etapa comercial invalida/i);
db.close();
});
+687
View File
@@ -0,0 +1,687 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
function fakeElement(id = '') {
const listeners = {};
const attributes = new Map();
const classes = new Set();
const node = {
id, nodeType: 1, tagName: '', listeners, attributes,
children: [], className: '', textContent: '', value: '', checked: false,
disabled: false, open: false, options: [], selectedOptions: [], dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, force) => {
const active = force === undefined ? !classes.has(name) : force;
if (active) classes.add(name); else classes.delete(name);
return active;
}
},
addEventListener: (type, handler) => { listeners[type] = handler; },
append(...children) { this.children.push(...children.flat()); },
replaceChildren(...children) { this.children = children.flat(); },
querySelectorAll: () => [],
querySelector: (sel) => {
for (const child of node.children) {
if (sel.startsWith('.') && child.className && child.className.split(' ').includes(sel.slice(1))) return child;
if (sel.startsWith('#') && child.id === sel.slice(1)) return child;
if (child.tagName && child.tagName.toLowerCase() === sel.toLowerCase()) return child;
}
return null;
},
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) || null,
setAttribute: (name, value) => attributes.set(name, value),
showModal() { this.open = true; },
close() { this.open = false; listeners.close?.(); },
click() { return listeners.click?.({ preventDefault() {}, stopPropagation() {}, target: this }); }
};
return node;
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function nodeText(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return `${node?.textContent || ''}${(node?.children || []).map(nodeText).join('')}`;
}
async function executeApp({ get, post, patch, del, put } = {}) {
const elements = new Map();
const timers = [];
const gets = [];
const posts = [];
const element = (id) => {
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
};
const apiGet = async (url) => {
gets.push(url);
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
if (get) return get(url);
throw new Error(`GET inesperado: ${url}`);
};
const context = {
API: {
get: apiGet,
post: async (url, body) => {
posts.push({ url, body });
if (post) return post(url, body);
if (url === '/api/outreach/enviar-manual') return { ok: true, status: 'aceito' };
return {};
},
patch: async (url, body) => {
posts.push({ url, body, method: 'PATCH' });
if (patch) return patch(url, body);
return {};
},
put: async (url, body) => {
posts.push({ url, body, method: 'PUT' });
if (put) return put(url, body);
return {};
},
delete: async (url, body) => {
posts.push({ url, body, method: 'DELETE' });
if (del) return del(url, body);
return {};
}
},
Captura: { init: async () => {} },
Tratamento: { bindEvents() {}, ativar() {} },
Campanhas: { bindEvents() {}, ativar() {} },
// Mirrors public/js/api.js's comCarregando — app.js expects it as a
// real global (plain <script> include order), but this sandbox only
// loads app.js, so it must be provided here too.
comCarregando: async (button, fn) => {
if (!button) return fn();
const jaCarregando = button.disabled;
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
try {
return await fn();
} finally {
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
}
},
clearTimeout() {},
dataBr: (value) => value || '—',
document: {
querySelectorAll: () => [],
querySelector: () => null
},
el: element,
fmt: new Intl.NumberFormat('pt-BR'),
h: (tag, attrs = {}, ...children) => {
const node = fakeElement();
node.tagName = tag.toUpperCase();
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
node.append(...children);
const id = node.getAttribute('id') || node.id;
if (id && !elements.has(id)) elements.set(id, node);
return node;
},
setTimeout: (callback, delay) => {
timers.push({ callback, delay });
return timers.length;
},
toast() {},
window: { Campanhas: { bindEvents() {}, ativar() {} }, confirm: () => true, location: {} },
URLSearchParams
};
vm.createContext(context);
vm.runInContext(await read('public/js/app.js'), context);
await new Promise((resolve) => setImmediate(resolve));
return { context, element, elements, gets, posts, timers };
}
async function renderCampaign(app, campaigns, options = {}) {
const { queue = [], replies = [], metrics = null, previewResult = null } = options;
const origGet = app.context.API.get;
app.context.API.get = async (url) => {
app.gets.push(url);
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
if (url === '/api/campaigns') return { campaigns };
if (/^\/api\/campaigns\/\d+$/.test(url)) {
const id = Number(url.match(/\d+/)[0]);
return campaigns.find((c) => c.id === id);
}
if (url.includes('/queue')) return { queue };
if (url === '/api/campaigns/replies/pending') return { pending: replies };
if (url.includes('/metrics')) return { metrics: metrics || {} };
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
if (origGet) return origGet(url);
throw new Error(`GET inesperado (campaign): ${url}`);
};
vm.runInContext(await read('public/js/campanhas.js'), app.context);
await app.context.window.Campanhas.ativar();
}
test('campaign cards show status, week, daily limit and pause reason', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery Alimentação', produto: 'G-Delivery', status: 'paused', pause_reason: 'server_restart', week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], usage: { manual: { used: 2, limit: 10 }, campaign: { used: 1 } } },
{ id: 2, code: 'gti-food', name: 'GTI Vendas Alimentação', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 2, approved_daily_limit: 12, segments: ['restaurantes', 'mercados'], usage: { manual: { used: 0, limit: 10 }, campaign: { used: 0 } } }
];
await renderCampaign(app, campaigns);
const cards = app.element('campaign-cards');
assert.ok(cards.children.length >= 2);
const t1 = nodeText(cards.children[0]);
const t2 = nodeText(cards.children[1]);
assert.match(t1, /G-Delivery/);
assert.match(t1, /Semana 1/);
assert.match(t1, /10\/dia/);
assert.match(t1, /server_restart/);
assert.match(t1, /Pausada/);
assert.match(t1, /Manual: 2/);
assert.match(t1, /Automático: 1/);
assert.match(t2, /GTI Vendas/);
assert.match(t2, /Semana 2/);
assert.match(t2, /12\/dia/);
assert.doesNotMatch(t2, /server_restart/);
assert.match(t2, /Pronta/);
});
test('campaign cards have prepare action button', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'paused', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
await renderCampaign(app, campaigns);
const card = app.element('campaign-cards').children[0];
assert.match(nodeText(card), /[Pp]reparar/);
const btn = app.element('btn-preview-1');
assert.ok(btn);
await btn.click();
assert.ok(app.posts.some((p) => /\/api\/campaigns\/1\/prepare/.test(p.url)));
});
test('"Avançar semana" calls PUT /api/campaigns/:id/week with week+1, and is hidden once week 4 is reached', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 2, approved_daily_limit: 12, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 },
{ id: 2, code: 'gti-food', name: 'GTI Vendas', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 4, approved_daily_limit: 18, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
let putWeekBody = null;
app.context.API.put = async (url, body) => {
app.posts.push({ url, body, method: 'PUT' });
if (/\/api\/campaigns\/1\/week/.test(url)) putWeekBody = body;
return { campaign: { id: 1, week_number: body.week } };
};
await renderCampaign(app, campaigns);
const advanceBtn1 = app.element('btn-advance-week-1');
assert.ok(advanceBtn1, 'expected an advance-week button for a week-2 campaign');
await advanceBtn1.click();
assert.ok(putWeekBody);
assert.equal(putWeekBody.week, 3);
// Week 4 is the max (WEEK_LIMITS only has entries for 1-4) — no button to go past it.
assert.equal(app.element('btn-advance-week-2').tagName, '');
});
test('preview rows show exact server-rendered text not editable after approval', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá, pessoal da Pizzaria Central, tudo bem? Me chamo Gustavo, sou representante do Grupo GTI.', segmento: 'restaurantes', selection_reason: 'gdelivery-food:prioridade-1', status: 'preview' },
{ id: 102, lead_id: 2, empresa: 'Mercado do João', rendered_message: 'Olá! Me chamo Gustavo, sou representante do Grupo GTI e escrevo para apresentar meu serviço.', segmento: 'mercados', selection_reason: 'gdelivery-food:prioridade-2', status: 'preview' }
];
app.context.API.post = async (url) => {
app.posts.push({ url });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
return {};
};
await renderCampaign(app, campaigns, { queue });
// Trigger queue loading by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const qt = nodeText(app.element('campaign-queue'));
assert.match(qt, /Pizzaria Central/);
assert.match(qt, /Olá, pessoal da Pizzaria Central/);
assert.match(qt, /Mercado do João/);
assert.match(qt, /Olá! Me chamo Gustavo/);
const approveBtn = app.element('btn-approve');
assert.ok(approveBtn);
assert.match(nodeText(approveBtn), /Aprovar/);
});
test('"Ver fila" shows queue items past preview (scheduled/sent/cancelled), not just preview — a running campaign has no preview items left', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá!', segmento: 'restaurantes', selection_reason: 'p-1', status: 'sent' },
{ id: 102, lead_id: 2, empresa: 'Mercado do João', rendered_message: 'Oi!', segmento: 'mercados', selection_reason: 'p-2', status: 'scheduled' },
{ id: 103, lead_id: 3, empresa: 'Padaria Central', rendered_message: 'Olá!', segmento: 'padarias', selection_reason: 'p-3', status: 'cancelled' }
];
await renderCampaign(app, campaigns, { queue });
let queueUrl = null;
const getAfterRender = app.context.API.get;
app.context.API.get = async (url) => {
if (url.includes('/queue')) queueUrl = url;
return getAfterRender(url);
};
const verFilaBtn = app.element('btn-queue-1');
assert.ok(verFilaBtn);
await verFilaBtn.click();
// No status query string: the backend returns every status when it's omitted.
assert.ok(queueUrl && !queueUrl.includes('status='), `expected no status filter, got ${queueUrl}`);
const t = nodeText(app.element('campaign-queue'));
assert.match(t, /Pizzaria Central/);
assert.match(t, /Mercado do João/);
assert.match(t, /Padaria Central/);
assert.match(t, /Enviado/);
assert.match(t, /Agendado/);
assert.match(t, /Cancelado/);
// Cancelled rows aren't removable on the backend — no "Remover" button for them.
assert.equal(app.element('btn-remove-queue-103').tagName, '');
assert.ok(app.element('btn-remove-queue-102'));
// No preview items in this queue, so there is nothing to approve.
assert.equal(app.element('btn-approve').tagName, '');
});
test('"Ver fila" button is available for paused campaigns too', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'paused', pause_reason: 'motivo', week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
await renderCampaign(app, campaigns);
assert.ok(app.element('btn-queue-1'));
});
test('per-row removal calls DELETE /api/campaigns/:id/queue/:queueId', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá!', segmento: 'restaurantes', selection_reason: 'prioridade-1', status: 'preview' }
];
let deletedId = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
return {};
};
app.context.API.delete = async (url) => {
app.posts.push({ url, method: 'DELETE' });
const match = url.match(/\/api\/campaigns\/(\d+)\/queue\/(\d+)/);
if (match) deletedId = Number(match[2]);
return { ok: true };
};
await renderCampaign(app, campaigns, { queue });
// Load queue by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const removeBtn = app.element('btn-remove-queue-101');
assert.ok(removeBtn);
await removeBtn.click();
assert.equal(deletedId, 101);
assert.ok(app.posts.some((p) => p.method === 'DELETE' && /\/api\/campaigns\/1\/queue\/101$/.test(p.url)));
});
test('explicit approval flow calls POST /api/campaigns/:id/approve with queueIds and date (backend requires date; see generateDailySchedule)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Teste', rendered_message: 'Mensagem de teste', segmento: 'restaurantes', selection_reason: 'prioridade-1', status: 'preview' }
];
let approved = false;
let approveBody = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
if (/\/api\/campaigns\/\d+\/approve/.test(url)) {
approved = true;
approveBody = body;
return { ok: true, scheduled: queue.length };
}
return {};
};
await renderCampaign(app, campaigns, { queue });
// Load queue by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const approveBtn = app.element('btn-approve');
assert.ok(approveBtn);
await approveBtn.click();
assert.ok(approved);
assert.ok(app.posts.some((p) => p.url.includes('/approve')));
assert.ok(approveBody);
assert.equal(approveBody.queueIds.length, 1);
assert.equal(approveBody.queueIds[0], 101);
// generateDailySchedule() on the backend rejects a missing/invalid date with
// 400 "data local invalida; use YYYY-MM-DD" — date is NOT optional there.
assert.match(approveBody.date, /^\d{4}-\d{2}-\d{2}$/);
});
test('start control calls POST /api/campaigns/:id/start (API has no patch method)', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
// Real api.js only defines get/post/put/delete — no patch. Remove the test
// double's patch stub so this test fails the way the real browser does if
// campanhas.js ever calls API.patch again.
delete app.context.API.patch;
let postedStart = false;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (url.includes('/start')) postedStart = true;
return { ok: true };
};
await renderCampaign(app, campaigns);
// The confirm listener is attached once in bindEvents(), not per "Iniciar"
// click — otherwise it would accumulate a new handler (closing over a stale
// campaignId) on every open, firing multiple /start calls on one confirm.
app.context.window.Campanhas.bindEvents();
const startBtn = app.element('btn-start-1');
assert.ok(startBtn);
await startBtn.click();
const modal = app.element('modal-campaign-start');
assert.ok(modal.open);
const confirmBtn = app.element('btn-confirm-start');
assert.ok(confirmBtn);
await confirmBtn.click();
assert.ok(postedStart);
});
test('confirming "Iniciar" does not fire /start for a previously-opened, cancelled campaign (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 },
{ id: 3, code: 'gti-varejo', name: 'GTI Varejo', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['lojas'], manual_usage: 0, auto_usage: 0 }
];
delete app.context.API.patch;
const startedCampaignIds = [];
app.context.API.post = async (url) => {
app.posts.push({ url });
const match = url.match(/\/api\/campaigns\/(\d+)\/start/);
if (match) startedCampaignIds.push(Number(match[1]));
return { ok: true };
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
// Open "Iniciar" for campaign 1, then cancel (never confirm) — this used to
// leave a dangling listener closed over campaignId=1.
await app.element('btn-start-1').click();
app.element('modal-campaign-start').close();
// Now open and confirm "Iniciar" for campaign 3 only.
await app.element('btn-start-3').click();
await app.element('btn-confirm-start').click();
assert.deepEqual(startedCampaignIds, [3], `expected only campaign 3 to start, got ${JSON.stringify(startedCampaignIds)}`);
});
test('separate manual and automatic usage display', async () => {
const app = await executeApp();
const campaigns = [{
id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery',
status: 'paused', pause_reason: null, week_number: 1, approved_daily_limit: 15,
segments: ['restaurantes'], usage: { manual: { used: 4, limit: 10 }, campaign: { used: 7 } }
}];
await renderCampaign(app, campaigns);
const t = nodeText(app.element('campaign-cards').children[0]);
assert.match(t, /Manual: 4/);
assert.match(t, /Automático: 7/);
assert.doesNotMatch(t, /11\/15/);
});
test('pending replies section shows reply items', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const replies = [
{ lead_id: 5, stage: 'respondeu', empresa: 'Padaria do Centro', whatsapp: '5511999999999', campaign_id: 1, updated_at: '2026-07-29T10:30:00Z', note: 'Olá, gostaria de mais informações' },
{ lead_id: 8, stage: 'respondeu', empresa: 'Açougue do João', whatsapp: '5511888888888', campaign_id: 1, updated_at: '2026-07-29T11:00:00Z', note: 'Não tenho interesse no momento' }
];
await renderCampaign(app, campaigns, { replies });
const t = nodeText(app.element('pending-replies'));
assert.match(t, /Padaria do Centro/);
assert.match(t, /Açougue do João/);
assert.match(t, /mais informações/);
assert.match(t, /não tenho interesse/i);
});
test('stage classification card shows stage area', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
await renderCampaign(app, campaigns);
const t = nodeText(app.element('stage-classification'));
assert.match(t, /classificar/i);
});
test('appointment form triggers POST on schedule', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
let appointmentPosted = false;
let appointmentBody = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/leads\/\d+\/appointments/.test(url)) {
appointmentPosted = true;
appointmentBody = body;
return { ok: true, id: 1 };
}
return {};
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
const scheduleBtn = app.element('btn-schedule-appointment');
assert.ok(scheduleBtn);
await scheduleBtn.click();
const leadInput = app.element('inp-appointment-lead-id');
if (leadInput) leadInput.value = '5';
const confirmBtn = app.element('btn-confirm-appointment');
assert.ok(confirmBtn);
await confirmBtn.click();
assert.ok(appointmentBody);
assert.ok('participantName' in appointmentBody);
assert.ok('companyName' in appointmentBody);
assert.ok(appointmentPosted);
});
test('opening the appointment modal twice and confirming once posts exactly once (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
let postCount = 0;
app.context.API.post = async (url) => {
if (/\/api\/campaigns\/leads\/\d+\/appointments/.test(url)) postCount += 1;
return { ok: true, id: 1 };
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
await app.element('btn-schedule-appointment').click();
app.element('modal-campaign-appointment').close(); // cancel without confirming
await app.element('btn-schedule-appointment').click(); // reopen
app.element('inp-appointment-lead-id').value = '5';
await app.element('btn-confirm-appointment').click();
assert.equal(postCount, 1, `expected exactly one POST, got ${postCount}`);
});
test('classifying a stage for lead B after opening (without confirming) lead A does not update lead A (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const replies = [
{ lead_id: 5, stage: 'respondeu', empresa: 'Padaria do Centro', whatsapp: '5511999999999', campaign_id: 1, updated_at: '2026-07-29T10:30:00Z', note: 'oi' },
{ lead_id: 8, stage: 'respondeu', empresa: 'Açougue do João', whatsapp: '5511888888888', campaign_id: 1, updated_at: '2026-07-29T11:00:00Z', note: 'oi' }
];
const updatedLeadIds = [];
app.context.API.put = async (url, body) => {
app.posts.push({ url, body, method: 'PUT' });
const match = url.match(/\/api\/campaigns\/leads\/(\d+)\/stage/);
if (match) updatedLeadIds.push(Number(match[1]));
return { state: {} };
};
await renderCampaign(app, campaigns, { replies });
app.context.window.Campanhas.bindEvents();
const items = app.element('pending-replies').children;
const classificarBtns = items.map((item) => item.children.find((c) => /Classificar/.test(nodeText(c))));
// Open lead 5's dialog, cancel without saving.
await classificarBtns[0].click();
app.element('modal-campaign-stage').close();
// Open and save lead 8's dialog only.
await classificarBtns[1].click();
await app.element('btn-save-stage').click();
assert.deepEqual(updatedLeadIds, [8], `expected only lead 8 to be updated, got ${JSON.stringify(updatedLeadIds)}`);
});
test('metrics section contains segmento and variant filters', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const metrics = {
previewed: 10, approved: 8, provider: { aceito: 5, erro: 0, desconhecido: 0 }, replies: 1,
groups: { byProduct: {}, bySegment: {}, byVariant: {} }, firstResponseTimeAvg: 120
};
await renderCampaign(app, campaigns, { metrics });
const t = nodeText(app.element('metrics-filters'));
assert.match(t, /Segmento/);
assert.match(t, /Variante/);
});
test('metrics stats render using the real /metrics response shape ({ metrics: { previewed, approved, sent, replies, firstResponseTimeAvg } })', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const realMetrics = {
previewed: 10, approved: 8, removed: 0, sent: 5, attempted: 6,
// provider.aceito deliberately differs from `sent`: it only tracks the
// transient "accepted by WhatsApp" status, not deliveries that progressed
// to entregue/lido — the UI must use the queue-based `sent` count, not this.
provider: { aceito: 2, erro: 0, desconhecido: 1 },
replies: 3, firstResponseTimeAvg: 120,
stages: { positive: 0, negative: 0, optOut: 0 },
appointments: { demos: 0, tests: 0 }, sales: 0,
groups: { byProduct: {}, bySegment: {}, byVariant: {} }
};
await renderCampaign(app, campaigns, { metrics: realMetrics });
// "Ver fila" sets selectedCampaignId, a prerequisite for carregarMetricsData().
const verFilaBtns = Array.from(app.element('campaign-cards').children[0].children)
.flatMap((row) => row.children || [])
.filter((node) => /Ver fila/.test(nodeText(node)));
assert.ok(verFilaBtns.length, 'expected a "Ver fila" button to exist for a running campaign');
await verFilaBtns[0].click();
const segmentoSelect = app.element('filtro-metrics-segmento');
assert.ok(segmentoSelect);
await segmentoSelect.listeners.change();
const t = nodeText(app.element('campaign-metrics'));
assert.match(t, /10/); // previsualizados
assert.match(t, /8/); // aprovados
assert.match(t, /5/); // enviados = metrics.sent (queue status), NOT attempted or provider.aceito
assert.match(t, /3/); // responderam
assert.match(t, /120 min/);
});
test('post-server-restart pause reason is displayed prominently', async () => {
const app = await executeApp();
const campaigns = [{
id: 1, code: 'gdelivery-food', name: 'G-Delivery Alimentação', produto: 'G-Delivery',
status: 'paused', pause_reason: 'server_restart', week_number: 1, approved_daily_limit: 10,
segments: ['restaurantes'], manual_usage: 0, auto_usage: 0
}];
await renderCampaign(app, campaigns);
const card = app.element('campaign-cards').children[0];
const t = nodeText(card);
assert.match(t, /Pausada/);
assert.match(t, /server_restart/);
assert.match(t, /[Pp]ausado: server_restart/);
// Verify pause reason is visible (not hidden)
const pauseEls = [];
for (const child of card.children) {
const childText = nodeText(child);
if (childText.includes('server_restart')) {
pauseEls.push(child);
}
}
assert.ok(pauseEls.length > 0);
for (const el of pauseEls) {
assert.doesNotMatch(el.className || '', /hidden/i);
}
});
+623
View File
@@ -0,0 +1,623 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Worker } from 'node:worker_threads';
import {
addOptOut,
openDb,
recoverPendingOutreach,
reserveOutreachSend,
saveWhatsAppVerification,
setSetting
} from '../src/db.mjs';
import { seedCampaigns } from '../src/outreach/campaign-store.mjs';
async function workerModule() {
return import('../src/outreach/campaign-worker.mjs');
}
function setup() {
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);
return { db, campaign };
}
function addQueue(db, campaign, {
suffix,
status = 'scheduled',
scheduledAt = '2026-07-29T12:00:00.000Z',
confirmed = true
}) {
const numero = `55119999${String(suffix).padStart(5, '0')}`;
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES (?, ?, ?, 'restaurantes', 1)`
).run(`worker-${suffix}`, `Empresa ${suffix}`, numero).lastInsertRowid);
if (confirmed) saveWhatsAppVerification(db, { numero, status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(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', ?, 'teste', ?, ?)`
).run(
campaign.id,
leadId,
variantId,
campaign.produto,
numero,
`Mensagem ${suffix}`,
status,
scheduledAt
).lastInsertRowid);
return { queueId, leadId, numero };
}
function whatsappFake({ state = 'READY', beforeProvider = async () => {}, outcome = 'success' } = {}) {
let providerCalls = 0;
return {
get providerCalls() { return providerCalls; },
getStatus: () => ({ state }),
sendText: async (args) => {
await beforeProvider(args);
const context = await args.beforeSend();
providerCalls += 1;
if (outcome === 'success') {
const result = { messageId: `message-${providerCalls}` };
await args.afterSend(result, null, context);
return result;
}
const error = Object.assign(new Error(outcome), {
deliveryUnknown: outcome === 'unknown'
});
await args.afterSend(null, error, context);
throw error;
}
};
}
test('worker processa somente o primeiro item scheduled vencido', async () => {
const { runDueCampaignQueue } = await workerModule();
assert.equal(typeof runDueCampaignQueue, 'function');
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 1 });
const second = addQueue(db, campaign, { suffix: 2 });
const future = addQueue(db, campaign, { suffix: 3, scheduledAt: '2026-07-29T12:01:00.000Z' });
const approved = addQueue(db, campaign, { suffix: 4, status: 'approved', scheduledAt: null });
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 1, erros: 0, pulados: 0 });
assert.equal(whatsapp.providerCalls, 1);
const statuses = new Map(db.prepare(
'SELECT id, status FROM outreach_campaign_queue ORDER BY id'
).all().map((row) => [row.id, row.status]));
assert.equal(statuses.get(first.queueId), 'sent');
assert.equal(statuses.get(second.queueId), 'scheduled');
assert.equal(statuses.get(future.queueId), 'scheduled');
assert.equal(statuses.get(approved.queueId), 'approved');
db.close();
});
test('worker cancela item permanentemente inelegivel e continua no proximo vencido', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const poisoned = addQueue(db, campaign, { suffix: 5, confirmed: false });
const eligible = addQueue(db, campaign, { suffix: 6 });
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 2, enviados: 1, erros: 0, pulados: 1 });
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(poisoned.queueId) },
{ status: 'cancelled', cancel_reason: 'whatsapp_not_confirmed' }
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(eligible.queueId).status, 'sent');
assert.equal(whatsapp.providerCalls, 1);
db.close();
});
test('worker mantem itens agendados e para no primeiro bloqueio transitorio de limite', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 7 });
const second = addQueue(db, campaign, { suffix: 8 });
db.prepare('UPDATE outreach_campaigns SET hourly_limit = 0 WHERE id = ?').run(campaign.id);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 1, pulados: 0 });
assert.deepEqual(
db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(first.queueId, second.queueId).map((row) => row.status),
['scheduled', 'scheduled']
);
assert.equal(whatsapp.providerCalls, 0);
db.close();
});
test('worker mantem dois itens agendados e para quando conexao cai antes da reserva', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const first = addQueue(db, campaign, { suffix: 12 });
const second = addQueue(db, campaign, { suffix: 13 });
let state = 'READY';
let providerCalls = 0;
const whatsapp = {
getStatus: () => ({ state }),
sendText: async (args) => {
state = 'DISCONNECTED';
await args.beforeSend();
providerCalls += 1;
}
};
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 1, pulados: 0 });
assert.deepEqual(
db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(first.queueId, second.queueId).map((row) => row.status),
['scheduled', 'scheduled']
);
assert.equal(providerCalls, 0);
db.close();
});
test('worker cancela numero rejeitado no lookup antes de beforeSend e continua', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const rejected = addQueue(db, campaign, { suffix: 9 });
const eligible = addQueue(db, campaign, { suffix: 11 });
let calls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
calls += 1;
if (calls === 1) {
throw Object.assign(new Error('numero nao cadastrado'), {
status: 'nao_cadastrado',
registered: false
});
}
const context = await args.beforeSend();
const result = { messageId: 'second-message' };
await args.afterSend(result, null, context);
return result;
}
};
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, { tentados: 2, enviados: 1, erros: 0, pulados: 1 });
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(rejected.queueId) },
{ status: 'cancelled', cancel_reason: 'whatsapp_not_confirmed' }
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(eligible.queueId).status, 'sent');
db.close();
});
test('reserva atomica vincula fila e send sem consumir limite manual', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 10 });
setSetting(db, 'outreach.mensagensPorDia', '1');
reserveOutreachSend(db, { numero: '5511999999999', mensagem: 'manual' });
setSetting(db, 'outreach.ativo', 'true');
assert.throws(
() => reserveOutreachSend(db, {
leadId: item.leadId,
produto: campaign.produto,
numero: item.numero,
mensagem: 'atalho automatico',
origem: 'automatico'
}),
/fila supervisionada/i
);
const reserved = reserveCampaignQueueSend(db, {
queueId: item.queueId,
now: new Date('2026-07-29T12:00:00.000Z')
});
assert.deepEqual(reserved, { queueId: item.queueId, sendId: 2 });
assert.deepEqual(
{ ...db.prepare('SELECT status, outreach_send_id FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
{ status: 'reserved', outreach_send_id: 2 }
);
assert.deepEqual(
{ ...db.prepare('SELECT lead_id, produto, numero, mensagem_enviada, status, origem FROM outreach_sends WHERE id = 2').get() },
{
lead_id: item.leadId,
produto: campaign.produto,
numero: item.numero,
mensagem_enviada: 'Mensagem 10',
status: 'pendente',
origem: 'automatico'
}
);
db.close();
});
test('reserva aplica limite diario da campanha e quatro tentativas na hora rolante', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const { db, campaign } = setup();
const due = addQueue(db, campaign, { suffix: 20 });
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10, hourly_limit = 4 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 4; index += 1) {
const old = addQueue(db, campaign, { suffix: 21 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', ?)`
).run(old.leadId, campaign.produto, old.numero, `2026-07-29 11:${String(10 + index).padStart(2, '0')}:00`).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, old.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(old.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(db, { queueId: due.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite.*hora/i
);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(due.queueId).status, 'scheduled');
db.prepare("UPDATE outreach_sends SET sent_at = '2026-07-29 10:59:59'").run();
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 6; index += 1) {
const old = addQueue(db, campaign, { suffix: 30 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29 09:00:00')`
).run(old.leadId, campaign.produto, old.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, old.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(old.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(db, { queueId: due.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite diario/i
);
db.close();
});
test('duas conexoes nao reservam a mesma fila nem ultrapassam o limite diario', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-worker-'));
const dbPath = path.join(dir, 'worker.sqlite');
const db = openDb(dbPath);
const { campaign } = (() => {
seedCampaigns(db);
const seeded = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(seeded.id);
return { campaign: seeded };
})();
const first = addQueue(db, campaign, { suffix: 70 });
const second = addQueue(db, campaign, { suffix: 71 });
const other = openDb(dbPath);
const sameQueue = await Promise.allSettled([
Promise.resolve().then(() => reserveCampaignQueueSend(db, { queueId: first.queueId, now: new Date('2026-07-29T12:00:00.000Z') })),
Promise.resolve().then(() => reserveCampaignQueueSend(other, { queueId: first.queueId, now: new Date('2026-07-29T12:00:00.000Z') }))
]);
assert.deepEqual(sameQueue.map((result) => result.status).sort(), ['fulfilled', 'rejected']);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 1);
db.prepare('UPDATE outreach_campaigns SET approved_daily_limit = 10 WHERE id = ?').run(campaign.id);
for (let index = 0; index < 9; index += 1) {
const item = addQueue(db, campaign, { suffix: 80 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29T08:00:00.000Z')`
).run(item.leadId, campaign.produto, item.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(item.queueId);
}
assert.throws(
() => reserveCampaignQueueSend(other, { queueId: second.queueId, now: new Date('2026-07-29T12:00:00.000Z') }),
/limite diario/i
);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 10);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(second.queueId).status, 'scheduled');
other.close();
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
test('segunda conexao aguarda lock e reconta o limite apos commit concorrente', async () => {
const { reserveCampaignQueueSend } = await workerModule();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-cap-lock-'));
const dbPath = path.join(dir, 'worker.sqlite');
const db = openDb(dbPath);
seedCampaigns(db);
const campaign = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running', approved_daily_limit = 10 WHERE id = ?").run(campaign.id);
const committing = addQueue(db, campaign, { suffix: 90 });
const blocked = addQueue(db, campaign, { suffix: 91 });
for (let index = 0; index < 9; index += 1) {
const item = addQueue(db, campaign, { suffix: 920 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at)
VALUES (?, ?, ?, 'anterior', 'erro', 'automatico', '2026-07-29T08:00:00.000Z')`
).run(item.leadId, campaign.produto, item.numero).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'error' WHERE id = ?").run(item.queueId);
}
const other = openDb(dbPath);
const worker = new Worker(`
const { parentPort, workerData } = require('node:worker_threads');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.dbPath);
db.exec('PRAGMA busy_timeout = 5000; BEGIN IMMEDIATE;');
const inserted = db.prepare(
"INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem, sent_at) VALUES (?, ?, ?, 'concorrente', 'pendente', 'local', 'automatico', '2026-07-29T12:00:00.000Z')"
).run(workerData.leadId, workerData.produto, workerData.numero);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?")
.run(Number(inserted.lastInsertRowid), workerData.queueId);
parentPort.postMessage('locked');
setTimeout(() => {
db.exec('COMMIT');
db.close();
parentPort.postMessage('committed');
}, 150);
`, {
eval: true,
workerData: {
dbPath,
queueId: committing.queueId,
leadId: committing.leadId,
numero: committing.numero,
produto: campaign.produto
}
});
const workerExit = new Promise((resolve, reject) => {
worker.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`worker encerrou com ${code}`)));
worker.once('error', reject);
});
try {
await new Promise((resolve, reject) => {
worker.once('message', resolve);
worker.once('error', reject);
});
const startedAt = Date.now();
assert.throws(
() => reserveCampaignQueueSend(other, {
queueId: blocked.queueId,
now: new Date('2026-07-29T12:00:00.000Z')
}),
/limite diario/i
);
assert.ok(Date.now() - startedAt >= 75, 'segunda conexao nao aguardou o lock concorrente');
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 10);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(blocked.queueId).status, 'scheduled');
await workerExit;
} finally {
await worker.terminate();
other.close();
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedupe', async () => {
const { runDueCampaignQueue } = await workerModule();
const cases = [
['campanha pausada', null, ({ db, campaign }) => db.prepare("UPDATE outreach_campaigns SET status = 'paused' WHERE id = ?").run(campaign.id)],
['fila deixou de estar agendada', null, ({ db, item }) => db.prepare("UPDATE outreach_campaign_queue SET status = 'cancelled' WHERE id = ?").run(item.queueId)],
['numero deixou de estar confirmado', 'whatsapp_not_confirmed', ({ db, item }) => saveWhatsAppVerification(db, { numero: item.numero, status: 'nao_cadastrado' })],
['opt-out equivalente', 'opt_out', ({ db, item }) => addOptOut(db, item.numero, 'pare antes do envio')],
['resposta inbound', 'inbound_reply', ({ db, item }) => db.prepare(
'INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES (?, ?, ?, ?)'
).run('inbound-1', item.leadId, item.numero, '2026-07-29T11:59:00.000Z')],
['envio ativo do mesmo produto', 'duplicate_product_send', ({ db, campaign, item }) => db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')`
).run(item.leadId, campaign.produto, item.numero)]
];
for (const [name, cancelReason, mutate] of cases) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 100 + cases.findIndex(([candidate]) => candidate === name) });
let state = 'READY';
const whatsapp = whatsappFake({
beforeProvider: async () => mutate({ db, campaign, item }),
get state() { return state; }
});
if (name === 'campanha pausada') state = 'READY';
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(whatsapp.providerCalls, 0, name);
assert.equal(summary.enviados, 0, name);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, name === 'envio ativo do mesmo produto' ? 1 : 0, name);
if (cancelReason) {
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
{ status: 'cancelled', cancel_reason: cancelReason },
name
);
}
db.close();
}
const { db, campaign } = setup();
addQueue(db, campaign, { suffix: 199 });
let state = 'READY';
const whatsapp = {
getStatus: () => ({ state }),
sendText: async (args) => {
state = 'DISCONNECTED';
await args.beforeSend();
throw new Error('provider nao deveria ser chamado');
}
};
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [outcome, queueStatus, sendStatus] of [
['success', 'sent', 'aceito'],
['unknown', 'unknown', 'desconhecido'],
['rejected', 'error', 'erro']
]) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: `30${outcome.length}` });
db.exec(`
CREATE TABLE finalize_audit (queue_status TEXT);
CREATE TRIGGER audit_queue_finalize AFTER UPDATE OF status ON outreach_campaign_queue
WHEN OLD.status = 'reserved'
BEGIN INSERT INTO finalize_audit VALUES (NEW.status); END;
`);
const whatsapp = whatsappFake({ outcome });
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status, queueStatus);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, sendStatus);
assert.deepEqual(db.prepare('SELECT queue_status FROM finalize_audit').all().map((row) => row.queue_status), [queueStatus]);
db.close();
}
});
test('resposta do provedor sem messageId fica unknown e nunca tenta a mesma fila novamente', async () => {
const { runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 399 });
let providerCalls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend();
providerCalls += 1;
const error = Object.assign(new Error('WhatsApp nao retornou o identificador da mensagem'), {
code: 'WHATSAPP_MESSAGE_ID_MISSING',
deliveryUnknown: true
});
await args.afterSend(null, error, context);
throw error;
}
};
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
await runDueCampaignQueue({ db, whatsapp, now: new Date('2026-07-29T12:00:00.000Z'), log: () => {} });
assert.equal(providerCalls, 1);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status, 'unknown');
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'desconhecido');
db.close();
});
test('restart pausa campanhas, desliga legado, preserva scheduled e recupera reserved pelo send', async () => {
const { pauseCampaignsAfterRestart, recoverReservedCampaignQueue } = await workerModule();
const { db, campaign } = setup();
setSetting(db, 'outreach.ativo', 'true');
const scheduled = addQueue(db, campaign, { suffix: 401 });
const mappings = [
['pendente', 'unknown'],
['aceito', 'unknown'],
['enviado', 'sent'],
['entregue', 'sent'],
['lido', 'sent'],
['desconhecido', 'unknown'],
['erro', 'error']
];
const reserved = [];
for (const [index, [sendStatus]] of mappings.entries()) {
const item = addQueue(db, campaign, { suffix: 410 + index });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (?, ?, ?, 'reservada', ?, 'automatico')`
).run(item.leadId, campaign.produto, item.numero, sendStatus).lastInsertRowid);
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?").run(sendId, item.queueId);
reserved.push(item);
}
assert.equal(pauseCampaignsAfterRestart(db), 1);
assert.deepEqual(
{ ...db.prepare('SELECT status, pause_reason FROM outreach_campaigns WHERE id = ?').get(campaign.id) },
{ status: 'paused', pause_reason: 'server_restart' }
);
assert.equal(db.prepare("SELECT value FROM settings WHERE key = 'outreach.ativo'").get().value, 'false');
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(scheduled.queueId).status, 'scheduled');
assert.equal(recoverReservedCampaignQueue(db), mappings.length);
assert.deepEqual(
reserved.map((item) => db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(item.queueId).status),
mappings.map(([, queueStatus]) => queueStatus)
);
db.close();
});
test('restart reconcilia fila sent quando recovery converte aceite sem ACK em desconhecido', async () => {
const { recoverReservedCampaignQueue, runDueCampaignQueue } = await workerModule();
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 499 });
await runDueCampaignQueue({
db,
whatsapp: whatsappFake(),
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(
{ ...db.prepare(
`SELECT queue.status AS queue_status, sends.status AS send_status
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.id = ?`
).get(item.queueId) },
{ queue_status: 'sent', send_status: 'aceito' }
);
assert.equal(recoverPendingOutreach(db), 1);
assert.equal(recoverReservedCampaignQueue(db), 1);
assert.deepEqual(
{ ...db.prepare(
`SELECT queue.status AS queue_status, sends.status AS send_status
FROM outreach_campaign_queue queue
JOIN outreach_sends sends ON sends.id = queue.outreach_send_id
WHERE queue.id = ?`
).get(item.queueId) },
{ queue_status: 'unknown', send_status: 'desconhecido' }
);
db.close();
});
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerCaptureRoutes } from '../src/server/routes/capture.mjs';
import { registerExtractionRoutes } from '../src/server/routes/extractions.mjs';
function setup() {
const db = openDb(':memory:');
db.prepare("INSERT INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES ('1','Salvador','salvador','BA')").run();
db.prepare("INSERT INTO segments (name,label,active) VALUES ('restaurantes','Restaurantes',1)").run();
db.prepare("INSERT INTO cnaes (codigo,descricao) VALUES ('5611201','Restaurantes')").run();
const router = createRouter();
let started;
registerCaptureRoutes(router, { db, jobs: { startJob: (options) => { started = options; return 'job-1'; } } });
return { db, handler: router.match('POST', '/api/capture/runs').handler, get started() { return started; } };
}
async function invoke(handler, body) {
let result;
await handler({}, {}, { body, json: (status, data) => { result = { status, data }; } });
return result;
}
const valid = {
sources: ['mock'], uf: 'BA', cities: ['Salvador'], segments: ['restaurantes'],
limits: { maxPerQuery: 10, queryLimit: 0, googlePages: 1 }
};
test('captura aceita apenas selecoes reais e persiste parametros normalizados', async () => {
const fixture = setup();
const { db, handler } = fixture;
const result = await invoke(handler, valid);
assert.equal(result.status, 202);
assert.deepEqual(JSON.parse(db.prepare('SELECT params_json FROM capture_runs').get().params_json), valid);
const row = db.prepare('SELECT job_id, owner_token, heartbeat_at FROM capture_runs').get();
assert.equal(row.job_id, 'job-1');
assert.ok(row.owner_token);
assert.ok(row.heartbeat_at);
assert.equal(fixture.started.ownerToken, row.owner_token);
db.close();
});
test('captura rejeita arrays vazios, duplicados, excessivos e strings longas', async () => {
for (const patch of [
{ sources: [] },
{ cities: [] },
{ segments: [] },
{ sources: ['mock', 'mock'] },
{ sources: Array(11).fill('mock') },
{ cities: Array(1001).fill('Salvador') },
{ segments: Array(251).fill('restaurantes') },
{ cities: ['x'.repeat(101)] }
]) {
const { db, handler } = setup();
assert.equal((await invoke(handler, { ...valid, ...patch })).status, 400);
db.close();
}
});
test('captura rejeita cidade, segmento, CNAE e limites inexistentes ou fora da faixa', async () => {
for (const patch of [
{ cities: ['Feira de Santana'] },
{ cities: ['all', 'Salvador'] },
{ segments: ['inativo'] },
{ segments: ['cnae:123'] },
{ segments: ['cnae:1234567'] },
{ limits: { maxPerQuery: 0, queryLimit: 0, googlePages: 1 } },
{ limits: { maxPerQuery: 101, queryLimit: 0, googlePages: 1 } },
{ limits: { maxPerQuery: 10, queryLimit: 10001, googlePages: 1 } },
{ limits: { maxPerQuery: 10, queryLimit: 0, googlePages: 4 } }
]) {
const { db, handler } = setup();
assert.equal((await invoke(handler, { ...valid, ...patch })).status, 400);
db.close();
}
});
test('captura converte corrida de constraint em 409 sem row parcial', async () => {
const db = openDb(':memory:');
db.prepare("INSERT INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES ('1','Salvador','salvador','BA')").run();
db.prepare("INSERT INTO segments (name,label,active) VALUES ('restaurantes','Restaurantes',1)").run();
const raceDb = {
prepare(sql) {
if (/INSERT INTO capture_runs/.test(sql)) {
const error = new Error('UNIQUE constraint failed: active capture');
error.errcode = 2067;
throw error;
}
return db.prepare(sql);
}
};
const router = createRouter();
let started = false;
registerCaptureRoutes(router, { db: raceDb, jobs: { startJob: () => { started = true; } } });
const result = await invoke(router.match('POST', '/api/capture/runs').handler, valid);
assert.equal(result.status, 409);
assert.match(result.data.erro, /simultaneamente/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM capture_runs').get().total, 0);
assert.equal(started, false);
db.close();
});
test('extracao converte corrida de constraint em 409 sem row parcial', async () => {
const db = openDb(':memory:');
const raceDb = {
prepare(sql) {
if (/INSERT INTO extractions/.test(sql)) {
const error = new Error('UNIQUE constraint failed: active extraction');
error.errcode = 2067;
throw error;
}
return db.prepare(sql);
}
};
const router = createRouter();
let started = false;
registerExtractionRoutes(router, { db: raceDb, jobs: { startJob: () => { started = true; } } });
const result = await invoke(router.match('POST', '/api/extractions').handler, { uf: 'BA' });
assert.equal(result.status, 409);
assert.match(result.data.erro, /simultaneamente/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM extractions').get().total, 0);
assert.equal(started, false);
db.close();
});
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { collect } from '../src/sources/cnpj-base.mjs';
const header = 'cnpj;razao_social;nome_fantasia;municipio;uf;cnae\n';
const row = (cnpj, nome, uf) => `${cnpj};${nome};${nome};Salvador;${uf};5611201\n`;
test('cnpj-base le somente o CSV da UF solicitada e o legado apenas para Bahia', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cnpj-files-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
await fs.writeFile(path.join(dir, 'estabelecimentos-BA.csv'), header + row('11111111000111', 'BA EXATA', 'BA'));
await fs.writeFile(path.join(dir, 'estabelecimentos-SP.csv'), header + row('22222222000122', 'SP ERRADA', 'SP'));
await fs.writeFile(path.join(dir, 'checkpoint-estabelecimentos-BA.csv'), header + row('33333333000133', 'CHECKPOINT', 'BA'));
await fs.writeFile(path.join(dir, 'qualquer.csv'), header + row('44444444000144', 'GENERICO', 'BA'));
await fs.writeFile(path.join(dir, 'estabelecimentos-bahia.csv'), header + row('55555555000155', 'LEGADO', 'BA'));
const names = [];
const params = {
uf: 'BA', cities: ['Salvador'],
segments: [{ name: 'restaurantes', cnaeCodes: ['5611201'], queries: [] }]
};
const ctx = { cnpjDir: dir, isCancelled: () => false, log() {}, emit() {}, endpoints: {} };
for await (const batch of collect(params, ctx)) names.push(...batch.leads.map((lead) => lead.company.name));
assert.deepEqual(names, ['BA EXATA']);
await fs.rm(path.join(dir, 'estabelecimentos-BA.csv'));
names.length = 0;
for await (const batch of collect(params, ctx)) names.push(...batch.leads.map((lead) => lead.company.name));
assert.deepEqual(names, ['LEGADO']);
});
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { splitCsvLine, csvEscape, excelTextCell, neutralizeSpreadsheetFormula, streamCsvRows, createCsvWriter, stripBom, BOM } from '../src/util/csv.mjs';
test('splitCsvLine separa campos com separador dentro de aspas', () => {
assert.deepEqual(splitCsvLine('a;"b;c";d'), ['a', 'b;c', 'd']);
});
test('splitCsvLine trata aspas duplas escapadas', () => {
assert.deepEqual(splitCsvLine('"x""y";z'), ['x"y', 'z']);
});
test('splitCsvLine aceita separador virgula', () => {
assert.deepEqual(splitCsvLine('a,b,"c,d"', ','), ['a', 'b', 'c,d']);
});
test('csvEscape protege valores com separador, aspas e quebra de linha', () => {
assert.equal(csvEscape('sem risco'), 'sem risco');
assert.equal(csvEscape('a;b'), '"a;b"');
assert.equal(csvEscape('diz "oi"'), '"diz ""oi"""');
assert.equal(csvEscape(null), '');
});
test('excelTextCell gera formula de texto para numeros longos', () => {
assert.equal(excelTextCell('557193335451'), '"=""557193335451"""');
});
test('neutraliza formula injection sem alterar numeros comuns', () => {
for (const value of ['=HYPERLINK("x")', '+cmd', '-1+2', '@SUM(A1)', '\tformula', '\rformula']) {
assert.equal(neutralizeSpreadsheetFormula(value), `'${value}`);
}
assert.equal(neutralizeSpreadsheetFormula('5511999990001'), '5511999990001');
});
test('stripBom remove BOM apenas quando presente', () => {
assert.equal(stripBom('abc'), 'abc');
assert.equal(stripBom('abc'), 'abc');
});
test('streamCsvRows le arquivo com BOM, auto-detecta separador e normaliza headers', async () => {
const file = path.join(os.tmpdir(), `gti-test-csv-${Date.now()}.csv`);
await fs.writeFile(file, `${BOM}Nome Empresa;Telefone 1\n"ACME; Ltda";7599999\nBeta;`, 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file, {
normalizeHeader: (h) => h.toLowerCase().replace(/\s+/g, '_')
})) {
rows.push(row);
}
assert.equal(rows.length, 2);
assert.equal(rows[0].nome_empresa, 'ACME; Ltda');
assert.equal(rows[0].telefone_1, '7599999');
assert.equal(rows[1].telefone_1, '');
} finally {
await fs.rm(file, { force: true });
}
});
test('streamCsvRows suporta campo com quebra de linha dentro de aspas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvml-${Date.now()}.csv`);
await fs.writeFile(file, 'a;b\n1;"linha um\nlinha dois"\n2;simples', 'utf8');
try {
const rows = [];
for await (const row of streamCsvRows(file)) rows.push(row);
assert.equal(rows.length, 2);
assert.equal(rows[0].b, 'linha um\nlinha dois');
assert.equal(rows[1].b, 'simples');
} finally {
await fs.rm(file, { force: true });
}
});
test('createCsvWriter grava BOM, header e linhas', async () => {
const file = path.join(os.tmpdir(), `gti-test-csvw-${Date.now()}.csv`);
const writer = createCsvWriter(file, ['a', 'b']);
writer.writeRow(['1', csvEscape('x;y')]);
writer.writeRow(['2', excelTextCell('123')]);
await writer.end();
try {
const content = await fs.readFile(file, 'utf8');
assert.ok(content.startsWith(BOM));
const lines = stripBom(content).trim().split('\n');
assert.equal(lines[0], 'a;b');
assert.equal(lines[1], '1;"x;y"');
assert.equal(lines[2], '2;"=""123"""');
} finally {
await fs.rm(file, { force: true });
}
});
+139
View File
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { dashboardSummary, dashboardDailyEvolution, dashboardMaiorOportunidade } from '../src/treatment/dashboard.mjs';
function seedLeads(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null
};
const hoje = new Date().toISOString();
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: hoje });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: null });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Ilheus', uf: 'BA', prioridade: 2, exported_at: null });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', prioridade: 4, exported_at: null });
}
test('dashboardSummary calcula totais, percentuais e meta diaria sobre a base toda', () => {
const db = openDb(':memory:');
seedLeads(db);
setSetting(db, 'meta_diaria', '2');
const s = dashboardSummary(db, {});
assert.equal(s.total, 4);
assert.equal(s.p1, 2);
assert.equal(s.p2, 1);
assert.equal(s.p3, 0);
assert.equal(s.p4, 1);
assert.equal(s.potencial_pct, 50);
assert.equal(s.p1_pct, 50);
assert.equal(s.p4_pct, 25);
assert.equal(s.exportados, 1);
assert.equal(s.disponiveis, 3);
assert.equal(s.cobertura_pct, 25);
assert.equal(s.hoje_exportados, 1);
assert.equal(s.meta_diaria, 2);
assert.equal(s.meta_pct, 50);
assert.equal(s.dias_estimados, 2);
db.close();
});
test('dashboardSummary respeita o filtro (cidade) ao calcular percentuais', () => {
const db = openDb(':memory:');
seedLeads(db);
const s = dashboardSummary(db, { cidade: 'Ilheus' });
assert.equal(s.total, 2);
assert.equal(s.p1, 0);
assert.equal(s.p2, 1);
assert.equal(s.p4, 1);
assert.equal(s.p2_pct, 50);
assert.equal(s.p4_pct, 50);
assert.equal(s.exportados, 0);
assert.equal(s.cobertura_pct, 0);
db.close();
});
test('dashboardSummary com base vazia nao divide por zero', () => {
const db = openDb(':memory:');
const s = dashboardSummary(db, {});
assert.equal(s.total, 0);
assert.equal(s.potencial_pct, 0);
assert.equal(s.cobertura_pct, 0);
assert.equal(s.meta_pct, 0);
assert.equal(s.dias_estimados, 0);
db.close();
});
function seedLeadsComProdutos(db) {
const insert = createLeadInserter(db);
const base = {
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
captured_at: null, prioridade: 1, produtos_json: '[]'
};
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T10:00:00.000Z' });
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T11:00:00.000Z' });
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Salvador', uf: 'BA', produto_principal: 'G-Chat', exported_at: '2026-07-11T09:00:00.000Z' });
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', produto_principal: 'ERP', exported_at: null });
}
test('dashboardDailyEvolution agrupa exportacoes por dia em ordem cronologica', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, {}, 14).map((r) => ({ ...r }));
assert.deepEqual(evolution, [
{ dia: '2026-07-10', total: 2 },
{ dia: '2026-07-11', total: 1 }
]);
db.close();
});
test('dashboardDailyEvolution respeita o filtro', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const evolution = dashboardDailyEvolution(db, { cidade: 'Ilheus' }, 14);
assert.deepEqual(evolution, []);
db.close();
});
test('dashboardMaiorOportunidade retorna a combinacao cidade+produto com mais empresas', () => {
const db = openDb(':memory:');
seedLeadsComProdutos(db);
const oportunidade = dashboardMaiorOportunidade(db, {});
assert.deepEqual({ ...oportunidade }, { cidade: 'Salvador', produto: 'ERP', total: 2 });
db.close();
});
test('dashboardMaiorOportunidade retorna null quando nao ha dados', () => {
const db = openDb(':memory:');
assert.equal(dashboardMaiorOportunidade(db, {}), null);
db.close();
});
+739
View File
@@ -0,0 +1,739 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as database from '../src/db.mjs';
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings, seedMessageTemplates, updateMessageTemplate, resetLeadsBase, reserveOutreachSend, finishOutreachSend } from '../src/db.mjs';
function sampleLead(overrides = {}) {
return {
lead_uid: 'MCP-TEST-1',
dedup_key: 'cnpj:57557263000113',
empresa: 'PREMIUM EXPRESS',
legal_name: 'PREMIUM EXPRESS LTDA',
whatsapp: '557193335451',
ddd: '71',
email: '',
cidade: 'Salvador',
uf: 'BA',
segmento: 'restaurantes',
segmento_label: 'Restaurantes',
interesse: 'Autoatendimento para WhatsApp',
cnpj: '57557263000113',
cnae: '5611203',
endereco: 'RUA X, 1',
website: '',
pains_json: '["atendimento"]',
origem: 'cnpj-base',
telefone_valido: 1,
cnpj_valido: 1,
empresa_valida: 1,
score: 100,
classificacao: 'bom',
motivo_classificacao: 'ok',
capture_run_id: null,
captured_at: '2026-07-09T14:26:18.560Z',
exported_at: null,
...overrides
};
}
test('openDb cria esquema e aplica migrations uma unica vez', () => {
const db = openDb(':memory:');
const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v;
assert.ok(version >= 1);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) {
assert.ok(tables.includes(table), `tabela ${table} ausente`);
}
for (const table of [
'outreach_campaigns',
'outreach_campaign_variants',
'outreach_campaign_queue',
'outreach_inbound_replies',
'outreach_lead_states',
'outreach_stage_events',
'outreach_appointments'
]) {
assert.ok(tables.includes(table), `tabela ${table} ausente`);
}
assert.equal(version, 18);
for (const table of ['capture_runs', 'extractions']) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name);
assert.ok(columns.includes('owner_token'));
assert.ok(columns.includes('heartbeat_at'));
}
const exportColumns = db.prepare('PRAGMA table_info(exports)').all().map((row) => row.name);
for (const column of ['pid', 'owner_token', 'heartbeat_at']) assert.ok(exportColumns.includes(column));
db.close();
});
test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-'));
const dbPath = path.join(tempDir, 'legacy.sqlite');
let db;
try {
db = openDb(dbPath);
db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('legacy-v14', 'Empresa Legada')").run();
db.prepare("INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, provider_message_id) VALUES (1, 'ERP', '5511999990001', 'mensagem legada', 'aceito', 'legacy-message')").run();
db.prepare("INSERT INTO outreach_opt_outs (numero, motivo) VALUES ('5511888880001', 'pedido legado')").run();
db.prepare("INSERT INTO whatsapp_number_verifications (numero, status, numero_resolvido, variante) VALUES ('5511999990001', 'confirmado', '5511999990001', 'exata')").run();
db.exec(`
DROP INDEX idx_leads_campaign_order;
DROP TABLE outreach_appointments;
DROP TABLE outreach_stage_events;
DROP TABLE outreach_lead_states;
DROP TABLE outreach_inbound_replies;
DROP TABLE outreach_campaign_queue;
DROP TABLE outreach_campaign_variants;
DROP TABLE outreach_campaigns;
DELETE FROM schema_migrations WHERE version >= 15;
`);
db.close();
db = null;
db = openDb(dbPath);
assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18);
assert.deepEqual({ ...db.prepare('SELECT dedup_key, empresa FROM leads').get() }, {
dedup_key: 'legacy-v14',
empresa: 'Empresa Legada'
});
assert.deepEqual({ ...db.prepare('SELECT lead_id, produto, numero, mensagem_enviada, status, provider_message_id FROM outreach_sends').get() }, {
lead_id: 1,
produto: 'ERP',
numero: '5511999990001',
mensagem_enviada: 'mensagem legada',
status: 'aceito',
provider_message_id: 'legacy-message'
});
assert.deepEqual({ ...db.prepare('SELECT numero, motivo FROM outreach_opt_outs').get() }, {
numero: '5511888880001',
motivo: 'pedido legado'
});
assert.deepEqual({ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() }, {
numero: '5511999990001',
status: 'confirmado',
numero_resolvido: '5511999990001',
variante: 'exata'
});
} finally {
db?.close();
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
test('v17 corrige trigger v16 ja aplicada sem bloquear agendamento legitimo', () => {
const dbSource = fs.readFileSync(new URL('../src/db.mjs', import.meta.url), 'utf8');
const v16Sql = dbSource.split('// v16')[1].split('// v17')[0];
assert.match(v16Sql, /WHEN OLD\.status <> 'preview'\s+AND \(NEW\.variant_id <> OLD\.variant_id\s+OR NEW\.rendered_message IS NOT OLD\.rendered_message\s+OR NEW\.scheduled_at IS NOT OLD\.scheduled_at\)/);
const db = openDb(':memory:');
try {
const campaignId = Number(db.prepare(
"INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('upgrade-v17', 'Upgrade v17', 'GTI Vendas', '[]')"
).run().lastInsertRowid);
const variantId = Number(db.prepare(
"INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'mensagem original')"
).run(campaignId).lastInsertRowid);
const otherVariantId = Number(db.prepare(
"INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'B', 'mensagem alternativa')"
).run(campaignId).lastInsertRowid);
const leadId = Number(db.prepare(
"INSERT INTO leads (dedup_key, empresa) VALUES ('upgrade-v17-lead', 'Upgrade Lead')"
).run().lastInsertRowid);
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status)
VALUES (?, ?, ?, 'GTI Vendas', '5511999990017', 'restaurantes',
'mensagem original', 'upgrade-test', 'preview')`
).run(campaignId, leadId, variantId).lastInsertRowid);
db.exec(`
DELETE FROM schema_migrations WHERE version > 16;
DROP TRIGGER outreach_campaign_queue_payload_immutable;
CREATE TRIGGER outreach_campaign_queue_payload_immutable
BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue
WHEN OLD.status <> 'preview'
AND (NEW.variant_id <> OLD.variant_id
OR NEW.rendered_message IS NOT OLD.rendered_message
OR NEW.scheduled_at IS NOT OLD.scheduled_at)
BEGIN
SELECT RAISE(ABORT, 'variant_id, rendered_message e scheduled_at sao imutaveis apos preview');
END;
`);
database.migrate(db);
assert.throws(
() => db.prepare(
`UPDATE outreach_campaign_queue
SET status = 'scheduled', variant_id = ?, rendered_message = ?, scheduled_at = ?
WHERE id = ?`
).run(otherVariantId, 'mensagem adulterada', '2026-07-30T09:30:00.000Z', queueId),
/imutav|immutable|constraint/i
);
assert.deepEqual({ ...db.prepare(
'SELECT status, variant_id, rendered_message, scheduled_at FROM outreach_campaign_queue WHERE id = ?'
).get(queueId) }, {
status: 'preview',
variant_id: variantId,
rendered_message: 'mensagem original',
scheduled_at: null
});
assert.doesNotThrow(() => db.prepare(
"UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?"
).run('2026-07-30T09:30:00.000Z', queueId));
assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18);
} finally {
db.close();
}
});
test('v15 aplica defaults, chaves estrangeiras, indices e checks operacionais', () => {
const db = openDb(':memory:');
const campaignId = Number(db.prepare(
"INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('schema-test', 'Schema', 'GTI Vendas', '[]')"
).run().lastInsertRowid);
const campaign = db.prepare(
`SELECT status, week_number, approved_daily_limit, windows_json,
interval_min_ms, interval_max_ms, hourly_limit, verification_multiplier
FROM outreach_campaigns WHERE id = ?`
).get(campaignId);
assert.deepEqual({ ...campaign }, {
status: 'paused',
week_number: 1,
approved_daily_limit: 10,
windows_json: '[{"start":"09:30","end":"11:30"},{"start":"14:00","end":"17:00"}]',
interval_min_ms: 720000,
interval_max_ms: 1500000,
hourly_limit: 4,
verification_multiplier: 1.5
});
const foreignKeys = (table) => db.prepare(`PRAGMA foreign_key_list(${table})`).all()
.map(({ table: target, from, to, on_delete }) => ({ table: target, from, to, on_delete }))
.sort((a, b) => a.from.localeCompare(b.from)
|| a.table.localeCompare(b.table)
|| a.to.localeCompare(b.to));
assert.deepEqual(foreignKeys('outreach_campaign_variants'), [
{ table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'CASCADE' }
]);
assert.deepEqual(foreignKeys('outreach_campaign_queue'), [
{ table: 'outreach_campaign_variants', from: 'campaign_id', to: 'campaign_id', on_delete: 'NO ACTION' },
{ table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'CASCADE' },
{ table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' },
{ table: 'outreach_sends', from: 'outreach_send_id', to: 'id', on_delete: 'NO ACTION' },
{ table: 'outreach_campaign_variants', from: 'variant_id', to: 'id', on_delete: 'NO ACTION' }
]);
assert.deepEqual(foreignKeys('outreach_inbound_replies'), [
{ table: 'leads', from: 'lead_id', to: 'id', on_delete: 'SET NULL' }
]);
assert.deepEqual(foreignKeys('outreach_lead_states'), [
{ table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' }
]);
assert.deepEqual(foreignKeys('outreach_stage_events'), [
{ table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'SET NULL' },
{ table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' }
]);
assert.deepEqual(foreignKeys('outreach_appointments'), [
{ table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'SET NULL' },
{ table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' }
]);
const indexes = Object.fromEntries(db.prepare(
`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name IN (
'idx_campaign_queue_active_lead', 'idx_campaign_queue_active_product_lead',
'idx_campaign_queue_due', 'idx_outreach_inbound_lead',
'idx_outreach_inbound_numero', 'idx_leads_campaign_order'
)`
).all().map(({ name, sql }) => [name, sql]));
assert.match(indexes.idx_campaign_queue_active_lead, /UNIQUE INDEX[\s\S]+campaign_id, lead_id[\s\S]+WHERE status IN \('preview','approved','scheduled','reserved','sent','unknown'\)/i);
assert.match(indexes.idx_campaign_queue_due, /\(status, scheduled_at\)/i);
assert.match(indexes.idx_campaign_queue_active_product_lead, /UNIQUE INDEX[\s\S]+produto, lead_id[\s\S]+WHERE status IN \('preview','approved','scheduled','reserved','sent','unknown'\)/i);
assert.match(indexes.idx_outreach_inbound_lead, /\(lead_id\)/i);
assert.match(indexes.idx_outreach_inbound_numero, /\(numero\)/i);
assert.match(indexes.idx_leads_campaign_order, /\(segmento, prioridade, score DESC, id ASC\)/i);
assert.throws(
() => db.prepare('UPDATE outreach_campaigns SET verification_multiplier = 1.6 WHERE id = ?').run(campaignId),
/verification_multiplier|constraint/i
);
const insertCampaign = db.prepare(
'INSERT INTO outreach_campaigns (code, name, produto, status, week_number, approved_daily_limit, segments_json) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
for (const values of [
['bad-product', 'Bad', 'Outro', 'paused', 1, 10, '[]'],
['bad-status', 'Bad', 'GTI Vendas', 'invalid', 1, 10, '[]'],
['bad-week', 'Bad', 'GTI Vendas', 'paused', 5, 10, '[]'],
['bad-limit', 'Bad', 'GTI Vendas', 'paused', 1, 11, '[]']
]) {
assert.throws(() => insertCampaign.run(...values), /constraint/i);
}
assert.throws(
() => db.prepare("INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'D', 'invalida')").run(campaignId),
/constraint/i
);
const variantId = Number(db.prepare(
"INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'valida')"
).run(campaignId).lastInsertRowid);
const secondVariantId = Number(db.prepare(
"INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'B', 'valida B')"
).run(campaignId).lastInsertRowid);
db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('schema-v15-lead', 'Schema Lead')").run();
const insertQueue = db.prepare(
'INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
assert.throws(
() => insertQueue.run(campaignId, 1, variantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'invalid'),
/constraint/i
);
assert.throws(
() => insertQueue.run(campaignId, 999, variantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'preview'),
/foreign key constraint/i
);
const otherCampaignId = Number(db.prepare(
"INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('schema-other', 'Other', 'GTI Vendas', '[]')"
).run().lastInsertRowid);
const foreignVariantId = Number(db.prepare(
"INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'estrangeira')"
).run(otherCampaignId).lastInsertRowid);
assert.throws(
() => insertQueue.run(campaignId, 1, foreignVariantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'preview'),
/variant|foreign key|constraint/i
);
for (const [index, status] of ['approved', 'scheduled', 'reserved', 'sent', 'unknown'].entries()) {
const leadId = Number(db.prepare(
'INSERT INTO leads (dedup_key, empresa) VALUES (?, ?)'
).run(`immutable-${status}`, `Immutable ${status}`).lastInsertRowid);
const queueId = Number(insertQueue.run(
campaignId, leadId, variantId, 'GTI Vendas', `5511777700${index}`,
'restaurantes', `mensagem ${status}`, 'schema', status
).lastInsertRowid);
assert.throws(
() => db.prepare('UPDATE outreach_campaign_queue SET rendered_message = ? WHERE id = ?').run('alterada', queueId),
/imutav|immutable|constraint/i,
`rendered_message ${status}`
);
assert.throws(
() => db.prepare('UPDATE outreach_campaign_queue SET variant_id = ? WHERE id = ?').run(secondVariantId, queueId),
/imutav|immutable|constraint/i,
`variant_id ${status}`
);
assert.throws(
() => db.prepare('UPDATE outreach_campaign_queue SET scheduled_at = ? WHERE id = ?').run('2026-07-30T09:30:00.000Z', queueId),
/imutav|immutable|constraint/i,
`scheduled_at ${status}`
);
}
const insertTransitionRow = (suffix) => {
const leadId = Number(db.prepare(
'INSERT INTO leads (dedup_key, empresa) VALUES (?, ?)'
).run(`transition-${suffix}`, `Transition ${suffix}`).lastInsertRowid);
return Number(insertQueue.run(
campaignId, leadId, variantId, 'GTI Vendas', `55116666${String(suffix).padStart(4, '0')}`,
'restaurantes', 'mensagem transicao', 'schema', 'preview'
).lastInsertRowid);
};
const transition = db.prepare('UPDATE outreach_campaign_queue SET status = ?, scheduled_at = COALESCE(?, scheduled_at) WHERE id = ?');
for (const [suffix, targetStatus] of [[11, 'approved'], [12, 'scheduled']]) {
const queueId = insertTransitionRow(suffix);
assert.throws(
() => db.prepare(
`UPDATE outreach_campaign_queue
SET status = ?, variant_id = ?, rendered_message = ?, scheduled_at = ?
WHERE id = ?`
).run(targetStatus, secondVariantId, 'mensagem adulterada', '2026-07-29T12:30:00.000Z', queueId),
/imutav|immutable|constraint/i,
`preview->${targetStatus} com payload adulterado`
);
assert.deepEqual({ ...db.prepare(
'SELECT status, variant_id, rendered_message, scheduled_at FROM outreach_campaign_queue WHERE id = ?'
).get(queueId) }, {
status: 'preview',
variant_id: variantId,
rendered_message: 'mensagem transicao',
scheduled_at: null
});
}
const sentPath = insertTransitionRow(1);
assert.doesNotThrow(() => transition.run('approved', '2026-07-29T12:30:00.000Z', sentPath));
assert.doesNotThrow(() => transition.run('scheduled', null, sentPath));
assert.doesNotThrow(() => transition.run('reserved', null, sentPath));
assert.doesNotThrow(() => transition.run('sent', null, sentPath));
assert.throws(() => transition.run('scheduled', null, sentPath), /transicao|transition|constraint/i);
for (const [suffix, path] of [
[2, ['scheduled', 'cancelled']],
[3, ['cancelled']],
[4, ['approved', 'cancelled']],
[5, ['scheduled', 'reserved', 'unknown']],
[6, ['scheduled', 'reserved', 'error']]
]) {
const queueId = insertTransitionRow(suffix);
for (const status of path) {
const scheduledAt = status === 'approved' || status === 'scheduled'
? '2026-07-29T12:30:00.000Z'
: null;
assert.doesNotThrow(() => transition.run(status, scheduledAt, queueId), `${suffix}:${status}`);
}
assert.throws(() => transition.run('preview', null, queueId), /transicao|transition|constraint/i);
}
for (const [suffix, from, to] of [
[7, 'preview', 'reserved'],
[8, 'approved', 'preview'],
[9, 'scheduled', 'sent'],
[10, 'reserved', 'cancelled']
]) {
const queueId = insertTransitionRow(suffix);
if (from === 'approved') transition.run('approved', '2026-07-29T12:30:00.000Z', queueId);
if (from === 'scheduled') transition.run('scheduled', '2026-07-29T12:30:00.000Z', queueId);
if (from === 'reserved') {
transition.run('scheduled', '2026-07-29T12:30:00.000Z', queueId);
transition.run('reserved', null, queueId);
}
assert.throws(() => transition.run(to, null, queueId), /transicao|transition|constraint/i, `${from}->${to}`);
}
const insertAppointment = db.prepare(
'INSERT INTO outreach_appointments (lead_id, campaign_id, kind) VALUES (1, ?, ?)'
);
assert.doesNotThrow(() => insertAppointment.run(campaignId, 'demonstracao'));
assert.doesNotThrow(() => insertAppointment.run(campaignId, 'teste_g_delivery'));
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_appointments').get().total, 2);
assert.throws(() => insertAppointment.run(campaignId, 'visita'), /constraint/i);
db.close();
});
test('limite manual e uso publicado compartilham origem e status exatos', () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '1');
db.prepare(
"INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990001', 'auto', 'aceito', 'automatico')"
).run();
db.prepare(
"INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990002', 'erro', 'erro', 'manual')"
).run();
assert.equal(typeof database.countDailyOutreachUsage, 'function');
assert.equal(database.countDailyOutreachUsage(db, { origem: 'manual' }), 0);
const sendId = reserveOutreachSend(db, {
produto: 'ERP', numero: '5511999990003', mensagem: 'manual', origem: 'manual'
});
assert.ok(sendId > 0);
assert.equal(database.countDailyOutreachUsage(db, { origem: 'manual' }), 1);
assert.throws(
() => reserveOutreachSend(db, {
produto: 'ERP', numero: '5511999990004', mensagem: 'manual 2', origem: 'manual'
}),
(error) => error.statusCode === 429
);
db.close();
});
test('cache de verificacao salva somente o numero solicitado e consulta equivalentes', () => {
const db = openDb(':memory:');
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name);
assert.ok(tables.includes('whatsapp_number_verifications'));
const saved = database.saveWhatsAppVerification(db, {
numero: '55 (75) 99909-1780',
status: 'confirmado',
numeroResolvido: '557599091780',
variante: 'sem_nono_digito'
});
assert.equal(saved.numero, '5575999091780');
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 1);
assert.equal(database.getWhatsAppVerification(db, '557599091780').status, 'confirmado');
assert.deepEqual(database.getWhatsAppVerification(db, '5511999990001'), {
numero: '5511999990001',
status: 'nao_consultado',
numero_resolvido: null,
variante: null,
erro: null,
checked_at: null
});
db.close();
});
test('cache de verificacao faz upsert pela chave normalizada solicitada', () => {
const db = openDb(':memory:');
database.saveWhatsAppVerification(db, {
numero: '55 (11) 99999-0001',
status: 'erro_temporario',
erro: 'timeout'
});
database.saveWhatsAppVerification(db, {
numero: '5511999990001',
status: 'nao_cadastrado'
});
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 1);
const result = database.getWhatsAppVerification(db, '5511999990001');
assert.equal(result.status, 'nao_cadastrado');
assert.equal(result.erro, null);
db.close();
});
test('listConfirmedWhatsAppNumbers retorna variantes somente do estado equivalente mais recente', () => {
const db = openDb(':memory:');
database.saveWhatsAppVerification(db, {
numero: '5575999091780',
status: 'confirmado'
});
database.saveWhatsAppVerification(db, {
numero: '557599091780',
status: 'nao_cadastrado'
});
database.saveWhatsAppVerification(db, {
numero: '5511999990001',
status: 'confirmado'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 10:00:00' WHERE numero = '5575999091780'").run();
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 11:00:00' WHERE numero = '557599091780'").run();
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00' WHERE numero = '5511999990001'").run();
assert.deepEqual(database.listConfirmedWhatsAppNumbers(db).sort(), [
'551199990001',
'5511999990001'
]);
db.close();
});
test('listConfirmedWhatsAppNumbers nao duplica variantes quando ambas foram confirmadas', () => {
const db = openDb(':memory:');
database.saveWhatsAppVerification(db, {
numero: '5575999091780',
status: 'confirmado'
});
database.saveWhatsAppVerification(db, {
numero: '557599091780',
status: 'confirmado'
});
assert.deepEqual(database.listConfirmedWhatsAppNumbers(db).sort(), [
'557599091780',
'5575999091780'
]);
db.close();
});
test('listConfirmedWhatsAppNumbers desempata estados equivalentes pelo rowid mais recente', () => {
const db = openDb(':memory:');
database.saveWhatsAppVerification(db, {
numero: '5575999091780',
status: 'confirmado'
});
database.saveWhatsAppVerification(db, {
numero: '557599091780',
status: 'erro_temporario'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run();
assert.deepEqual(database.listConfirmedWhatsAppNumbers(db), []);
db.close();
});
test('createLeadInserter insere e ignora duplicata pela dedup_key', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.equal(insert(sampleLead()), 'inserted');
assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate');
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
assert.equal(total, 1);
db.close();
});
test('transaction faz rollback em erro', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
assert.throws(() =>
transaction(db, () => {
insert(sampleLead());
throw new Error('boom');
})
);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0);
db.close();
});
test('settings: get/set e seed com defaults pessoais', () => {
const db = openDb(':memory:');
seedSettings(db);
assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '10');
setSetting(db, 'outreach.mensagensPorDia', '9');
assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '9');
// seed nao sobrescreve valor ja definido pelo usuario
seedSettings(db);
assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '9');
db.close();
});
test('message_templates atualiza somente mensagem e ativo com texto pessoal neutro', () => {
const db = openDb(':memory:');
seedMessageTemplates(db, ['ERP']);
const original = db.prepare('SELECT * FROM message_templates WHERE produto = ?').get('ERP');
assert.equal(original.mensagem.includes('Grupo GTI'), false);
const updated = updateMessageTemplate(db, 'ERP', { mensagem: 'Ola {empresa}', ativo: false });
assert.equal(updated.mensagem, 'Ola {empresa}');
assert.equal(updated.ativo, 0);
db.close();
});
test('seeds de segments, municipios e geo_cache', () => {
const db = openDb(':memory:');
seedSegments(db, [
{ name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] }
]);
const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas');
assert.equal(segment.label, 'Clinicas Medicas');
assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']);
seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]);
const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA');
assert.equal(municipio.nome_clean, 'santo antonio de jesus');
seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]);
const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus');
assert.equal(bbox.south, -12.995);
assert.equal(bbox.provider, 'config-legacy');
db.close();
});
test('schema v10 inclui resultado desconhecido no indice automatico unico', () => {
const db = openDb(':memory:');
const columns = db.prepare('PRAGMA table_info(message_templates)').all().map((row) => row.name);
assert.deepEqual(columns, ['id', 'produto', 'mensagem', 'ativo', 'updated_at']);
createLeadInserter(db)(sampleLead());
db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'a', 'pendente', 'automatico')").run();
assert.throws(() => db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'b', 'enviado', 'automatico')").run());
const index = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_outreach_automatico_unico'").get();
assert.match(index.sql, /desconhecido/);
db.close();
});
test('outreach persiste aceite, ACK, entrega e leitura sem regredir status', () => {
const db = openDb(':memory:');
assert.equal(typeof database.updateOutreachMessageStatus, 'function');
const columns = db.prepare('PRAGMA table_info(outreach_sends)').all().map((row) => row.name);
for (const column of ['accepted_at', 'server_ack_at', 'delivered_at', 'read_at']) assert.ok(columns.includes(column));
const id = reserveOutreachSend(db, { numero: '5511999990001', mensagem: 'oi' });
assert.equal(finishOutreachSend(db, id, { messageId: 'native-1' }), true);
assert.equal(db.prepare('SELECT status FROM outreach_sends WHERE id = ?').get(id).status, 'aceito');
assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'entregue' }), true);
assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'enviado' }), false);
assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'lido' }), true);
const row = db.prepare('SELECT status, accepted_at, server_ack_at, delivered_at, read_at FROM outreach_sends WHERE id = ?').get(id);
assert.equal(row.status, 'lido');
assert.ok(row.accepted_at);
assert.ok(row.server_ack_at);
assert.ok(row.delivered_at);
assert.ok(row.read_at);
db.close();
});
test('recovery converte aceite sem ACK em resultado desconhecido', () => {
const db = openDb(':memory:');
const id = reserveOutreachSend(db, { numero: '5511999990001', mensagem: 'oi' });
finishOutreachSend(db, id, { messageId: 'sem-ack' });
assert.equal(database.recoverPendingOutreach(db), 1);
const row = db.prepare('SELECT status, erro FROM outreach_sends WHERE id = ?').get(id);
assert.equal(row.status, 'desconhecido');
assert.match(row.erro, /sem confirmacao/i);
db.close();
});
test('migracao v10 normaliza pendentes legados antes de recriar indice', async () => {
const db = openDb(':memory:');
createLeadInserter(db)(sampleLead());
db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'a', 'pendente', 'automatico')").run();
db.exec('DROP INDEX idx_outreach_automatico_unico');
db.prepare('DELETE FROM schema_migrations WHERE version = 10').run();
db.exec("CREATE UNIQUE INDEX idx_outreach_automatico_unico ON outreach_sends(lead_id, produto) WHERE origem = 'automatico' AND status IN ('pendente', 'enviado') AND lead_id IS NOT NULL");
const { migrate } = await import('../src/db.mjs');
migrate(db);
const row = db.prepare('SELECT status, erro FROM outreach_sends').get();
assert.equal(row.status, 'desconhecido');
assert.match(row.erro, /processo encerrou sem confirmacao/i);
db.close();
});
test('reset apaga outreach antes de leads e preserva opt-outs', () => {
const db = openDb(':memory:');
createLeadInserter(db)(sampleLead());
db.prepare("INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('reset-test', 'Reset', 'GTI Vendas', '[]')").run();
db.prepare("INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (1, 'A', 'oi')").run();
db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada) VALUES (1, 'ERP', 'oi')").run();
db.prepare("INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (1, 1, 1, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'reset-test', 'preview')").run();
db.prepare("INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES ('reply-reset', 1, '5511999990001', datetime('now'))").run();
db.prepare("INSERT INTO outreach_lead_states (lead_id, stage) VALUES (1, 'respondeu')").run();
db.prepare("INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (1, 1, 'respondeu')").run();
db.prepare("INSERT INTO outreach_appointments (lead_id, campaign_id, kind) VALUES (1, 1, 'demonstracao')").run();
db.prepare("INSERT INTO outreach_opt_outs (numero) VALUES ('5511999990001')").run();
resetLeadsBase(db);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM leads').get().total, 0);
for (const table of [
'outreach_campaign_queue',
'outreach_inbound_replies',
'outreach_lead_states',
'outreach_stage_events',
'outreach_appointments'
]) {
assert.equal(db.prepare(`SELECT COUNT(*) total FROM ${table}`).get().total, 0, `${table} nao foi limpa`);
}
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaigns').get().total, 1);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_opt_outs').get().total, 1);
db.close();
});
test('v12 garante no maximo um job ativo por tipo e libera novo apos done', () => {
const db = openDb(':memory:');
const cases = [
['capture_runs', "params_json, status", "'{}', 'pending'"],
['extractions', 'uf, status', "'BA', 'running'"],
['exports', 'tipo, status', "'excel', 'pending'"]
];
for (const [table, columns, values] of cases) {
const insert = db.prepare(`INSERT INTO ${table} (${columns}) VALUES (${values})`);
const first = insert.run();
assert.throws(() => insert.run(), /constraint/i);
db.prepare(`UPDATE ${table} SET status = 'done' WHERE id = ?`).run(first.lastInsertRowid);
assert.doesNotThrow(() => insert.run());
}
db.close();
});
test('v12 normaliza duplicados ativos legados mantendo somente o mais recente', async () => {
const db = openDb(':memory:');
for (const table of ['capture_runs', 'extractions', 'exports']) db.exec(`DROP INDEX idx_${table}_single_active`);
db.prepare('DELETE FROM schema_migrations WHERE version = 12').run();
db.prepare("INSERT INTO capture_runs (params_json, status) VALUES ('{}', 'pending'), ('{}', 'running')").run();
db.prepare("INSERT INTO extractions (uf, status) VALUES ('BA', 'pending'), ('SP', 'running')").run();
db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending'), ('excel', 'running')").run();
const { migrate } = await import('../src/db.mjs');
migrate(db);
for (const table of ['capture_runs', 'extractions', 'exports']) {
assert.equal(db.prepare(`SELECT COUNT(*) total FROM ${table} WHERE status IN ('pending','running')`).get().total, 1);
assert.equal(db.prepare(`SELECT status FROM ${table} ORDER BY id`).get().status, 'error');
}
db.close();
});
+121
View File
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { exportExcel } from '../src/export/excel.mjs';
import { registerExportRoutes } from '../src/server/routes/exports.mjs';
import { createRouter } from '../src/server/router.mjs';
import { stripBom } from '../src/util/csv.mjs';
function seedLead(db) {
createLeadInserter(db)({
lead_uid: 'P-1', dedup_key: 'p-1', empresa: 'PREMIUM EXPRESS', legal_name: '',
whatsapp: '557193335451', ddd: '71', email: '', cidade: 'Salvador', uf: 'BA',
segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '57557263000113',
cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base', telefone_valido: 1,
cnpj_valido: 1, empresa_valida: 1, score: 100, classificacao: 'bom', motivo_classificacao: '',
capture_run_id: null, captured_at: null, exported_at: null
});
}
test('exportExcel usa nome interno e neutraliza formulas no CSV/XLS', async () => {
const db = openDb(':memory:');
seedLead(db);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-'));
const escapedPath = path.resolve(dir, '../../escape.csv');
try {
await fs.rm(escapedPath, { force: true });
const ids = [];
db.prepare("UPDATE leads SET empresa = '=HYPERLINK(\"https://evil\")' WHERE id = 1").run();
const result = await exportExcel({ db, filtro: {}, dir, baseName: '../../escape', collectIds: ids });
assert.deepEqual(result.files, ['leads-excel.csv', 'leads-excel.xls']);
const csv = stripBom(await fs.readFile(path.join(dir, 'leads-excel.csv'), 'utf8'));
const xls = await fs.readFile(path.join(dir, 'leads-excel.xls'), 'utf8');
assert.match(csv, /'=HYPERLINK/);
assert.match(xls, /'=HYPERLINK/);
await assert.rejects(fs.stat(escapedPath));
assert.doesNotMatch(csv, /(^|;)=HYPERLINK/m);
assert.deepEqual(ids, [1]);
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(dir, { recursive: true, force: true });
db.close();
}
});
test('rota de exportacao rejeita tipos corporativos removidos', async () => {
const db = openDb(':memory:');
seedLead(db);
const router = createRouter();
registerExportRoutes(router, { db, jobs: { startJob: () => 'job-1' } });
const calls = [];
await router.match('POST', '/api/exports').handler(null, null, {
body: { tipo: 'zpro-oportunidades' },
json: (status, data) => calls.push({ status, data })
});
assert.equal(calls[0].status, 400);
assert.match(calls[0].data.erro, /excel/);
db.close();
});
test('rota de exportacao persiste somente marcarExportado nos params', async () => {
const db = openDb(':memory:');
seedLead(db);
const router = createRouter();
let started;
registerExportRoutes(router, { db, jobs: { startJob: (options) => { started = options; return 'job-1'; } } });
await router.match('POST', '/api/exports').handler(null, null, { body: { tipo: 'excel', params: { baseName: '../../evil', marcarExportado: false, outro: true } }, json: () => {} });
assert.deepEqual(JSON.parse(db.prepare('SELECT params_json FROM exports').get().params_json), { marcarExportado: false });
const row = db.prepare('SELECT job_id, owner_token, heartbeat_at FROM exports').get();
assert.equal(row.job_id, 'job-1');
assert.ok(row.owner_token);
assert.ok(row.heartbeat_at);
assert.equal(started.ownerToken, row.owner_token);
db.close();
});
test('rota de exportacao rejeita nova exportacao enquanto outra esta pendente', async () => {
const db = openDb(':memory:');
seedLead(db);
db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending')").run();
const router = createRouter();
registerExportRoutes(router, { db, jobs: { startJob: () => 'job-1' } });
let response;
await router.match('POST', '/api/exports').handler(null, null, {
body: { tipo: 'excel' },
json: (status, data) => { response = { status, data }; }
});
assert.equal(response.status, 409);
assert.equal(db.prepare('SELECT COUNT(*) total FROM exports').get().total, 1);
db.close();
});
test('rota de exportacao converte corrida de constraint em 409 sem row parcial', async () => {
const db = openDb(':memory:');
seedLead(db);
const raceDb = {
prepare(sql) {
if (/INSERT INTO exports/.test(sql)) {
const error = new Error('UNIQUE constraint failed: active export');
error.errcode = 2067;
throw error;
}
return db.prepare(sql);
}
};
const router = createRouter();
let started = false;
registerExportRoutes(router, { db: raceDb, jobs: { startJob: () => { started = true; } } });
let response;
await router.match('POST', '/api/exports').handler(null, null, {
body: { tipo: 'excel' },
json: (status, data) => { response = { status, data }; }
});
assert.equal(response.status, 409);
assert.match(response.data.erro, /simultaneamente/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM exports').get().total, 0);
assert.equal(started, false);
db.close();
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchWithTimeout, fetchJsonWithTimeout, fetchTextWithTimeout } from '../src/util/fetch.mjs';
import { createOverpassClient } from '../src/sources/overpass.mjs';
function waitingFetch(url, { signal }) {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}
test('fetchWithTimeout aborta requisicao que excede o prazo', async () => {
await assert.rejects(
fetchWithTimeout('https://example.test', { fetch: waitingFetch }, 5),
/timeout.*5ms/i
);
});
test('fetchWithTimeout combina cancelamento externo sem esperar o timeout', async () => {
const controller = new AbortController();
const promise = fetchWithTimeout('https://example.test', { fetch: waitingFetch, signal: controller.signal }, 10_000);
controller.abort(new Error('cancelado pelo usuario'));
await assert.rejects(promise, /cancelado pelo usuario/i);
});
test('fetchJsonWithTimeout mantem timer ate body JSON terminar', async () => {
let aborted = false;
const fetch = async (url, { signal }) => {
signal.addEventListener('abort', () => { aborted = true; }, { once: true });
return new Response(new ReadableStream({ start(controller) { controller.enqueue(Buffer.from('{"ok":')); } }));
};
await assert.rejects(fetchJsonWithTimeout('https://example.test', { fetch }, 10), /timeout.*10ms/i);
assert.equal(aborted, true);
});
test('fetchTextWithTimeout limita bytes do corpo', async () => {
await assert.rejects(
fetchTextWithTimeout('https://example.test', { fetch: async () => new Response('123456') }, 1000, { maxBytes: 5 }),
/limite.*5 bytes/i
);
});
test('Overpass mantem timeout proprio durante leitura do corpo', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(Buffer.from('{"elements":')); }
}));
try {
const request = createOverpassClient({ urls: ['https://overpass.test'], timeoutMs: 10 });
await assert.rejects(request('[out:json];'), /timeout/i);
} finally {
globalThis.fetch = originalFetch;
}
});
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, saveWhatsAppVerification } from '../src/db.mjs';
import { seedCampaigns } from '../src/outreach/campaign-store.mjs';
import { runDueCampaignQueue } from '../src/outreach/campaign-worker.mjs';
import { recordInboundReply } from '../src/outreach/inbound.mjs';
function setup() {
const db = openDb(':memory:');
seedCampaigns(db);
const campaigns = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id').all();
return { db, campaigns };
}
function addLead(db, suffix, numero) {
return Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES (?, ?, ?, 'restaurantes', 1)`
).run(`inbound-${suffix}`, `Empresa ${suffix}`, numero).lastInsertRowid);
}
function addQueue(db, campaign, { leadId, numero, suffix, status = 'preview' }) {
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
const queueId = Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status)
VALUES (?, ?, ?, ?, ?, 'restaurantes', ?, 'inbound-test', 'preview')`
).run(campaign.id, leadId, variantId, campaign.produto, numero, `Mensagem ${suffix}`).lastInsertRowid);
if (status === 'approved') {
db.prepare("UPDATE outreach_campaign_queue SET status = 'approved' WHERE id = ?").run(queueId);
} else if (status === 'scheduled') {
db.prepare(
"UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-29T12:00:00.000Z' WHERE id = ?"
).run(queueId);
}
return queueId;
}
function addHistoricalSend(db, campaign, { leadId, numero, suffix, sentAt }) {
const queueId = addQueue(db, campaign, { leadId, numero, suffix, status: 'scheduled' });
const sendId = Number(db.prepare(
`INSERT INTO outreach_sends
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at, provider_message_id)
VALUES (?, ?, ?, ?, 'aceito', 'automatico', ?, ?)`
).run(leadId, campaign.produto, numero, `Mensagem ${suffix}`, sentAt, `outbound-${suffix}`).lastInsertRowid);
db.prepare(
"UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?"
).run(sendId, queueId);
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent' WHERE id = ?").run(queueId);
return queueId;
}
test('inbound usa envio de campanha mais recente entre variantes e aplica pausa idempotente', () => {
const { db, campaigns } = setup();
const withNine = '5575999091780';
const withoutNine = '557599091780';
const oldLeadId = addLead(db, 'old', withNine);
const latestLeadId = addLead(db, 'latest', withoutNine);
addHistoricalSend(db, campaigns[0], {
leadId: oldLeadId,
numero: withNine,
suffix: 'old',
sentAt: '2026-07-29T10:00:00.000Z'
});
addHistoricalSend(db, campaigns[1], {
leadId: latestLeadId,
numero: withoutNine,
suffix: 'latest',
sentAt: '2026-07-29T11:00:00.000Z'
});
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
VALUES (?, 'interessado_gti_vendas', '2026-07-20T09:00:00.000Z', '2026-07-20T09:00:00.000Z')`
).run(latestLeadId);
const mutable = ['preview', 'approved', 'scheduled'].map((status, index) => {
const leadId = addLead(db, `mutable-${status}`, index % 2 ? withoutNine : withNine);
return addQueue(db, campaigns[index], {
leadId,
numero: index % 2 ? withoutNine : withNine,
suffix: status,
status
});
});
const event = {
messageId: 'provider-inbound-1',
jid: `${withNine}@s.whatsapp.net`,
number: withNine,
receivedAt: '2026-07-29T12:00:00.000Z'
};
assert.deepEqual(recordInboundReply(db, event), {
inserted: true,
leadId: latestLeadId,
cancelled: 3
});
assert.deepEqual(recordInboundReply(db, event), {
inserted: false,
leadId: latestLeadId,
cancelled: 0
});
assert.deepEqual(
{ ...db.prepare('SELECT lead_id, numero, received_at FROM outreach_inbound_replies').get() },
{ lead_id: latestLeadId, numero: withNine, received_at: event.receivedAt }
);
assert.deepEqual(
{ ...db.prepare('SELECT stage, first_reply_at, updated_at FROM outreach_lead_states WHERE lead_id = ?').get(latestLeadId) },
{ stage: 'respondeu', first_reply_at: '2026-07-20T09:00:00.000Z', updated_at: event.receivedAt }
);
assert.deepEqual(
db.prepare('SELECT lead_id, campaign_id, stage, created_at FROM outreach_stage_events').all().map((row) => ({ ...row })),
[{ lead_id: latestLeadId, campaign_id: campaigns[1].id, stage: 'respondeu', created_at: event.receivedAt }]
);
assert.deepEqual(
db.prepare(`SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id IN (${mutable.map(() => '?').join(',')}) ORDER BY id`).all(...mutable).map((row) => ({ ...row })),
mutable.map(() => ({ status: 'cancelled', cancel_reason: 'inbound_reply' }))
);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_opt_outs').get().total, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS total FROM outreach_lead_states WHERE stage <> 'respondeu' AND lead_id = ?").get(latestLeadId).total, 0);
db.close();
});
test('inbound vence corrida no beforeSend e impede chamada ao provedor', async () => {
const { db, campaigns } = setup();
const campaign = campaigns[0];
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const numero = '5575999091780';
const leadId = addLead(db, 'race', numero);
saveWhatsAppVerification(db, { numero, status: 'confirmado' });
const queueId = addQueue(db, campaign, { leadId, numero, suffix: 'race', status: 'scheduled' });
let releaseGuard;
let guardStarted;
const blocked = new Promise((resolve) => { guardStarted = resolve; });
const gate = new Promise((resolve) => { releaseGuard = resolve; });
let providerCalls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const originalBeforeSend = args.beforeSend;
args.beforeSend = async () => {
guardStarted();
await gate;
return originalBeforeSend();
};
const context = await args.beforeSend();
providerCalls += 1;
const result = { messageId: 'must-not-send' };
await args.afterSend(result, null, context);
return result;
}
};
const running = runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
await blocked;
assert.deepEqual(recordInboundReply(db, {
messageId: 'provider-race-1',
jid: `${numero}@s.whatsapp.net`,
number: numero,
receivedAt: '2026-07-29T11:59:59.000Z'
}), { inserted: true, leadId: null, cancelled: 1 });
releaseGuard();
const summary = await running;
assert.equal(providerCalls, 0);
assert.equal(summary.enviados, 0);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'inbound_reply' }
);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 0);
db.close();
});
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { createJobLease } from '../src/jobs/lease.mjs';
test('lease marca running e atualiza heartbeat ate cleanup', () => {
const db = openDb(':memory:');
const id = Number(db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending')").run().lastInsertRowid);
let tick;
let cleared = false;
const lease = createJobLease({
db, table: 'exports', id, ownerToken: 'owner-1', pid: 123,
setInterval: (fn) => { tick = fn; return 7; },
clearInterval: (timer) => { assert.equal(timer, 7); cleared = true; }
});
const first = db.prepare('SELECT status, pid, owner_token, heartbeat_at FROM exports WHERE id = ?').get(id);
assert.equal(first.status, 'running');
assert.equal(first.pid, 123);
assert.equal(first.owner_token, 'owner-1');
db.prepare("UPDATE exports SET heartbeat_at = '2000-01-01 00:00:00' WHERE id = ?").run(id);
tick();
assert.notEqual(db.prepare('SELECT heartbeat_at FROM exports WHERE id = ?').get(id).heartbeat_at, '2000-01-01 00:00:00');
lease.close();
assert.equal(cleared, true);
db.close();
});
+182
View File
@@ -0,0 +1,182 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
import { openDb } from '../src/db.mjs';
test('close cancela jobs, aguarda exit e mata ao exceder prazo', async () => {
const children = [];
const spawn = () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write: (value) => { child.cancel = value; } };
child.kill = () => { child.killed = true; };
children.push(child);
return child;
};
const jobs = createJobManager({ spawn, killGraceMs: 5 });
jobs.startJob({ type: 'teste', script: 'fake.mjs' });
await Promise.race([
jobs.close(),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('close nao respeitou prazo')), 50))
]);
assert.equal(children[0].cancel, 'cancel\n');
assert.equal(children[0].killed, true);
assert.equal(jobs.activeJobs().length, 0);
});
test('recoverOrphans recupera heartbeat stale mesmo com PID reutilizado e preserva lease recente', () => {
const db = openDb(':memory:');
db.prepare("INSERT INTO capture_runs (params_json, status, pid, heartbeat_at) VALUES ('{}', 'running', ?, '2000-01-01 00:00:00')").run(process.pid);
db.prepare("INSERT INTO extractions (uf, status, pid, heartbeat_at) VALUES ('BA', 'pending', 999, datetime('now'))").run();
db.prepare("INSERT INTO exports (tipo, status, pid, heartbeat_at) VALUES ('xlsx', 'running', 999, '2000-01-01 00:00:00')").run();
recoverOrphans(db, () => {}, { processAlive: (pid) => pid === process.pid });
assert.equal(db.prepare('SELECT status FROM capture_runs').get().status, 'error');
assert.equal(db.prepare('SELECT status FROM extractions').get().status, 'pending');
const recovered = db.prepare('SELECT status, error, finished_at FROM exports').get();
assert.equal(recovered.status, 'error');
assert.match(recovered.error, /interrompido/);
assert.ok(recovered.finished_at);
db.close();
});
test('manager aguarda close para publicar done e converte close nao zero apos done em erro', async () => {
const children = [];
const spawn = () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => {};
children.push(child);
return child;
};
const jobs = createJobManager({ spawn });
const firstId = jobs.startJob({ type: 'teste', script: 'fake' });
const firstEvents = [];
jobs.subscribe(firstId, (event) => firstEvents.push(event));
children[0].stdout.write('{"t":"done","summary":{"total":1}}\n');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(jobs.getJob(firstId).status, 'running');
assert.equal(firstEvents.some((event) => event.t === 'done'), false);
children[0].emit('exit', 0, null);
assert.equal(jobs.getJob(firstId).status, 'running');
children[0].emit('close', 0, null);
assert.equal(jobs.getJob(firstId).status, 'done');
assert.equal(firstEvents.at(-1).t, 'done');
const secondId = jobs.startJob({ type: 'teste', script: 'fake' });
const secondEvents = [];
jobs.subscribe(secondId, (event) => secondEvents.push(event));
children[1].stdout.write('{"t":"done","summary":{}}\n');
await new Promise((resolve) => setImmediate(resolve));
children[1].emit('exit', 1, null);
children[1].emit('close', 1, null);
assert.equal(jobs.getJob(secondId).status, 'error');
assert.equal(secondEvents.at(-1).t, 'error');
});
test('exit nao zero corrige estado done persistido prematuramente pelo filho', async () => {
const db = openDb(':memory:');
const runId = Number(db.prepare("INSERT INTO capture_runs (params_json, status) VALUES ('{}', 'running')").run().lastInsertRowid);
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => {};
const jobs = createJobManager({ spawn: () => child, db });
jobs.startJob({ type: 'captura', script: 'fake', meta: { runId } });
db.prepare("UPDATE capture_runs SET status = 'done' WHERE id = ?").run(runId);
child.stdout.write('{"t":"done","summary":{}}\n');
await new Promise((resolve) => setImmediate(resolve));
child.emit('exit', 1, null);
child.emit('close', 1, null);
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'error');
db.close();
});
test('manager persiste erro de filho inesperado e cancelamento forcado no DB', async () => {
const db = openDb(':memory:');
const captureId = Number(db.prepare("INSERT INTO capture_runs (params_json, status) VALUES ('{}', 'pending')").run().lastInsertRowid);
const extractionId = Number(db.prepare("INSERT INTO extractions (uf, status) VALUES ('BA', 'pending')").run().lastInsertRowid);
const children = [];
const spawn = () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => { child.emit('exit', null, 'SIGTERM'); child.emit('close', null, 'SIGTERM'); };
children.push(child);
return child;
};
const jobs = createJobManager({ spawn, db, killGraceMs: 1 });
jobs.startJob({ type: 'captura', script: 'fake', meta: { runId: captureId } });
children[0].stderr.end('boom');
children[0].emit('exit', 1, null);
children[0].emit('close', 1, null);
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(captureId).status, 'error');
const jobId = jobs.startJob({ type: 'extracao', script: 'fake', meta: { extractionId } });
assert.equal(jobs.cancelJob(jobId), true);
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(db.prepare('SELECT status FROM extractions WHERE id = ?').get(extractionId).status, 'cancelled');
db.close();
});
test('manager mantem cancelado quando filho relata erro depois da intencao de cancelar', async () => {
const db = openDb(':memory:');
const extractionId = Number(db.prepare("INSERT INTO extractions (uf, status) VALUES ('BA', 'running')").run().lastInsertRowid);
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => {};
const jobs = createJobManager({ spawn: () => child, db, killGraceMs: 100 });
const jobId = jobs.startJob({ type: 'extracao', script: 'fake', meta: { extractionId } });
jobs.cancelJob(jobId);
child.stdout.write('{"t":"error","message":"stream encerrado"}\n');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(db.prepare('SELECT status FROM extractions WHERE id = ?').get(extractionId).status, 'cancelled');
child.emit('exit', 1, null);
child.emit('close', 1, null);
db.close();
});
test('linha done drenada entre exit e close resulta em done', async () => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => {};
const jobs = createJobManager({ spawn: () => child });
const jobId = jobs.startJob({ type: 'teste', script: 'fake' });
child.emit('exit', 0, null);
child.stdout.write('{"t":"done","summary":{"late":true}}\n');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(jobs.getJob(jobId).status, 'running');
child.emit('close', 0, null);
assert.equal(jobs.getJob(jobId).status, 'done');
assert.equal(jobs.getJob(jobId).lastEvent.summary.late, true);
});
test('manager trata erro do stream stdout como falha terminal persistida', async () => {
const db = openDb(':memory:');
const exportId = Number(db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'running')").run().lastInsertRowid);
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = { write() {} };
child.kill = () => { child.killed = true; };
const jobs = createJobManager({ spawn: () => child, db });
jobs.startJob({ type: 'exportacao', script: 'fake', meta: { exportId } });
child.stdout.emit('error', new Error('pipe quebrado'));
assert.equal(db.prepare('SELECT status FROM exports WHERE id = ?').get(exportId).status, 'error');
assert.equal(child.killed, true);
db.close();
});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
const source = await fs.readFile(new URL('../launcher/Program.cs', import.meta.url), 'utf8');
test('launcher valida APP_PORT como inteiro na faixa TCP com fallback 3910', () => {
assert.match(source, /int\.TryParse\(port, out parsedPort\)/);
assert.match(source, /parsedPort\s*<\s*1\s*\|\|\s*parsedPort\s*>\s*65535/);
assert.match(source, /port\s*=\s*"3910"/);
});
test('launcher exige status 200 e identidade JSON da aplicacao no health', () => {
assert.match(source, /StreamReader/);
assert.match(source, /\\"app\\":\\"maquina-comercial-pessoal\\"/);
assert.match(source, /StatusCode\s*==\s*200/);
});
+157
View File
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
buildNextQuestion,
detectService,
normalizePhone,
processLead
} from '../src/lead-engine.mjs';
describe('lead engine', () => {
it('normaliza telefone brasileiro para formato internacional', () => {
assert.equal(normalizePhone('(75) 99999-1111'), '5575999991111');
assert.equal(normalizePhone('+55 75 99999-1111'), '5575999991111');
});
it('detecta servico por texto livre', () => {
assert.equal(detectService('quero trocar meu sistema de estoque'), 'erp');
assert.equal(detectService('preciso de suporte em servidor e backup'), 'ti');
assert.equal(detectService('automatizar atendimento no WhatsApp'), 'whatsapp');
});
it('prioriza lead com WhatsApp para tratamento manual', () => {
const result = processLead({
source: 'site',
contact: { name: 'Carlos Souza', phone: '(75) 99999-1111', email: 'carlos@mercado.com' },
company: {
name: 'Mercado Sao Joao',
city: 'Santo Antonio de Jesus',
state: 'BA',
segment: 'mercado',
employees: 18
},
interest: 'ERP',
current_system: 'uso outro sistema, mas quero trocar',
pains: ['estoque', 'financeiro', 'emissao fiscal'],
urgency: 'alta',
consent: true
});
assert.equal(result.lead.service, 'erp');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.equal(result.priority.hasWhatsapp, true);
assert.ok(result.whatsapp.internal.message.includes('Novo lead captado - COM WHATSAPP'));
assert.equal(result.nextAction, 'tratamento_manual');
});
it('mantem lead de TI com dados parciais como captado', () => {
const result = processLead({
origem: 'indicacao',
nome: 'Ana Lima',
telefone: '75988887777',
empresa: 'Clinica Vida',
cidade: 'Santo Antonio de Jesus',
uf: 'BA',
segmento: 'clinica',
servico: 'gestao de TI',
computadores: 8,
dor: 'backup; suporte tecnico',
urgencia: 'media'
});
assert.equal(result.lead.service, 'ti');
assert.equal(result.priority.priority, 'com_whatsapp');
assert.ok(result.crmRecord.whatsapp.startsWith('55'));
});
it('mantem lead sem WhatsApp abaixo na prioridade', () => {
const result = processLead({
name: 'Lead Sem Servico',
message: 'quero mais informacoes'
});
assert.equal(result.lead.service, 'indefinido');
assert.equal(result.priority.priority, 'sem_whatsapp');
assert.equal(result.priority.hasWhatsapp, false);
assert.ok(buildNextQuestion(result.lead).includes('1 - Sistema ERP'));
});
it('reconhece segmentos compostos da prospeccao automatica como segmento-alvo', () => {
const compostos = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999993333',
company_name: 'Restaurante Sabor da Terra',
city: 'Amargosa',
state: 'BA',
segment: 'restaurante_pizzaria'
});
assert.equal(compostos.lead.isTargetSegment, true);
const materialConstrucao = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999994444',
company_name: 'Deposito Central',
segment: 'material_construcao_cimento_blocos'
});
assert.equal(materialConstrucao.lead.isTargetSegment, true);
const clinica = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999995555',
company_name: 'Clinica Odonto Vida',
segment: 'clinica_laboratorio_odonto_otica'
});
assert.equal(clinica.lead.isTargetSegment, true);
});
it('nao marca como segmento-alvo um segmento fora da lista', () => {
const result = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999996666',
company_name: 'Posto Boa Vista',
segment: 'posto_combustivel'
});
assert.equal(result.lead.isTargetSegment, false);
});
it('reconhece os novos nichos (beleza, celulares, transportadora) como segmento-alvo', () => {
const beleza = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999997777',
company_name: 'Studio Beleza Bahia',
segment: 'beleza_estetica_barbearia'
});
assert.equal(beleza.lead.isTargetSegment, true);
const celulares = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999998888',
company_name: 'Cell Center',
segment: 'celulares_eletronicos_assistencia'
});
assert.equal(celulares.lead.isTargetSegment, true);
const transportadora = processLead({
name: 'Lead Prospeccao',
whatsapp: '75999999999',
company_name: 'Transportadora Recôncavo',
segment: 'transportadora_logistica'
});
assert.equal(transportadora.lead.isTargetSegment, true);
});
it('direciona lead de WhatsApp para pergunta de volume quando a dor ja existe', () => {
const result = processLead({
name: 'Mariana',
whatsapp: '75999992222',
company_name: 'Restaurante Sabor',
city: 'Santo Antonio de Jesus',
state: 'BA',
interest: 'autoatendimento WhatsApp',
pains: ['pedidos', 'triagem']
});
assert.equal(result.lead.service, 'whatsapp');
assert.ok(buildNextQuestion(result.lead).includes('quantas mensagens'));
});
});
+98
View File
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, getSetting, seedSettings } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerMetaRoutes } from '../src/server/routes/meta.mjs';
function setup({ whatsapp = { getStatus: () => ({ pendingSends: 0 }) } } = {}) {
const db = openDb(':memory:');
seedSettings(db);
const router = createRouter();
registerMetaRoutes(router, { db, jobs: { activeJobs: () => [] }, whatsapp });
return { db, router };
}
async function call(router, method, pathname, { body, user } = {}) {
const match = router.match(method, pathname);
let response;
await match.handler({}, {}, {
...match,
body,
user,
json(status, data) { response = { status, data }; }
});
return response;
}
test('somente admin altera configuracoes e apenas chaves permitidas', async () => {
const { db, router } = setup();
const denied = await call(router, 'PUT', '/api/settings', {
user: { role: 'comercial' },
body: { 'outreach.ativo': 'true' }
});
assert.equal(denied.status, 403);
assert.equal(getSetting(db, 'outreach.ativo'), 'false');
const unknown = await call(router, 'PUT', '/api/settings', {
user: { role: 'admin' },
body: { 'chave.arbitraria': 'valor' }
});
assert.equal(unknown.status, 400);
assert.equal(getSetting(db, 'chave.arbitraria'), null);
const allowed = await call(router, 'PUT', '/api/settings', {
user: { role: 'admin' },
body: { 'outreach.ativo': 'true', 'outreach.mensagensPorDia': '5' }
});
assert.equal(allowed.status, 200);
assert.equal(getSetting(db, 'outreach.ativo'), 'true');
assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '5');
db.close();
});
test('settings valida meta, limite e booleano estritamente', async () => {
const { db, router } = setup();
for (const body of [
{ meta_diaria: '0' },
{ meta_diaria: '1.5' },
{ 'outreach.mensagensPorDia': '1001' },
{ 'outreach.ativo': 'sim' },
{ 'outreach.ativo': true }
]) assert.equal((await call(router, 'PUT', '/api/settings', { user: { role: 'admin' }, body })).status, 400);
assert.equal((await call(router, 'PUT', '/api/settings', { user: { role: 'admin' }, body: { meta_diaria: '500', 'outreach.mensagensPorDia': '20', 'outreach.ativo': 'false' } })).status, 200);
assert.equal(getSetting(db, 'meta_diaria'), '500');
db.close();
});
test('reset exige senha configurada e bloqueia enquanto ha job ativo', async () => {
const previous = process.env.APP_RESET_PASSWORD;
delete process.env.APP_RESET_PASSWORD;
const { db, router } = setup();
const admin = { role: 'admin' };
assert.equal((await call(router, 'POST', '/api/admin/reset', { user: admin, body: { senha: 'x' } })).status, 503);
process.env.APP_RESET_PASSWORD = 'segredo';
const busyRouter = createRouter();
registerMetaRoutes(busyRouter, { db, jobs: { activeJobs: () => [{ jobId: '1' }] }, whatsapp: { getStatus: () => ({ pendingSends: 0 }) } });
assert.equal((await call(busyRouter, 'POST', '/api/admin/reset', { user: admin, body: { senha: 'segredo' } })).status, 409);
if (previous === undefined) delete process.env.APP_RESET_PASSWORD;
else process.env.APP_RESET_PASSWORD = previous;
db.close();
});
test('reset bloqueia envio WhatsApp pendente e jobs persistentes fora do manager', async () => {
const previous = process.env.APP_RESET_PASSWORD;
process.env.APP_RESET_PASSWORD = 'segredo';
const admin = { role: 'admin' };
const sending = setup({ whatsapp: { getStatus: () => ({ pendingSends: 1 }) } });
assert.equal((await call(sending.router, 'POST', '/api/admin/reset', { user: admin, body: { senha: 'segredo' } })).status, 409);
sending.db.close();
const persistent = setup();
persistent.db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending')").run();
assert.equal((await call(persistent.router, 'POST', '/api/admin/reset', { user: admin, body: { senha: 'segredo' } })).status, 409);
assert.equal(persistent.db.prepare('SELECT COUNT(*) total FROM exports').get().total, 1);
persistent.db.close();
if (previous === undefined) delete process.env.APP_RESET_PASSWORD;
else process.env.APP_RESET_PASSWORD = previous;
});
+500
View File
@@ -0,0 +1,500 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, addOptOut, createLeadInserter, setSetting } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerOutreachRoutes } from '../src/server/routes/outreach.mjs';
function setup({ state = 'READY' } = {}) {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const sent = [];
const whatsapp = {
getStatus: () => ({ state, connected: state === 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend?.();
sent.push(args);
const result = { messageId: 'local-123' };
await args.afterSend?.(result, null, context);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
return { db, router, sent };
}
async function invoke(router, method, pathname, { body, user = { role: 'comercial' } } = {}) {
const calls = [];
const matched = router.match(method, pathname);
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
await matched.handler(null, null, {
params: matched.params,
body,
user,
json: (status, data) => calls.push({ status, data })
});
return calls[0];
}
test('status de outreach informa conexao WhatsApp local', async () => {
const { db, router } = setup();
const response = await invoke(router, 'GET', '/api/outreach/status');
assert.equal(response.status, 200);
assert.equal(response.data.whatsapp.state, 'READY');
assert.equal(response.data.configurado, true);
db.close();
});
test('resultado desconhecido conta no limite diario', async () => {
const { db, router, sent } = setup();
setSetting(db, 'outreach.mensagensPorDia', '1');
db.prepare("INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990001', 'oi', 'desconhecido', 'manual')").run();
const status = await invoke(router, 'GET', '/api/outreach/status');
assert.equal(status.data.enviadosHoje, 1);
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990002', mensagem: 'Oi' }
});
assert.equal(response.status, 429);
assert.equal(sent.length, 0);
db.close();
});
test('envio manual exige READY, numero valido e mensagem', async () => {
const disconnected = setup({ state: 'DISCONNECTED' });
const response = await invoke(disconnected.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 409);
disconnected.db.close();
const ready = setup();
assert.equal((await invoke(ready.router, 'POST', '/api/outreach/enviar-manual', { body: { numero: '123', mensagem: 'Oi' } })).status, 400);
assert.equal((await invoke(ready.router, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990001', mensagem: '' } })).status, 400);
ready.db.close();
});
test('envio manual renderiza lead, envia local e registra messageId generico', async () => {
const { db, router, sent } = setup();
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: {
numero: '+55 (11) 99999-0001',
mensagem: 'Ola {empresa}, oferta de {produto} em {cidade}',
lead: { empresa: 'Padaria Sol', produto_principal: 'ERP', cidade: 'Ilheus' }
}
});
assert.deepEqual(response, { status: 202, data: { ok: true, status: 'aceito', messageId: 'local-123' } });
assert.equal(sent[0].number, '5511999990001');
assert.equal(sent[0].message, 'Ola Padaria Sol, oferta de ERP em Ilheus');
const row = db.prepare('SELECT * FROM outreach_sends').get();
assert.equal(row.provider_message_id, 'local-123');
assert.equal(row.status, 'aceito');
assert.equal(row.canal, 'local');
assert.equal(row.origem, 'manual');
db.close();
});
test('envio manual bloqueia opt-out e limite diario', async () => {
const opted = setup();
addOptOut(opted.db, '5511999990001', 'nao contatar');
const blocked = await invoke(opted.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(blocked.status, 409);
assert.equal(opted.sent.length, 0);
opted.db.close();
const limited = setup();
setSetting(limited.db, 'outreach.mensagensPorDia', '0');
const response = await invoke(limited.router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 429);
limited.db.close();
});
test('rotas opt-out listam e restringem mutacoes a admin', async () => {
const { db, router } = setup();
const denied = await invoke(router, 'POST', '/api/outreach/opt-outs', {
body: { numero: '5511999990001' }, user: { role: 'comercial' }
});
assert.equal(denied.status, 403);
const created = await invoke(router, 'POST', '/api/outreach/opt-outs', {
body: { numero: '5511999990001', motivo: 'solicitado' }, user: { role: 'admin' }
});
assert.equal(created.status, 201);
const listed = await invoke(router, 'GET', '/api/outreach/opt-outs');
assert.equal(listed.data.optOuts.length, 1);
const removed = await invoke(router, 'DELETE', '/api/outreach/opt-outs/5511999990001', { user: { role: 'admin' } });
assert.equal(removed.status, 200);
db.close();
});
test('envio com leadId honra destino visivel e usa contexto completo do banco', async () => {
const { db, router, sent } = setup();
createLeadInserter(db)({
dedup_key: 'real-1', empresa: 'Empresa Real', whatsapp: '5511999990002', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { leadId: 1, numero: '5511999990001', mensagem: 'Oi {empresa} de {cidade}' }
});
assert.equal(response.status, 202);
assert.equal(sent[0].number, '5511999990001');
assert.equal(sent[0].message, 'Oi Empresa Real de Santos');
assert.equal(db.prepare('SELECT lead_id FROM outreach_sends').get().lead_id, 1);
assert.equal(db.prepare('SELECT numero FROM outreach_sends').get().numero, '5511999990001');
assert.equal(db.prepare('SELECT whatsapp FROM leads WHERE id = 1').get().whatsapp, '5511999990002');
assert.equal((await invoke(router, 'POST', '/api/outreach/enviar-manual', { body: { leadId: 999, numero: '5511999990001', mensagem: 'Oi' } })).status, 404);
db.close();
});
test('envio manual rejeita leadId coercivel antes de cache, reserva ou envio', async () => {
const { db, router, sent } = setup();
createLeadInserter(db)({
dedup_key: 'canonical-1', empresa: 'Empresa Real', whatsapp: '5511999990002', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache nao deveria ser tocado'); END;
`);
for (const leadId of [true, '1', '01', '1e0', 1.5, 0, -1, NaN, 999]) {
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { leadId, numero: '5511999990001', mensagem: 'Oi' }
});
assert.deepEqual(response, { status: 404, data: { erro: 'lead nao encontrado' } });
}
assert.equal(sent.length, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 0);
db.close();
});
test('envio manual persiste numero confirmado e devolve metadados resolvidos', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const result = {
messageId: 'id-1',
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
};
await args.afterSend(result, null, sendId);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5575999091780', mensagem: 'Oi' }
});
assert.deepEqual(response, {
status: 202,
data: {
ok: true,
status: 'aceito',
messageId: 'id-1',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
}
});
assert.deepEqual(
{ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() },
{
numero: '5575999091780',
status: 'confirmado',
numero_resolvido: '557599091780',
variante: 'sem_nono_digito'
}
);
db.close();
});
test('numero nao cadastrado no envio retorna 422 e persiste verificacao', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('numero nao esta cadastrado no WhatsApp'), {
numero: '5511888880000',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 422);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
{ numero: '5511999990001', status: 'nao_cadastrado' }
);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('timeout de consulta no envio persiste erro temporario antes de retornar 504', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504,
code: 'WHATSAPP_LOOKUP_TIMEOUT',
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 504);
assert.deepEqual(
{ ...db.prepare('SELECT status, erro FROM whatsapp_number_verifications').get() },
{ status: 'erro_temporario', erro: 'consulta excedeu o prazo' }
);
db.close();
});
test('falha de cache no numero nao cadastrado preserva 422 e aviso local', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('numero nao cadastrado'), {
status: 'nao_cadastrado', registered: false, resolvedNumber: null, variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 422);
assert.equal(response.data.ok, false);
assert.match(response.data.erro, /nao cadastrado/);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('falha de cache no timeout de lookup preserva 504 e aviso local', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504, status: 'erro_temporario', registered: false, resolvedNumber: null, variant: null
});
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 504);
assert.equal(response.data.ok, false);
assert.match(response.data.erro, /prazo/);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar/i);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0);
db.close();
});
test('timeout apos reserva finaliza uma vez como desconhecido e orienta a nao reenviar', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TABLE outreach_send_update_audit (send_id INTEGER, status TEXT);
CREATE TRIGGER audit_outreach_send_update AFTER UPDATE ON outreach_sends
BEGIN
INSERT INTO outreach_send_update_audit (send_id, status) VALUES (NEW.id, NEW.status);
END;
`);
let callbackCalls = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const error = Object.assign(new Error('envio excedeu o prazo'), {
statusCode: 504,
code: 'WHATSAPP_SEND_TIMEOUT',
deliveryUnknown: true,
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '557599091780',
variant: 'sem_nono_digito'
});
callbackCalls += 1;
await args.afterSend(null, error, sendId);
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5575999091780', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'resultado_desconhecido');
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(callbackCalls, 1);
const row = db.prepare('SELECT status, erro FROM outreach_sends').get();
assert.equal(row.status, 'desconhecido');
assert.match(row.erro, /prazo/);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
{ numero: '5575999091780', status: 'confirmado' }
);
assert.deepEqual(
db.prepare('SELECT status FROM outreach_send_update_audit').all().map((audit) => audit.status),
['desconhecido']
);
db.close();
});
test('falha secundaria no cache preserva resposta aceita e orienta a nao reenviar', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const result = { messageId: 'aceita-1', resolvedNumber: '5511999990001', variant: 'exata' };
await args.afterSend(result, null, sendId);
return result;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'aceito');
assert.equal(response.data.messageId, 'aceita-1');
assert.match(response.data.aviso, /verificacao local/i);
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'aceito');
db.close();
});
test('falha secundaria no cache preserva resultado desconhecido e aviso de nao reenvio', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.mensagensPorDia', '10');
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const sendId = await args.beforeSend();
const error = Object.assign(new Error('envio excedeu o prazo'), {
deliveryUnknown: true,
numero: '5511888880000',
status: 'erro_temporario',
resolvedNumber: '5511999990001',
variant: 'exata'
});
await args.afterSend(null, error, sendId);
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'Oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'resultado_desconhecido');
assert.match(response.data.aviso, /verificacao local/i);
assert.match(response.data.aviso, /nao reenvie/i);
assert.equal(db.prepare('SELECT status FROM outreach_sends').get().status, 'desconhecido');
db.close();
});
test('duas requisicoes manuais concorrentes respeitam limite dentro da fila', async () => {
const { db, router } = setup();
setSetting(db, 'outreach.mensagensPorDia', '1');
const calls = [];
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => { const context = await args.beforeSend(); const result = { messageId: 'id' }; await args.afterSend(result, null, context); return result; }
};
const concurrentRouter = createRouter();
registerOutreachRoutes(concurrentRouter, { db, whatsapp });
const responses = await Promise.all([
invoke(concurrentRouter, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990001', mensagem: 'um' } }),
invoke(concurrentRouter, 'POST', '/api/outreach/enviar-manual', { body: { numero: '5511999990002', mensagem: 'dois' } })
]);
calls.push(...responses.map((r) => r.status));
assert.deepEqual(calls.sort(), [202, 429]);
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_sends WHERE status = 'aceito'").get().total, 1);
db.close();
});
test('aceite com falha de persistencia orienta a nao reenviar', async () => {
const db = openDb(':memory:');
const whatsapp = {
getStatus: () => ({ state: 'READY' }),
sendText: async () => {
const error = new Error('banco indisponivel');
error.deliverySucceeded = true;
error.messageId = 'possibly-sent-1';
throw error;
}
};
const router = createRouter();
registerOutreachRoutes(router, { db, whatsapp });
const response = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990001', mensagem: 'oi' }
});
assert.equal(response.status, 202);
assert.equal(response.data.status, 'persistencia_incerta');
assert.match(response.data.aviso, /nao reenvie/i);
db.close();
});
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, saveWhatsAppVerification } from '../src/db.mjs';
import { seedCampaigns } from '../src/outreach/campaign-store.mjs';
import { runOutreachBatch } from '../src/outreach/run.mjs';
import { renderTemplate } from '../src/outreach/templates.mjs';
function addScheduledQueue(db, scheduledAt = '2026-07-29T12:00:00.000Z') {
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);
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('queue-lead', 'Empresa Fila', '5511999990001', 'restaurantes', 1)`
).run().lastInsertRowid);
saveWhatsAppVerification(db, { numero: '5511999990001', status: 'confirmado' });
const variantId = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
).get(campaign.id).id;
return Number(db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento,
rendered_message, selection_reason, status, scheduled_at)
VALUES (?, ?, ?, ?, '5511999990001', 'restaurantes',
'Mensagem aprovada e imutavel', 'teste', 'scheduled', ?)`
).run(campaign.id, leadId, variantId, campaign.produto, scheduledAt).lastInsertRowid);
}
function whatsappFake() {
const sent = [];
return {
sent,
getStatus: () => ({ state: 'READY' }),
sendText: async (args) => {
const context = await args.beforeSend();
sent.push({ number: args.number, message: args.message });
const result = { messageId: 'queue-message-1' };
await args.afterSend(result, null, context);
return result;
}
};
}
test('renderTemplate inclui produto no contexto pessoal', () => {
assert.equal(renderTemplate('{empresa}: {produto}', { empresa: 'Loja', produto: 'ERP' }), 'Loja: ERP');
});
test('runOutreachBatch consome somente uma linha aprovada e vencida da fila', async () => {
const db = openDb(':memory:');
const queueId = addScheduledQueue(db);
const whatsapp = whatsappFake();
const result = await runOutreachBatch({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(result, { tentados: 1, enviados: 1, erros: 0, pulados: 0 });
assert.deepEqual(whatsapp.sent, [{
number: '5511999990001',
message: 'Mensagem aprovada e imutavel'
}]);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'sent');
assert.deepEqual(
{ ...db.prepare(
'SELECT mensagem_enviada, provider_message_id, status, canal, origem FROM outreach_sends'
).get() },
{
mensagem_enviada: 'Mensagem aprovada e imutavel',
provider_message_id: 'queue-message-1',
status: 'aceito',
canal: 'local',
origem: 'automatico'
}
);
db.close();
});
test('runOutreachBatch sem WhatsApp READY preserva fila e registra adiamento', async () => {
const db = openDb(':memory:');
const queueId = addScheduledQueue(db);
const logs = [];
let sends = 0;
const result = await runOutreachBatch({
db,
whatsapp: {
getStatus: () => ({ state: 'DISCONNECTED' }),
sendText: async () => { sends += 1; }
},
now: new Date('2026-07-29T12:00:00.000Z'),
log: (line) => logs.push(line)
});
assert.deepEqual(result, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
assert.equal(sends, 0);
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'scheduled');
assert.match(logs[0], /nao esta conectado/i);
db.close();
});
test('runOutreachBatch nunca seleciona lead cru sem fila supervisionada', async () => {
const db = openDb(':memory:');
db.prepare(
`INSERT INTO leads
(dedup_key, empresa, whatsapp, segmento, telefone_valido, produto_principal)
VALUES ('raw-lead', 'Lead Cru', '5511999990002', 'restaurantes', 1, 'G-Delivery')`
).run();
saveWhatsAppVerification(db, { numero: '5511999990002', status: 'confirmado' });
const whatsapp = whatsappFake();
const result = await runOutreachBatch({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(result, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
assert.equal(whatsapp.sent.length, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 0);
db.close();
});
test('runOutreachBatch respeita cancelamento antes da reserva', async () => {
const db = openDb(':memory:');
const queueId = addScheduledQueue(db);
const whatsapp = whatsappFake();
const controller = new AbortController();
controller.abort();
const result = await runOutreachBatch({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
signal: controller.signal,
log: () => {}
});
assert.deepEqual(result, { tentados: 0, enviados: 0, erros: 0, pulados: 0 });
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'scheduled');
assert.equal(whatsapp.sent.length, 0);
db.close();
});
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
test('package e ambiente usam identidade e caminhos pessoais', async () => {
const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
assert.equal(pkg.name, 'maquina-comercial-pessoal');
assert.equal(pkg.dependencies.baileys, '7.0.0-rc14');
assert.equal(pkg.dependencies.qrcode, '1.5.4');
const previous = { ...process.env };
delete process.env.APP_PORT;
delete process.env.APP_DB_PATH;
delete process.env.APP_WHATSAPP_AUTH_DIR;
const env = await import(`../src/env.mjs?personal=${Date.now()}`);
assert.equal(env.APP_PORT, 3910);
assert.equal(env.paths.db, path.join(root, 'data', 'maquina-pessoal.db'));
assert.equal(env.paths.whatsappAuth, path.join(root, 'data', 'runtime', 'whatsapp-auth'));
process.env = previous;
});
test('schema pessoal possui opt-outs e identificador generico de mensagem', async () => {
const { openDb } = await import('../src/db.mjs');
const db = openDb(':memory:');
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(({ name }) => name);
assert.ok(tables.includes('outreach_opt_outs'));
const columns = db.prepare('PRAGMA table_info(outreach_sends)').all().map(({ name }) => name);
assert.ok(columns.includes('provider_message_id'));
assert.ok(!columns.includes('zpro_ticket_id'));
db.close();
});
test('settings pessoais sao conservadores e nao incluem credenciais corporativas', async () => {
const { openDb, seedSettings, getSetting } = await import('../src/db.mjs');
const db = openDb(':memory:');
seedSettings(db);
assert.equal(getSetting(db, 'outreach.ativo'), 'false');
assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '10');
assert.equal(getSetting(db, 'outreach.intervaloMinMs'), '5000');
assert.equal(getSetting(db, 'outreach.intervaloMaxMs'), '12000');
assert.equal(db.prepare("SELECT COUNT(*) total FROM settings WHERE key LIKE 'zpro.%' OR key LIKE 'gchat.%'").get().total, 0);
db.close();
});
test('helpers de opt-out adicionam, listam, consultam e removem sem o reset apagar', async () => {
const { openDb, addOptOut, listOptOuts, isOptedOut, removeOptOut, resetLeadsBase } = await import('../src/db.mjs');
const db = openDb(':memory:');
addOptOut(db, '55 (11) 99999-0001', 'pedido do contato');
assert.equal(isOptedOut(db, '5511999990001'), true);
assert.equal(listOptOuts(db)[0].motivo, 'pedido do contato');
resetLeadsBase(db);
assert.equal(isOptedOut(db, '5511999990001'), true);
assert.equal(removeOptOut(db, '5511999990001'), true);
assert.equal(isOptedOut(db, '5511999990001'), false);
db.close();
});
test('opt-out bloqueia e remove as formas equivalentes com e sem nono digito', async () => {
const { openDb, addOptOut, isOptedOut, removeOptOut } = await import('../src/db.mjs');
const db = openDb(':memory:');
addOptOut(db, '5575999091780', 'solicitado');
assert.equal(isOptedOut(db, '557599091780'), true);
assert.equal(removeOptOut(db, '557599091780'), true);
assert.equal(isOptedOut(db, '5575999091780'), false);
db.close();
});
test('listagem e detalhe derivam o estado da verificacao equivalente mais recente', async () => {
const { openDb, saveWhatsAppVerification } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
const inserted = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('telefone:5575999091780', 'Lead verificado', '5575999091780', 1)`
).run();
saveWhatsAppVerification(db, {
numero: '5575999091780',
status: 'confirmado',
numeroResolvido: '5575999091780',
variante: 'exata'
});
saveWhatsAppVerification(db, {
numero: '557599091780',
status: 'nao_cadastrado'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run();
const cached = db.prepare(
'SELECT numero, rowid FROM whatsapp_number_verifications ORDER BY rowid'
).all();
assert.deepEqual(cached.map((row) => row.numero), ['5575999091780', '557599091780']);
assert.ok(cached[1].rowid > cached[0].rowid);
const detail = getLead(db, Number(inserted.lastInsertRowid));
assert.equal(detail.whatsapp_status, 'nao_cadastrado');
assert.equal(detail.whatsapp_numero_resolvido, null);
assert.equal(detail.whatsapp_checked_at, '2026-07-29 12:00:00');
assert.equal(detail.telefone_valido, 1);
const listed = listLeads(db).rows[0];
assert.equal(listed.whatsapp_status, 'nao_cadastrado');
assert.equal(listed.whatsapp_numero_resolvido, null);
assert.equal(listed.whatsapp_checked_at, '2026-07-29 12:00:00');
assert.equal(listed.telefone_valido, 1);
saveWhatsAppVerification(db, {
numero: '5575999091780',
status: 'confirmado',
numeroResolvido: '557599091780',
variante: 'sem_nono_digito'
});
db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run();
const updated = getLead(db, Number(inserted.lastInsertRowid));
assert.equal(updated.whatsapp_status, 'confirmado');
assert.equal(updated.whatsapp_numero_resolvido, '557599091780');
db.close();
});
test('lead sem verificacao equivalente permanece nao consultado', async () => {
const { openDb } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
const inserted = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('telefone:5511999990001', 'Lead novo', '5511999990001', 1)`
).run();
const detail = getLead(db, Number(inserted.lastInsertRowid));
const listed = listLeads(db).rows[0];
for (const lead of [detail, listed]) {
assert.equal(lead.whatsapp_status, 'nao_consultado');
assert.equal(lead.whatsapp_numero_resolvido, null);
assert.equal(lead.whatsapp_checked_at, null);
assert.equal(lead.telefone_valido, 1);
}
db.close();
});
+506
View File
@@ -0,0 +1,506 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
function fakeElement(id = '') {
const listeners = {};
const attributes = new Map();
const classes = new Set();
const node = {
id,
nodeType: 1,
tagName: '',
listeners,
attributes,
children: [],
className: '',
textContent: '',
value: '',
checked: false,
disabled: false,
open: false,
options: [],
selectedOptions: [],
dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, force) => {
const active = force === undefined ? !classes.has(name) : force;
if (active) classes.add(name); else classes.delete(name);
return active;
}
},
addEventListener: (type, handler) => { listeners[type] = handler; },
append(...children) { this.children.push(...children.flat()); },
replaceChildren(...children) { this.children = children.flat(); },
querySelectorAll: () => [],
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) || null,
setAttribute: (name, value) => attributes.set(name, value),
showModal() { this.open = true; },
close() { this.open = false; listeners.close?.(); },
click() { return listeners.click?.({ preventDefault() {}, stopPropagation() {}, target: this }); }
};
return node;
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function nodeText(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return `${node?.textContent || ''}${(node?.children || []).map(nodeText).join('')}`;
}
async function executeApp({ whatsappState = 'READY', statusResponses = [], qrResponses = [], get, post } = {}) {
const elements = new Map();
const timers = [];
const gets = [];
const posts = [];
const element = (id) => {
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
};
const apiGet = async (url) => {
gets.push(url);
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/whatsapp/status') return statusResponses.length ? statusResponses.shift() : { state: whatsappState };
if (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null };
if (get) return get(url);
throw new Error(`GET inesperado: ${url}`);
};
const context = {
API: {
get: apiGet,
post: async (url, body) => {
posts.push({ url, body });
if (post) return post(url, body);
if (url === '/api/outreach/enviar-manual') return { ok: true, status: 'aceito' };
return {};
},
put: async () => ({}),
delete: async () => ({})
},
Captura: { init: async () => {} },
Tratamento: { bindEvents() {}, ativar() {} },
Campanhas: { bindEvents() {}, ativar() {} },
// Mirrors public/js/api.js's comCarregando — app.js expects it as a
// real global (plain <script> include order), but this sandbox only
// loads app.js, so it must be provided here too.
comCarregando: async (button, fn) => {
if (!button) return fn();
const jaCarregando = button.disabled;
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
try {
return await fn();
} finally {
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
}
},
clearTimeout() {},
dataBr: (value) => value,
document: {
querySelectorAll: () => [],
querySelector: (selector) => selector === '#tabela-leads tbody' ? element('tabela-leads-tbody') : null
},
el: element,
fmt: new Intl.NumberFormat('pt-BR'),
h: (tag, attrs = {}, ...children) => {
const node = fakeElement();
node.tagName = tag.toUpperCase();
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
node.append(...children);
return node;
},
setTimeout: (callback, delay) => {
timers.push({ callback, delay });
return timers.length;
},
toast() {},
URLSearchParams,
window: { Campanhas: { bindEvents() {}, ativar() {} }, confirm: () => true, location: {} }
};
vm.createContext(context);
vm.runInContext(await read('public/js/app.js'), context);
await new Promise((resolve) => setImmediate(resolve));
return { context, element, elements, gets, posts, timers };
}
async function renderTreatment(app, rows) {
app.context.API.get = async (url) => {
if (url === '/api/leads/facets') return { ufs: [], interesses: [], produtos: [], segmentos: [], cidades: [] };
if (url.startsWith('/api/dashboard/summary')) return {
total: rows.length, com_whatsapp: rows.length, p1: rows.length, p2: 0, p3: 0, p4: 0,
potencial_pct: 100, cobertura_pct: 0, exportados: 0, disponiveis: rows.length,
p1_pct: 100, p2_pct: 0, p3_pct: 0, p4_pct: 0, meta_diaria: 10,
meta_pct: 0, hoje_exportados: 0, dias_estimados: 1
};
if (url.startsWith('/api/dashboard/evolution')) return { evolution: [] };
if (url.startsWith('/api/dashboard/oportunidade')) return { oportunidade: null };
if (url.startsWith('/api/dashboard/rankings')) return { ranking: [] };
if (url.startsWith('/api/leads?')) return { rows, total: rows.length, page: 1, pageSize: 50 };
if (url === '/api/exports?limit=12') return { exports: [] };
if (/^\/api\/leads\/\d+$/.test(url)) return rows.find((lead) => url.endsWith(`/${lead.id}`));
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
throw new Error(`GET inesperado no tratamento: ${url}`);
};
vm.runInContext(await read('public/js/tratamento.js'), app.context);
await app.context.window.Tratamento.ativar();
return app.element('tabela-leads-tbody');
}
test('interface e arquivos operacionais usam somente a identidade pessoal', async () => {
const files = await Promise.all([
'public/index.html',
'public/login.html',
'public/js/app.js',
'public/js/tratamento.js',
'public/css/app.css',
'.env.example',
'README.md',
'launcher/Program.cs',
'scripts/build-launcher.ps1'
].map(read));
const operationalText = files.join('\n');
assert.match(operationalText, /Máquina Comercial Pessoal/);
assert.doesNotMatch(operationalText, /Grupo GTI|GTI-|GTI_|Z-PRO|ZPRO|WABA|GChat/i);
await assert.rejects(fs.access(path.join(root, 'public', 'img', 'logo-gti.png')));
await assert.rejects(fs.access(path.join(root, 'logo-gti-256x256.png')));
await assert.rejects(fs.access(path.join(root, 'INICIAR-GTI.bat')));
await fs.access(path.join(root, 'INICIAR-MAQUINA-PESSOAL.bat'));
await fs.access(path.join(root, 'launcher', 'Maquina-Comercial-Pessoal.csproj'));
});
test('runtime pessoal usa cookie, IDs e variavel de ambiente pessoais', async () => {
const runtimeText = (await Promise.all([
'src/server/session.mjs',
'src/lead-engine.mjs',
'src/scoring.mjs',
'src/sources/common.mjs'
].map(read))).join('\n');
assert.match(runtimeText, /maquina_pessoal_session/);
assert.match(runtimeText, /PERSONAL_COMMERCIAL_WHATSAPP/);
assert.match(runtimeText, /MCP-/);
assert.doesNotMatch(runtimeText, /Grupo GTI|GTI-|GTI_/i);
});
test('catalogo de campanhas limita a identidade Grupo GTI aos produtos aprovados', async () => {
const catalogText = await read('src/outreach/campaign-catalog.mjs');
assert.match(catalogText, /Grupo GTI/);
assert.match(catalogText, /GTI Vendas/);
assert.match(catalogText, /G-Delivery/);
assert.doesNotMatch(catalogText, /Z-PRO|ZPRO|WABA|GChat/i);
});
test('fontes usam os nomes de ambiente documentados', async () => {
const envText = await read('src/env.mjs');
const placesText = await read('src/sources/google-places.mjs');
assert.match(envText, /process\.env\.CNPJ_BASE_DIR/);
assert.doesNotMatch(envText, /APP_CNPJ_BASE_DIR/);
assert.match(placesText, /process\.env\.GOOGLE_PLACES_API_KEY/);
assert.doesNotMatch(placesText, /GOOGLE_MAPS_API_KEY/);
});
test('catalogo preserva segmentos com um servico neutro por segmento', async () => {
const config = JSON.parse(await read('prospeccao/config.json'));
assert.ok(config.segments.length > 0);
for (const segment of config.segments) {
assert.deepEqual(segment.products, [{ nome: 'Meu serviço', estrelas: 3 }]);
}
});
test('comando npm do launcher contorna somente a politica local de scripts', async () => {
const pkg = JSON.parse(await read('package.json'));
assert.match(pkg.scripts['build:launcher'], /-ExecutionPolicy Bypass -File scripts\/build-launcher\.ps1/);
const buildScript = await read('scripts/build-launcher.ps1');
assert.match(buildScript, /Get-Command dotnet -ErrorAction SilentlyContinue/);
});
test('launcher carrega APP_PORT do .env do root sem sobrescrever ambiente real', async () => {
const source = await read('launcher/Program.cs');
assert.match(source, /LoadDotEnv\(root\)/);
assert.match(source, /BaseUrl\(root\)/);
assert.match(source, /Environment\.GetEnvironmentVariable\(key\)/);
assert.match(source, /StartsWith\("#"\)/);
});
test('tratamento separa validade do telefone da verificacao de WhatsApp', async () => {
const html = await read('public/index.html');
const treatment = await read('public/js/tratamento.js');
assert.match(html, /Telefone: todos/);
assert.match(html, /Com telefone/);
assert.match(html, /Sem telefone/);
assert.match(html, /<th>Telefone<\/th>/);
assert.match(html, /empresa, CNPJ ou telefone/i);
assert.doesNotMatch(treatment, /WhatsApp v[aá]lido/i);
assert.doesNotMatch(treatment, /não tem WhatsApp cadastrado/i);
assert.match(treatment, /whatsapp_status/);
assert.match(treatment, /whatsapp_numero_resolvido/);
assert.match(treatment, /whatsapp_checked_at/);
for (const label of ['Não consultado', 'Confirmado', 'Não cadastrado', 'Erro temporário']) {
assert.match(treatment, new RegExp(label));
}
});
test('configuracoes mantem polling rapido em transicao e lento em estado estavel', async () => {
const transitional = await executeApp({ whatsappState: 'RECONNECTING' });
await transitional.elements.get('btn-settings').click();
assert.equal(transitional.timers.at(-1)?.delay, 1000);
const stable = await executeApp({ whatsappState: 'READY' });
await stable.elements.get('btn-settings').click();
assert.equal(stable.timers.at(-1)?.delay, 5000);
await stable.timers.at(-1).callback();
assert.equal(stable.timers.at(-1)?.delay, 5000);
});
test('resultado de poll antigo nao sobrescreve uma acao WhatsApp mais nova', async () => {
const oldPoll = deferred();
const app = await executeApp({
statusResponses: [{ state: 'READY' }, oldPoll.promise],
post: async (url) => {
assert.equal(url, '/api/whatsapp/disconnect');
return { status: { state: 'STOPPED' } };
}
});
await app.element('btn-settings').click();
const poll = app.timers.at(-1).callback();
await Promise.resolve();
await app.element('btn-whatsapp-disconnect').click();
oldPoll.resolve({ state: 'QR_REQUIRED' });
await poll;
assert.equal(app.element('whatsapp-state').textContent, 'Parado');
assert.equal(app.element('whatsapp-qr').getAttribute('src'), null);
});
test('QR antigo nao reaparece depois de uma acao informar READY', async () => {
const oldQr = deferred();
const app = await executeApp({
statusResponses: [{ state: 'QR_REQUIRED' }],
qrResponses: [oldQr.promise],
post: async () => ({ status: { state: 'READY' } })
});
const opening = app.element('btn-settings').click();
await Promise.resolve();
await Promise.resolve();
await app.element('btn-whatsapp-connect').click();
oldQr.resolve({ qr: 'data:image/png;base64,antigo', expiresAt: '2026-07-29T12:00:00Z' });
await opening;
assert.equal(app.element('whatsapp-state').textContent, 'Conectado');
assert.equal(app.element('whatsapp-qr').getAttribute('src'), null);
assert.equal(app.element('whatsapp-qr').classList.contains('hidden'), true);
});
test('resultado de acao antiga nao sobrescreve uma acao posterior', async () => {
const oldAction = deferred();
const app = await executeApp({
post: async (url) => url === '/api/whatsapp/connect'
? oldAction.promise
: { status: { state: 'STOPPED' } }
});
await app.element('btn-settings').click();
const connecting = app.element('btn-whatsapp-connect').click();
await Promise.resolve();
await app.element('btn-whatsapp-disconnect').click();
const getCount = app.gets.length;
oldAction.resolve({});
await connecting;
assert.equal(app.element('whatsapp-state').textContent, 'Parado');
assert.equal(app.element('whatsapp-qr').classList.contains('hidden'), true);
assert.equal(app.gets.length, getCount);
});
test('fechar configuracoes invalida consulta de status em andamento', async () => {
const oldStatus = deferred();
const app = await executeApp({ statusResponses: [oldStatus.promise] });
const opening = app.element('btn-settings').click();
await Promise.resolve();
app.element('modal-settings').close();
oldStatus.resolve({ state: 'QR_REQUIRED' });
await opening;
assert.equal(app.element('modal-settings').open, false);
assert.equal(app.element('whatsapp-state').textContent, '');
assert.equal(app.timers.length, 0);
});
test('limpar contexto preserva destino e mensagem digitados', async () => {
const { context, element } = await executeApp();
const numero = element('manual-numero');
const mensagem = element('manual-mensagem');
numero.value = '5575999999999';
mensagem.value = 'Mensagem revisada';
context.window.MaquinaSelecionarLeadManual({ id: 8, empresa: 'Empresa teste' });
assert.equal(typeof context.window.MaquinaLimparLeadManual, 'function');
context.window.MaquinaLimparLeadManual();
assert.equal(numero.value, '5575999999999');
assert.equal(mensagem.value, 'Mensagem revisada');
assert.equal(element('manual-lead-context').textContent, 'Envio avulso, sem lead selecionado.');
});
test('envio manual usa edicoes visiveis e mantem o lead somente como contexto', async () => {
const { context, element, posts } = await executeApp();
context.window.MaquinaSelecionarLeadManual({ id: 8, empresa: 'Empresa teste', whatsapp: '5575111111111' });
element('manual-numero').value = '5575222222222';
element('manual-mensagem').value = 'Texto final do operador';
await element('btn-enviar-manual').click();
assert.deepEqual(JSON.parse(JSON.stringify(posts[0])), {
url: '/api/outreach/enviar-manual',
body: {
numero: '5575222222222',
mensagem: 'Texto final do operador',
leadId: 8,
lead: { id: 8, empresa: 'Empresa teste', whatsapp: '5575111111111' }
}
});
assert.equal(posts.some(({ url }) => url.includes('check-number')), false);
});
test('envio manual mostra aviso quando o resultado da entrega e desconhecido', async () => {
const app = await executeApp({
post: async (url) => {
assert.equal(url, '/api/outreach/enviar-manual');
return {
ok: false,
status: 'resultado_desconhecido',
aviso: 'O envio pode ter sido aceito. Nao reenvie sem confirmar no celular.'
};
}
});
app.element('manual-numero').value = '5575222222222';
app.element('manual-mensagem').value = 'Texto final';
await app.element('btn-enviar-manual').click();
assert.match(app.element('manual-status').textContent, /nao reenvie/i);
assert.equal(app.element('manual-status').classList.contains('status-error'), true);
assert.equal(app.element('manual-status').classList.contains('status-ok'), false);
});
test('envio manual aceito mostra aviso de cache com destaque de alerta', async () => {
const app = await executeApp({
post: async (url) => {
assert.equal(url, '/api/outreach/enviar-manual');
return {
ok: true,
status: 'aceito',
messageId: 'aceita-1',
aviso: 'Nao foi possivel atualizar a verificacao local. Nao reenvie a mensagem por esse motivo.'
};
}
});
app.element('manual-numero').value = '5575222222222';
app.element('manual-mensagem').value = 'Texto final';
await app.element('btn-enviar-manual').click();
assert.match(app.element('manual-status').textContent, /nao reenvie/i);
assert.equal(app.element('manual-status').classList.contains('status-error'), true);
assert.equal(app.element('manual-status').classList.contains('status-ok'), false);
});
test('envio manual aceito sem aviso preserva confirmacao normal', async () => {
const app = await executeApp();
app.element('manual-numero').value = '5575222222222';
app.element('manual-mensagem').value = 'Texto final';
await app.element('btn-enviar-manual').click();
assert.match(app.element('manual-status').textContent, /Aceito pelo WhatsApp/i);
assert.equal(app.element('manual-status').classList.contains('status-ok'), true);
assert.equal(app.element('manual-status').classList.contains('status-error'), false);
});
test('acao real da tabela preenche envio e preserva edicao visivel com contexto do lead', async () => {
const app = await executeApp();
const lead = {
id: 8, empresa: 'Empresa teste', legal_name: 'Empresa Teste Ltda', whatsapp: '5575111111111',
cidade: 'Salvador', uf: 'BA', segmento: 'tecnologia', segmento_label: 'Tecnologia', cnpj: '123',
whatsapp_status: 'confirmado', whatsapp_numero_resolvido: '5575111111111', whatsapp_checked_at: '2026-07-29T12:00:00Z',
prioridade: 1, score: 90, classificacao: 'excelente', produto_principal: 'Meu serviço', afinidade: 'alta', exported_at: null
};
const tbody = await renderTreatment(app, [lead]);
const action = tbody.children[0].children[0].children[0];
await action.click();
await app.timers.find(({ delay }) => delay === 250).callback();
assert.match(app.element('manual-lead-context').textContent, /Lead: Empresa teste/);
assert.equal(app.element('manual-numero').value, '5575111111111');
assert.equal(app.element('manual-mensagem').value, 'Olá! Encontrei a Empresa teste em Salvador e gostaria de apresentar meu serviço. Podemos conversar?');
app.element('manual-numero').value = '5575222222222';
await app.element('btn-enviar-manual').click();
const sent = app.posts.find(({ url }) => url === '/api/outreach/enviar-manual');
assert.equal(sent.body.numero, '5575222222222');
assert.equal(sent.body.leadId, 8);
assert.equal(sent.body.lead.empresa, 'Empresa teste');
assert.equal(app.posts.some(({ url }) => url.includes('check-number')), false);
});
test('status de verificacao mostra numero e data validos sem depender de title', async () => {
const app = await executeApp();
const base = {
empresa: 'Empresa', whatsapp: '5575111111111', cidade: 'Salvador', segmento: 'tecnologia',
prioridade: 1, score: 90, classificacao: 'excelente', produto_principal: '', afinidade: 'alta', exported_at: null,
whatsapp_status: 'confirmado', whatsapp_numero_resolvido: '5575111111111'
};
const tbody = await renderTreatment(app, [
{ ...base, id: 1, whatsapp_checked_at: '2026-07-29T12:00:00Z' },
{ ...base, id: 2, whatsapp_checked_at: 'data-invalida' }
]);
const validStatus = tbody.children[0].children[3];
const invalidStatus = tbody.children[1].children[3];
assert.match(nodeText(validStatus), /Confirmado/);
assert.match(nodeText(validStatus), /5575111111111/);
assert.match(nodeText(validStatus), /2026-07-29T12:00:00Z/);
assert.doesNotMatch(nodeText(invalidStatus), /data-invalida/);
});
test('controle de limpar contexto e um botao acessivel', async () => {
const html = await read('public/index.html');
assert.match(html, /<button[^>]+id="btn-limpar-lead-manual"[^>]+type="button"[^>]*>\s*Limpar contexto do lead\s*<\/button>/i);
assert.match(html, /id="whatsapp-state"[^>]+role="status"[^>]+aria-live="polite"/i);
});
+241
View File
@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, addOptOut } from '../src/db.mjs';
import { seedCampaigns, setLeadCommercialStage, saveAppointment, listPendingReplies, listCampaigns } from '../src/outreach/campaign-store.mjs';
import { CAMPAIGN_STAGES } from '../src/outreach/campaign-catalog.mjs';
function insertLead(db, empresa = 'Lead Teste', whatsapp = '5511999990001') {
db.prepare("INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES (?, ?, ?)").run(`pipeline-${empresa}`, empresa, whatsapp);
return Number(db.prepare('SELECT id FROM leads ORDER BY id DESC LIMIT 1').get().id);
}
function insertLeadWithReply(db, empresa = 'Lead Respondeu', whatsapp = '5511999990002') {
const leadId = insertLead(db, empresa, whatsapp);
setLeadCommercialStage(db, { leadId, stage: 'enviado' });
setLeadCommercialStage(db, { leadId, stage: 'respondeu', note: 'respondeu no whatsapp' });
return leadId;
}
test('listPendingReplies retorna leads que responderam e estao em etapas iniciais', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const replied = insertLeadWithReply(db, 'Lead A', '5511999990010');
const noReply = insertLead(db, 'Lead B', '5511999990011');
const movedToInterest = insertLeadWithReply(db, 'Lead C', '5511999990012');
setLeadCommercialStage(db, { leadId: movedToInterest, stage: 'interessado_gti_vendas', note: 'quer gti' });
const optedOut = insertLead(db, 'Lead D', '5511999990013');
setLeadCommercialStage(db, { leadId: optedOut, stage: 'opt_out', note: 'nao quer' });
const pending = listPendingReplies(db);
assert.equal(pending.length, 1);
assert.equal(pending[0].lead_id, replied);
assert.equal(pending[0].stage, 'respondeu');
db.close();
});
test('setLeadCommercialStage rejeita etapa fora do catalogo', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db);
assert.throws(() => setLeadCommercialStage(db, { leadId, stage: 'etapa_inexistente' }), /etapa comercial invalida/);
db.close();
});
test('setLeadCommercialStage aceita somente as 11 etapas do catalogo', () => {
const db = openDb(':memory:');
seedCampaigns(db);
assert.deepEqual(CAMPAIGN_STAGES, [
'fila_preparada', 'aprovado_para_envio', 'enviado', 'respondeu',
'interessado_gti_vendas', 'interessado_g_delivery', 'demonstracao_agendada',
'teste_g_delivery_iniciado', 'sem_interesse', 'opt_out', 'venda_fechada'
]);
db.close();
});
test('setLeadCommercialStage atualiza estado atual e insere historico na mesma transacao', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db);
const campaignId = listCampaigns(db)[0].id;
const result = setLeadCommercialStage(db, { leadId, campaignId, stage: 'respondeu', note: 'cliente respondeu' });
assert.equal(result.lead_id, leadId);
assert.equal(result.stage, 'respondeu');
const events = db.prepare('SELECT stage, note, campaign_id FROM outreach_stage_events WHERE lead_id = ? ORDER BY id').all(leadId);
assert.equal(events.length, 1);
assert.equal(events[0].stage, 'respondeu');
assert.equal(events[0].note, 'cliente respondeu');
assert.equal(events[0].campaign_id, campaignId);
db.close();
});
test('setLeadCommercialStage com opt_out adiciona opt-out do telefone equivalente', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Opt', '5575999091780');
const result = setLeadCommercialStage(db, { leadId, stage: 'opt_out', note: 'solicitou remocao' });
assert.equal(result.stage, 'opt_out');
const opt = db.prepare("SELECT numero, motivo FROM outreach_opt_outs WHERE numero = '5575999091780'").get();
assert.ok(opt);
assert.equal(opt.motivo, 'solicitou remocao');
db.close();
});
test('setLeadCommercialStage transition de opt_out para outra etapa nao recria opt-out', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Opt Back', '557599091780');
setLeadCommercialStage(db, { leadId, stage: 'opt_out', note: 'solicitou' });
const updated = setLeadCommercialStage(db, { leadId, stage: 'interessado_gti_vendas', note: 'mudou de ideia' });
assert.equal(updated.stage, 'interessado_gti_vendas');
const opts = db.prepare('SELECT COUNT(*) total FROM outreach_opt_outs').get().total;
assert.equal(opts, 1);
db.close();
});
test('setLeadCommercialStage armazena historico completo ordenado por id ascendente', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Historico', '5511999990020');
const stages = ['fila_preparada', 'aprovado_para_envio', 'enviado', 'respondeu', 'interessado_gti_vendas', 'demonstracao_agendada'];
for (const stage of stages) {
setLeadCommercialStage(db, { leadId, stage, note: `etapa ${stage}` });
}
const events = db.prepare('SELECT stage FROM outreach_stage_events WHERE lead_id = ? ORDER BY id').all(leadId).map((r) => r.stage);
assert.deepEqual(events, stages);
const state = db.prepare('SELECT stage, lead_id FROM outreach_lead_states WHERE lead_id = ?').get(leadId);
assert.equal(state.stage, 'demonstracao_agendada');
db.close();
});
test('saveAppointment persiste agendamento com campos obrigatorios', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead App', '5511999990030');
const campaignId = listCampaigns(db)[0].id;
const app = saveAppointment(db, {
leadId,
campaignId,
kind: 'demonstracao',
participantName: 'Joao',
companyName: 'Empresa App',
preferredDay: '2026-08-05',
preferredTime: '14:30'
});
assert.equal(app.lead_id, leadId);
assert.equal(app.kind, 'demonstracao');
assert.equal(app.participant_name, 'Joao');
assert.equal(app.company_name, 'Empresa App');
assert.equal(app.preferred_day, '2026-08-05');
assert.equal(app.preferred_time, '14:30');
const stored = db.prepare('SELECT * FROM outreach_appointments WHERE id = ?').get(app.id);
assert.equal(stored.lead_id, leadId);
db.close();
});
test('saveAppointment cria appointment kind teste_g_delivery sem preferred_day/preferred_time', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Teste', '5511999990031');
const app = saveAppointment(db, { leadId, kind: 'teste_g_delivery' });
assert.equal(app.kind, 'teste_g_delivery');
assert.equal(app.participant_name, null);
assert.equal(app.company_name, null);
assert.equal(app.preferred_day, null);
assert.equal(app.preferred_time, null);
db.close();
});
test('listPendingReplies inclui campo produto do ultimo envio da campanha', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const campaign = listCampaigns(db)[0];
const leadId = insertLead(db, 'Lead Produto', '5511999990040');
setLeadCommercialStage(db, { leadId, campaignId: campaign.id, stage: 'enviado' });
setLeadCommercialStage(db, { leadId, campaignId: campaign.id, stage: 'respondeu' });
const pending = listPendingReplies(db);
assert.equal(pending.length, 1);
assert.equal(pending[0].produto, campaign.produto);
db.close();
});
test('setLeadCommercialStage com campaignId nulo e aceito', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead NoCamp', '5511999990050');
const result = setLeadCommercialStage(db, { leadId, stage: 'sem_interesse' });
assert.equal(result.stage, 'sem_interesse');
db.close();
});
test('historico de etapas retorna ordenado newest first via rota', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Order', '5511999990060');
setLeadCommercialStage(db, { leadId, stage: 'enviado' });
setLeadCommercialStage(db, { leadId, stage: 'respondeu' });
const events = db.prepare('SELECT stage FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId).map((r) => r.stage);
assert.deepEqual(events, ['respondeu', 'enviado']);
db.close();
});
test('F1: setLeadCommercialStage cancela itens da fila do lead em preview/approved/scheduled', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Fila', '5511999990080');
const campaign = listCampaigns(db)[0];
const variant = campaign.variants[0];
db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'msg', 'test', 'approved')`
).run(campaign.id, leadId, variant.id, campaign.produto, '5511999990080');
setLeadCommercialStage(db, { leadId, stage: 'sem_interesse' });
const queueItem = db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE lead_id = ?').get(leadId);
assert.equal(queueItem.status, 'cancelled');
assert.equal(queueItem.cancel_reason, 'lead_stage_moved');
db.close();
});
test('F2: saveAppointment rejeita preferredDay com formato invalido', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Date', '5511999990090');
assert.throws(
() => saveAppointment(db, {
leadId,
kind: 'demonstracao',
participantName: 'Joao',
companyName: 'Empresa',
preferredDay: '2026/08/05',
preferredTime: '14:30'
}),
/formato de data invalido/i
);
db.close();
});
test('F2: saveAppointment rejeita preferredTime com formato invalido', () => {
const db = openDb(':memory:');
seedCampaigns(db);
const leadId = insertLead(db, 'Lead Time', '5511999990091');
assert.throws(
() => saveAppointment(db, {
leadId,
kind: 'demonstracao',
participantName: 'Joao',
companyName: 'Empresa',
preferredDay: '2026-08-05',
preferredTime: '2:30 PM'
}),
/formato de hora invalido/i
);
db.close();
});
+491
View File
@@ -0,0 +1,491 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
REQUIRED_RFB_FILES,
parseLatestSnapshot,
validateRfbRawFiles,
downloadRfbDataset
} from '../src/extract/rfb-downloader.mjs';
const zip = Buffer.concat([Buffer.from('PK\x05\x06'), Buffer.alloc(18)]);
async function tempDir(t) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rfb-test-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
return dir;
}
function response(body, { status = 200, headers = {} } = {}) {
return new Response(body, { status, headers });
}
test('lista somente os 21 arquivos necessarios e escolhe o snapshot mais recente', () => {
assert.equal(REQUIRED_RFB_FILES.length, 21);
assert.deepEqual(REQUIRED_RFB_FILES.slice(0, 3), ['Municipios.zip', 'Empresas0.zip', 'Empresas1.zip']);
assert.equal(REQUIRED_RFB_FILES.at(-1), 'Estabelecimentos9.zip');
assert.equal(parseLatestSnapshot('<a href="2026-06-01/">x</a><a href="2026-07-12/">y</a>'), '2026-07-12');
});
test('ignora datas de modificacao que nao sao links de snapshot', () => {
const html = '<a href="2026-07-12/">2026-07-12/</a> 2026-07-20 18:11 -';
assert.equal(parseLatestSnapshot(html), '2026-07-12');
});
test('validacao rejeita conjunto incompleto, HTML e ZIP menor que o minimo', async (t) => {
const dir = await tempDir(t);
await fs.writeFile(path.join(dir, 'Municipios.zip'), Buffer.from('<html>erro</html>'));
await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /21 arquivos|ausentes|invalido/i);
});
test('validacao rejeita ZIP com assinatura inicial mas sem EOCD', async (t) => {
const dir = await tempDir(t);
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), Buffer.from('PK\x03\x04sem-diretorio-central'));
await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /EOCD/i);
});
test('validacao rejeita PK com EOCD falso estruturalmente inconsistente', async (t) => {
const dir = await tempDir(t);
const falseZip = Buffer.concat([
Buffer.from('PKjunkPK\x05\x06'),
Buffer.from([0, 0, 0, 0, 1, 0, 1, 0]),
Buffer.alloc(8),
Buffer.from([0, 0])
]);
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), falseZip);
await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /diretorio central|EOCD/i);
});
test('validacao rejeita sentinelas ZIP64 com erro claro', async (t) => {
const dir = await tempDir(t);
const zip64 = Buffer.from(zip);
zip64.writeUInt16LE(0xffff, 8);
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip64);
await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /ZIP64/i);
});
test('download descobre snapshot, baixa os necessarios e depois pula arquivos validos', async (t) => {
const dir = await tempDir(t);
let downloads = 0;
const mockFetch = async (url) => {
if (String(url).endsWith('/arquivos/')) {
return response('<a href="2026-07-12/">snapshot</a>', { headers: { 'content-type': 'text/html' } });
}
downloads += 1;
return response(zip, { headers: { 'content-length': String(zip.length) } });
};
const first = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://example.test/arquivos/',
fetch: mockFetch,
minZipBytes: 4
});
assert.equal(first.snapshot, '2026-07-12');
assert.equal(first.downloaded.length, 21);
assert.equal(downloads, 21);
await validateRfbRawFiles(dir, { minZipBytes: 4 });
const second = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://example.test/arquivos/',
fetch: mockFetch,
minZipBytes: 4
});
assert.equal(second.skipped.length, 21);
assert.equal(downloads, 21);
});
test('download rejeita HTTP 404 e resposta HTML sem promover o .part', async (t) => {
const dir = await tempDir(t);
const index = response('<a href="2026-07-12/">snapshot</a>');
let caseName = '404';
const mockFetch = async (url) => {
if (String(url).endsWith('/arquivos/')) return index.clone();
if (caseName === '404') return response('not found', { status: 404 });
return response('<html>bloqueado</html>', { headers: { 'content-length': '22' } });
};
await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }), /HTTP 404/);
caseName = 'html';
await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }), /ZIP|assinatura/i);
await assert.rejects(fs.stat(path.join(dir, 'Municipios.zip')));
});
test('download retoma .part com Range e preserva parcial ao cancelar', async (t) => {
const dir = await tempDir(t);
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: false, files: {},
partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', lastModified: null, totalSize: zip.length } }
}));
await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5));
let range;
let ifRange;
let cancelled = false;
const mockFetch = async (url, options = {}) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
range ||= options.headers?.Range || options.headers?.range;
ifRange ||= options.headers?.['If-Range'] || options.headers?.['if-range'];
if (String(url).endsWith('/Municipios.zip')) {
return response(zip.subarray(5), {
status: 206,
headers: { etag: '"v1"', 'content-range': `bytes 5-${zip.length - 1}/${zip.length}`, 'content-length': String(zip.length - 5) }
});
}
cancelled = true;
return response(zip, { headers: { 'content-length': String(zip.length) } });
};
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: mockFetch,
minZipBytes: 4,
isCancelled: () => cancelled
});
assert.equal(range, 'bytes=5-');
assert.equal(ifRange, '"v1"');
assert.equal(result.cancelled, true);
assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip);
});
test('resposta 206 divergente descarta parcial e reinicia uma vez do zero', async (t) => {
const dir = await tempDir(t);
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: false, files: {},
partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', totalSize: zip.length } }
}));
await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5));
let municipiosCalls = 0;
let cancelled = false;
const mockFetch = async (url, options = {}) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
if (String(url).endsWith('/Municipios.zip')) {
municipiosCalls += 1;
if (options.headers?.Range) return response(zip.subarray(5), {
status: 206,
headers: { etag: '"v2"', 'content-range': `bytes 5-${zip.length - 1}/${zip.length}`, 'content-length': String(zip.length - 5) }
});
return response(zip, { headers: { etag: '"v2"', 'content-length': String(zip.length) } });
}
cancelled = true;
return response(zip, { headers: { 'content-length': String(zip.length) } });
};
const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, isCancelled: () => cancelled });
assert.equal(result.cancelled, true);
assert.equal(municipiosCalls, 2);
assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip);
});
test('progresso de muitos chunks emite inicio e fim sem evento por chunk', async (t) => {
const dir = await tempDir(t);
const chunks = Array.from({ length: 100 }, (_, index) => zip.subarray(index % zip.length, (index % zip.length) + 1));
const body = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.enqueue(zip);
controller.close();
}
});
const total = chunks.length + zip.length;
let cancelled = false;
const events = [];
const mockFetch = async (url) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
if (String(url).endsWith('/Municipios.zip')) return response(body, { headers: { 'content-length': String(total) } });
cancelled = true;
return response(zip, { headers: { 'content-length': String(zip.length) } });
};
await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, isCancelled: () => cancelled, onProgress: (event) => events.push(event) });
const fileEvents = events.filter((event) => event.file === 'Municipios.zip');
assert.ok(fileEvents.length >= 2);
assert.ok(fileEvents.length <= 3, `eventos por arquivo: ${fileEvents.length}`);
assert.equal(fileEvents[0].bytes, 0);
assert.equal(fileEvents.at(-1).bytes, total);
});
test('ENOSPC preserva ZIP e manifesto anteriores byte a byte', async (t) => {
const dir = await tempDir(t);
const manifest = Buffer.from(JSON.stringify({ snapshot: '2026-06-01', complete: true, files: { 'Municipios.zip': zip.length } }));
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), manifest);
await fs.writeFile(path.join(dir, 'Municipios.zip'), zip);
const mockFetch = async (url) => response('<a href="2026-07-12/">snapshot</a>');
await assert.rejects(downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: mockFetch,
minZipBytes: 4,
minimumFreeBytes: 100,
statfs: async () => ({ bavail: 1, bsize: 1 })
}), (error) => error.code === 'ENOSPC');
assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip);
assert.deepEqual(await fs.readFile(path.join(dir, '.rfb-snapshot.json')), manifest);
});
test('download rejeita Content-Range que nao continua o .part', async (t) => {
const dir = await tempDir(t);
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: false, files: {},
partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', totalSize: zip.length } }
}));
await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5));
const mockFetch = async (url) => String(url).endsWith('/arquivos/')
? response('<a href="2026-07-12/">snapshot</a>')
: response(zip.subarray(5), {
status: 206,
headers: { etag: '"v1"', 'content-range': `bytes 4-${zip.length - 2}/${zip.length}`, 'content-length': String(zip.length - 5) }
});
await assert.rejects(
downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }),
/Content-Range/i
);
});
test('arquivos sem manifesto nao sao atribuidos ao snapshot mais recente', async (t) => {
const dir = await tempDir(t);
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip);
let downloads = 0;
const mockFetch = async (url) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
downloads += 1;
return response(zip, { headers: { 'content-length': String(zip.length) } });
};
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: mockFetch,
minZipBytes: 4
});
assert.equal(result.downloaded.length, REQUIRED_RFB_FILES.length);
assert.equal(downloads, REQUIRED_RFB_FILES.length);
});
test('manifesto objeto sem tamanho esperado nao autoriza pular ZIP final', async (t) => {
const dir = await tempDir(t);
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip);
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: true,
files: Object.fromEntries(REQUIRED_RFB_FILES.map((name) => [name, { etag: '"v1"' }]))
}));
let downloads = 0;
const mockFetch = async (url) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
downloads += 1;
return response(zip, { headers: { etag: '"v1"', 'content-length': String(zip.length) } });
};
await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 });
assert.equal(downloads, REQUIRED_RFB_FILES.length);
});
test('download interrompe corpo sem atividade e preserva o arquivo parcial', async (t) => {
const dir = await tempDir(t);
const stalledBody = new ReadableStream({
start(controller) {
controller.enqueue(zip.subarray(0, 4));
}
});
const mockFetch = async (url) => String(url).endsWith('/arquivos/')
? response('<a href="2026-07-12/">snapshot</a>')
: response(stalledBody, { headers: { 'content-length': String(zip.length) } });
await assert.rejects(
downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: mockFetch,
minZipBytes: 4,
idleTimeoutMs: 10
}),
/sem dados|timeout/i
);
assert.equal((await fs.stat(path.join(dir, 'Municipios.zip.part'))).size, 4);
});
test('snapshot completo pode ser reutilizado sem espaco extra e com indice offline', async (t) => {
const dir = await tempDir(t);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(dir, name), zip);
files[name] = { size: zip.length, etag: '"v1"', lastModified: null };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: true, files, partials: {}
}));
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://offline.test/arquivos/',
fetch: async () => { throw new Error('sem rede'); },
minZipBytes: 4,
minimumFreeBytes: Number.MAX_SAFE_INTEGER,
statfs: async () => ({ bavail: 0n, bsize: 1n })
});
assert.equal(result.snapshot, '2026-07-12');
assert.equal(result.offline, true);
assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length);
});
test('snapshot completo com validator faz HEAD e baixa novamente se o arquivo mudou', async (t) => {
const dir = await tempDir(t);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(dir, name), zip);
files[name] = { size: zip.length, etag: '"v1"', lastModified: null };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} }));
const methods = [];
const mockFetch = async (url, options = {}) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
methods.push(options.method || 'GET');
if (options.method === 'HEAD') return response(null, { headers: { etag: '"v2"', 'content-length': String(zip.length) } });
return response(zip, { headers: { etag: '"v2"', 'content-length': String(zip.length) } });
};
const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 });
assert.equal(methods.filter((method) => method === 'HEAD').length, 1);
assert.equal(result.downloaded.length, REQUIRED_RFB_FILES.length);
assert.equal(methods.filter((method) => method === 'GET').length, REQUIRED_RFB_FILES.length);
});
test('snapshot completo sem validator nao faz HEAD', async (t) => {
const dir = await tempDir(t);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(dir, name), zip);
files[name] = { size: zip.length, etag: null, lastModified: null };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} }));
let heads = 0;
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: async (url, options = {}) => {
if (options.method === 'HEAD') heads += 1;
return response('<a href="2026-07-12/">snapshot</a>');
},
minZipBytes: 4
});
assert.equal(heads, 0);
assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length);
});
test('falha de rede no HEAD usa snapshot local estruturalmente validado', async (t) => {
const dir = await tempDir(t);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(dir, name), zip);
files[name] = { size: zip.length, etag: '"v1"', lastModified: null };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} }));
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: async (url, options = {}) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
if (options.method === 'HEAD') throw new TypeError('rede indisponivel');
throw new Error('GET inesperado');
},
minZipBytes: 4
});
assert.equal(result.offline, true);
assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length);
});
test('retomada exige somente restante ou margem de extracao, nao 8 GiB', async (t) => {
const dir = await tempDir(t);
const oneGiB = 1024 * 1024 * 1024;
const partials = {};
for (const name of REQUIRED_RFB_FILES.slice(0, 7)) {
const part = path.join(dir, `${name}.part`);
await fs.writeFile(part, Buffer.alloc(1));
await fs.truncate(part, oneGiB);
partials[name] = { validator: '"v1"', etag: '"v1"', totalSize: oneGiB + 1 };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({
snapshot: '2026-07-12', complete: false,
files: {}, partials
}));
let cancelled = false;
const result = await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: async (url) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
cancelled = true;
return response(zip, { headers: { 'content-length': String(zip.length) } });
},
minZipBytes: 4,
isCancelled: () => cancelled,
statfs: async () => ({ bavail: 3n * 1024n * 1024n * 1024n, bsize: 1n })
});
assert.equal(result.cancelled, true);
});
test('indice offline restaura backup completo quando o snapshot atual esta incompleto', async (t) => {
const dir = await tempDir(t);
const backup = path.join(dir, '.rfb-backup');
await fs.mkdir(backup);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(backup, name), zip);
files[name] = { size: zip.length, etag: null, lastModified: null };
}
await fs.writeFile(path.join(backup, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} }));
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials: {} }));
await fs.writeFile(path.join(dir, 'Municipios.zip'), Buffer.from('quebrado'));
const result = await downloadRfbDataset({ rawDir: dir, fetch: async () => { throw new Error('offline'); }, minZipBytes: 4 });
assert.equal(result.offline, true);
assert.equal(result.snapshot, '2026-06-01');
assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip);
assert.ok((await fs.stat(path.join(dir, '.rfb-incomplete'))).isDirectory());
});
test('backup valido existente sobrevive a snapshot intermediario incompleto', async (t) => {
const dir = await tempDir(t);
const backup = path.join(dir, '.rfb-backup');
await fs.mkdir(backup);
const files = Object.fromEntries(REQUIRED_RFB_FILES.map((name) => [name, { size: zip.length }]));
for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(backup, name), zip);
const backupManifest = JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} });
await fs.writeFile(path.join(backup, '.rfb-snapshot.json'), backupManifest);
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-01', complete: false, files: {}, partials: {} }));
let cancelled = false;
await downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: async (url) => {
if (String(url).endsWith('/arquivos/')) return response('<a href="2026-07-12/">snapshot</a>');
cancelled = true;
return response(zip, { headers: { 'content-length': String(zip.length) } });
},
minZipBytes: 4,
isCancelled: () => cancelled
});
assert.equal(await fs.readFile(path.join(backup, '.rfb-snapshot.json'), 'utf8'), backupManifest);
});
test('snapshot A completo vira backup durante falha de B e e restaurado offline', async (t) => {
const dir = await tempDir(t);
const files = {};
for (const name of REQUIRED_RFB_FILES) {
await fs.writeFile(path.join(dir, name), zip);
files[name] = { size: zip.length, etag: null, lastModified: null };
}
await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} }));
await assert.rejects(downloadRfbDataset({
rawDir: dir,
indexUrl: 'https://x/arquivos/',
fetch: async (url) => String(url).endsWith('/arquivos/')
? response('<a href="2026-07-01/">snapshot</a>')
: response('<html>falha</html>', { headers: { 'content-length': '18' } }),
minZipBytes: 4
}), /ZIP|EOCD|assinatura/i);
assert.ok((await fs.stat(path.join(dir, '.rfb-backup'))).isDirectory());
const restored = await downloadRfbDataset({ rawDir: dir, fetch: async () => { throw new Error('offline'); }, minZipBytes: 4 });
assert.equal(restored.offline, true);
assert.equal(restored.snapshot, '2026-06-01');
assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip);
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { readZipLines } from '../src/extract/rfb-extractor.mjs';
function fakeChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit('exit', null, 'SIGTERM');
return child;
}
test('readZipLines rejeita leitor ZIP que encerra com codigo nao zero e inclui stderr limitado', async () => {
const child = fakeChild();
const zip = readZipLines({ name: 'fake', argsFor: () => [] }, 'ruim.zip', { spawn: () => child });
child.stderr.write('arquivo corrompido');
child.stdout.end();
child.emit('exit', 2, null);
await assert.rejects(zip.done, /codigo 2.*arquivo corrompido/i);
});
test('readZipLines rejeita erro de spawn mas nao trata kill intencional como corrupcao', async () => {
const failed = fakeChild();
const zipFailed = readZipLines({ name: 'missing', argsFor: () => [] }, 'x.zip', { spawn: () => failed });
failed.emit('error', new Error('ENOENT'));
await assert.rejects(zipFailed.done, /ENOENT/);
const cancelled = fakeChild();
const zipCancelled = readZipLines({ name: 'fake', argsFor: () => [] }, 'x.zip', { spawn: () => cancelled });
zipCancelled.close();
await zipCancelled.done;
});
test('readZipLines propaga erro do stream de saida do leitor', async () => {
const child = fakeChild();
const zip = readZipLines({ name: 'fake', argsFor: () => [] }, 'x.zip', { spawn: () => child });
child.stdout.emit('error', new Error('falha de leitura'));
await assert.rejects(zip.done, /falha de leitura/i);
});
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
const SEGMENTS = [
{ name: 'mercados_de_bairro', label: 'Mercados De Bairro', interest: 'ERP / sistema de gestao', queries: ['mercado'], cnaeCodes: ['4711302'], overpassTags: [], pains: ['estoque'] },
{ name: 'clinicas_medicas', label: 'Clinicas Medicas', interest: 'gestao de TI', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [], pains: ['agenda'] },
{ name: 'restaurantes', label: 'Restaurantes', interest: 'autoatendimento WhatsApp', queries: ['restaurante'], cnaeCodes: ['5611201'], overpassTags: [], pains: ['pedidos'] }
];
function captureParams() {
return {
sources: ['mock'],
uf: 'BA',
cities: ['Santo Antonio de Jesus'],
segments: SEGMENTS,
limits: { maxPerQuery: 10, queryLimit: 0 }
};
}
test('runCapture insere leads do mock e deduplica no rerun', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const first = await runCapture({ db, runId, params: captureParams() });
assert.equal(first.raw, 3);
assert.equal(first.new, 3);
assert.equal(first.dup, 0);
assert.equal(first.status, 'done');
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
assert.equal(run.status, 'done');
assert.equal(run.new_count, 3);
assert.ok(run.finished_at);
const lead = db.prepare("SELECT * FROM leads WHERE segmento = 'restaurantes'").get();
assert.equal(lead.uf, 'BA');
assert.equal(lead.cidade, 'Santo Antonio de Jesus');
assert.equal(lead.origem, 'mock');
assert.equal(lead.telefone_valido, 1);
assert.ok(lead.dedup_key.startsWith('phone:'));
assert.equal(lead.capture_run_id, runId);
// rerun: tudo duplicado
const runId2 = createCaptureRun(db, {});
const second = await runCapture({ db, runId: runId2, params: captureParams() });
assert.equal(second.new, 0);
assert.equal(second.dup, 3);
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 3);
db.close();
});
test('runCapture respeita cancelamento', async () => {
const db = openDb(':memory:');
const runId = createCaptureRun(db, {});
const result = await runCapture({
db,
runId,
params: captureParams(),
isCancelled: () => true
});
assert.equal(result.status, 'cancelled');
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'cancelled');
db.close();
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';
test('scheduler nao sobrepoe ciclos', async () => {
let active = 0;
let maxActive = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const timers = [];
const stop = startOutreachScheduler({
db: {}, whatsapp: {},
runBatch: async () => { active += 1; maxActive = Math.max(maxActive, active); await gate; active -= 1; },
setTimeoutFn: (fn) => { timers.push(fn); return { unref() {} }; },
setIntervalFn: (fn) => { timers.push(fn); return { unref() {} }; },
clearTimeoutFn: () => {}, clearIntervalFn: () => {}
});
timers[0]();
timers[1]();
await new Promise((resolve) => setTimeout(resolve, 1));
assert.equal(maxActive, 1);
release();
await stop();
});
test('stop aborta e aguarda o ciclo ativo', async () => {
let release;
let started;
const gate = new Promise((resolve) => { release = resolve; });
const active = new Promise((resolve) => { started = resolve; });
const timers = [];
let receivedSignal;
const stop = startOutreachScheduler({
db: {}, whatsapp: {},
runBatch: async ({ signal }) => { receivedSignal = signal; started(); await gate; },
setTimeoutFn: (fn) => { timers.push(fn); return { unref() {} }; },
setIntervalFn: (fn) => { timers.push(fn); return { unref() {} }; },
clearTimeoutFn: () => {}, clearIntervalFn: () => {}
});
timers[0]();
await active;
let stopped = false;
const stopping = stop().then(() => { stopped = true; });
await new Promise((resolve) => setTimeout(resolve, 1));
assert.equal(receivedSignal.aborted, true);
assert.equal(stopped, false);
release();
await stopping;
assert.equal(stopped, true);
});
test('scheduler consulta a fila a cada 60 segundos', async () => {
const intervals = [];
const stop = startOutreachScheduler({
db: {},
whatsapp: {},
runBatch: async () => {},
setTimeoutFn: () => ({ unref() {} }),
setIntervalFn: (_fn, ms) => {
intervals.push(ms);
return { unref() {} };
},
clearTimeoutFn: () => {},
clearIntervalFn: () => {}
});
assert.deepEqual(intervals, [60_000]);
await stop();
});
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
validatePhone, validateCnpj, validateEmpresa, dedupKey, extractDdd, segmentLabel,
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral
} from '../src/scoring.mjs';
import { equivalentPhoneNumbers, normalizePhone } from '../src/phone.mjs';
test('telefones equivalentes preservam o numero exato antes da variante brasileira', () => {
assert.equal(normalizePhone('+55 (75) 99909-1780'), '5575999091780');
assert.deepEqual(equivalentPhoneNumbers('5575999091780'), ['5575999091780', '557599091780']);
assert.deepEqual(equivalentPhoneNumbers('557599091780'), ['557599091780', '5575999091780']);
});
test('validatePhone aceita celular e fixo com 55', () => {
assert.equal(validatePhone('557193335451').valid, true);
assert.equal(validatePhone('5571993335451').valid, true);
});
test('validatePhone rejeita vazio, curto e ddd invalido', () => {
assert.deepEqual(validatePhone(''), { valid: false, motivo: 'telefone vazio' });
assert.equal(validatePhone('7199999').valid, false);
assert.equal(validatePhone('550599999999').valid, false);
assert.equal(validatePhone('5571000000000').valid, false);
});
test('validateCnpj valida digitos verificadores', () => {
// CNPJ real da base legada (PREMIUM EXPRESS)
assert.equal(validateCnpj('57557263000113'), true);
assert.equal(validateCnpj('57557263000114'), false);
assert.equal(validateCnpj('11111111111111'), false);
assert.equal(validateCnpj('123'), false);
});
test('validateEmpresa aplica regra critica do relatorio legado', () => {
assert.equal(validateEmpresa('PREMIUM EXPRESS').valid, true);
assert.deepEqual(validateEmpresa('57.563.316 JHONATAS SOUZA'), { valid: false, motivo: 'empresa comeca com numero' });
assert.deepEqual(validateEmpresa('-'), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.deepEqual(validateEmpresa(''), { valid: false, motivo: 'empresa vazia ou com lixo' });
assert.equal(validateEmpresa('...***...').valid, false);
});
test('scoreQualidade: lead completo com website bate 100', () => {
const result = scoreQualidade({
whatsapp: '557193335451',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: 'https://premiumexpress.com.br'
});
assert.equal(result.score, 100);
assert.equal(result.telefoneValido, true);
assert.equal(result.cnpjValido, true);
assert.equal(result.empresaValida, true);
});
test('scoreQualidade: sem telefone/website perde 30+5 pontos', () => {
const result = scoreQualidade({
whatsapp: '',
cnpj: '57557263000113',
empresa: 'PREMIUM EXPRESS',
cidade: 'Salvador',
segmento: 'restaurantes',
cnae: '5611203',
website: ''
});
assert.equal(result.score, 65);
assert.equal(result.telefoneValido, false);
});
test('scoreComercial: nicho alta aderencia + tudo valido bate 100', () => {
const score = scoreComercial({
whatsapp: '557193335451',
cnpj: '57557263000113',
website: 'https://premiumexpress.com.br',
email: 'contato@premiumexpress.com.br',
affinity: 'alta'
});
assert.equal(score, 100);
});
test('scoreComercial: nicho baixa aderencia sem website/email/whatsapp/cnpj fica so com os pontos do nicho', () => {
const score = scoreComercial({ whatsapp: '', cnpj: '', website: '', email: '', affinity: 'baixa' });
assert.equal(score, 20);
});
test('scoreGeral combina 60% comercial + 40% qualidade', () => {
assert.equal(scoreGeral(100, 100), 100);
assert.equal(scoreGeral(50, 50), 50);
assert.equal(scoreGeral(0, 100), 60);
assert.equal(scoreGeral(100, 0), 40);
});
test('classificarScoreGeral aplica as 5 faixas', () => {
assert.equal(classificarScoreGeral(95).id, 'excelente');
assert.equal(classificarScoreGeral(80).id, 'bom');
assert.equal(classificarScoreGeral(60).id, 'medio');
assert.equal(classificarScoreGeral(40).id, 'baixo');
assert.equal(classificarScoreGeral(10).id, 'muito_baixo');
});
test('dedupKey segue precedencia cnpj > phone > empresa+cidade', () => {
assert.equal(
dedupKey({ cnpj: '57557263000113', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'cnpj:57557263000113'
);
assert.equal(
dedupKey({ cnpj: '123', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
'phone:557193335451'
);
assert.equal(
dedupKey({ cnpj: '', whatsapp: '', empresa: 'Premium Express', cidade: 'Salvador' }),
'name:premium express:salvador'
);
});
test('extractDdd retira o ddd do numero normalizado', () => {
assert.equal(extractDdd('557193335451'), '71');
assert.equal(extractDdd('999'), '');
});
test('segmentLabel formata igual ao segmento_legivel legado', () => {
assert.equal(segmentLabel('lojas_de_calcados'), 'Lojas De Calcados');
assert.equal(segmentLabel('saloes_de_beleza'), 'Saloes De Beleza');
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
test('servidor inicia encerramentos em paralelo e limita esperas antes de fechar banco', async () => {
const source = await fs.readFile(path.join(root, 'scripts', 'servidor.mjs'), 'utf8');
assert.match(source, /recoverPendingOutreach\(db\)/);
assert.match(source, /pauseCampaignsAfterRestart\(db\)/);
assert.match(source, /recoverReservedCampaignQueue\(db\)/);
assert.match(source, /onMessageStatus:\s*\(event\)\s*=>\s*updateOutreachMessageStatus\(db, event\)/);
assert.ok(source.indexOf('recoverPendingOutreach(db)') < source.indexOf('const stopScheduler = startOutreachScheduler'));
assert.ok(source.indexOf('pauseCampaignsAfterRestart(db)') < source.indexOf('const stopScheduler = startOutreachScheduler'));
assert.ok(source.indexOf('recoverReservedCampaignQueue(db)') < source.indexOf('const stopScheduler = startOutreachScheduler'));
assert.match(source, /setTimeout\(\(\) => process\.exit\(1\), 30_000\)/);
const shutdown = source.slice(source.indexOf('async function shutdown()'));
const schedulerStart = shutdown.indexOf('stopScheduler()');
const jobsStart = shutdown.indexOf('jobs.close()');
const whatsappStart = shutdown.indexOf('whatsapp.close()');
const sharedWait = shutdown.indexOf('Promise.allSettled(shutdownTasks)');
assert.ok(schedulerStart >= 0 && schedulerStart < sharedWait);
assert.ok(jobsStart >= 0 && jobsStart < sharedWait);
assert.ok(whatsappStart >= 0 && whatsappStart < sharedWait);
assert.match(shutdown, /Promise\.race\([\s\S]*Promise\.allSettled\(shutdownTasks\)[\s\S]*20_000/);
assert.match(shutdown, /encerramento dos recursos excedeu o prazo/);
assert.ok(sharedWait < shutdown.indexOf('recoverOrphans(db, log)'));
assert.ok(shutdown.indexOf('recoverOrphans(db, log)') < shutdown.indexOf('server.close(resolve)'));
assert.ok(shutdown.indexOf('server.close(resolve)') < shutdown.indexOf('db.close()'));
assert.match(shutdown, /encerramento HTTP excedeu o prazo/);
});
test('UI distingue resultado desconhecido de falha', async () => {
const source = await fs.readFile(path.join(root, 'public', 'js', 'app.js'), 'utf8');
assert.match(source, /desconhecido[^\n]+(?:Resultado desconhecido|Confirmação pendente)/);
assert.match(source, /aceito[^\n]+Aguardando confirmação/);
assert.match(source, /entregue[^\n]+Entregue/);
assert.match(source, /refresh-qr/);
assert.match(source, /persistencia_incerta[\s\S]+?aviso/);
});
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, ensureDefaultAdmin } from '../src/db.mjs';
import { createAppServer } from '../src/server/index.mjs';
test('createAppServer injeta o mesmo manager nas rotas WhatsApp e outreach', async (t) => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
let connects = 0;
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true, qrExpiresAt: null, error: null }),
getQr: () => null,
connect: async () => { connects += 1; },
disconnect: async () => ({ state: 'STOPPED' }),
sendText: async () => ({ messageId: 'id' })
};
const jobs = { subscribe: () => null, activeJobs: () => [] };
const server = createAppServer({ db, jobs, whatsapp });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
db.close();
});
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin' })
});
assert.equal(login.headers.get('cache-control'), 'no-store');
const cookie = login.headers.get('set-cookie').split(';')[0];
const status = await fetch(`${base}/api/outreach/status`, { headers: { cookie } }).then((response) => response.json());
assert.equal(status.whatsapp.state, 'READY');
const campaignsResponse = await fetch(`${base}/api/campaigns`, { headers: { cookie } });
assert.equal(campaignsResponse.status, 200);
const campaigns = await campaignsResponse.json();
assert.deepEqual(campaigns.campaigns.map((campaign) => campaign.code), [
'gdelivery-food', 'gti-food', 'gti-retail'
]);
const connect = await fetch(`${base}/api/whatsapp/connect`, { method: 'POST', headers: { cookie } });
assert.equal(connect.status, 202);
assert.equal(connects, 1);
const qr = await fetch(`${base}/api/whatsapp/qr`, { headers: { cookie } });
assert.equal(qr.headers.get('pragma'), 'no-cache');
assert.equal(qr.headers.get('expires'), '0');
});
test('erro global 500 e generico para cliente e detalhado somente no log', async (t) => {
const db = openDb(':memory:');
ensureDefaultAdmin(db);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score)
VALUES ('global-error', 'Hamburgueria Erro', '5511999990001', 'hamburguerias', 1, 100)`
).run();
const secret = 'GLOBAL-SENTINEL-SECRET';
db.exec(`
CREATE TRIGGER global_sentinel_failure
BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, '${secret}'); END;
`);
const logs = [];
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true }),
getQr: () => null,
checkNumber: async (number) => ({ registered: true, numero: number, numero_resolvido: number }),
sendText: async () => { throw new Error('sendText nao deveria ser chamado'); },
connect: async () => {},
disconnect: async () => ({ state: 'STOPPED' })
};
const jobs = { subscribe: () => null, activeJobs: () => [] };
const server = createAppServer({ db, jobs, whatsapp, log: (message) => logs.push(message) });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
db.close();
});
const base = `http://127.0.0.1:${server.address().port}`;
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin' })
});
const cookie = login.headers.get('set-cookie').split(';')[0];
const response = await fetch(`${base}/api/campaigns/1/prepare`, {
method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{}'
});
assert.equal(response.status, 500);
const body = await response.json();
assert.deepEqual(body, { erro: 'erro interno do servidor' });
assert.doesNotMatch(JSON.stringify(body), new RegExp(secret, 'i'));
assert.match(logs.join('\n'), new RegExp(secret, 'i'));
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { streamJsonArray } from '../src/util/stream-json.mjs';
async function collect(filePath, options) {
const items = [];
for await (const item of streamJsonArray(filePath, options)) {
items.push(item);
}
return items;
}
async function withTempFile(content, fn) {
const file = path.join(os.tmpdir(), `gti-test-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
await fs.writeFile(file, content, 'utf8');
try {
return await fn(file);
} finally {
await fs.rm(file, { force: true });
}
}
test('streamJsonArray le array pretty-printed com chaves e aspas dentro de strings', async () => {
const data = [
{ nome: 'Empresa {teste} "aspas"', valor: 10, aninhado: { lista: [1, 2, { x: '}' }] } },
{ nome: 'Segunda \\ barra', vazio: null }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray funciona com JSON compacto e BOM', async () => {
const data = [{ a: 1 }, { b: '[{teste}]' }, { c: [{ d: 2 }] }];
await withTempFile('' + JSON.stringify(data), async (file) => {
const items = await collect(file);
assert.deepEqual(items, data);
});
});
test('streamJsonArray processa elementos maiores que o chunk', async () => {
const data = [
{ texto: 'x'.repeat(500), fim: 'ok' },
{ texto: 'y'.repeat(300) }
];
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
const items = await collect(file, { highWaterMark: 64 });
assert.deepEqual(items, data);
});
});
test('streamJsonArray retorna vazio para array vazio', async () => {
await withTempFile('[]', async (file) => {
const items = await collect(file);
assert.deepEqual(items, []);
});
});
test('streamJsonArray falha com erro claro em arquivo truncado', async () => {
await withTempFile('[\n { "a": 1 },\n { "b": 2', async (file) => {
await assert.rejects(() => collect(file), /JSON truncado/);
});
});
+991
View File
@@ -0,0 +1,991 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createWhatsAppManager, WHATSAPP_STATES } from '../src/whatsapp/manager.mjs';
async function fixture(options = {}) {
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'whatsapp-manager-'));
const sockets = [];
let loads = 0;
const module = {
DisconnectReason: { restartRequired: 515, loggedOut: 401, badSession: 500, forbidden: 403, multideviceMismatch: 411, connectionReplaced: 440 },
useMultiFileAuthState: async () => ({ state: { creds: {} }, saveCreds: async () => {} }),
default: (config) => {
const ev = new EventEmitter();
const socket = {
ev,
config,
sent: [],
onWhatsApp: async (jid) => [{ jid: `${String(jid).replace(/@s\.whatsapp\.net$/, '')}@s.whatsapp.net`, exists: true }],
sendMessage: async (...args) => {
socket.sent.push(args);
return { key: { id: `message-${socket.sent.length}` } };
},
end: () => {},
logout: async () => {}
};
sockets.push(socket);
return socket;
}
};
const manager = createWhatsAppManager({
authDir,
loadBaileys: async () => { loads += 1; return module; },
qrTtlMs: 20,
reconnectDelayMs: 0,
...options
});
return { authDir, manager, module, sockets, get loads() { return loads; } };
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
async function waitFor(predicate, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!await predicate()) {
if (Date.now() >= deadline) throw new Error('condicao nao atendida dentro do prazo');
await tick();
}
}
test('manager carrega Baileys somente ao conectar e evita conexoes duplicadas', async () => {
const f = await fixture();
assert.equal(f.loads, 0);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STOPPED);
const first = f.manager.connect();
const second = f.manager.connect();
assert.strictEqual(first, second);
await first;
assert.equal(f.loads, 1);
assert.equal(f.sockets.length, 1);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STARTING);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('manager bloqueia dump criptografico do libsignal sem silenciar outros logs', async () => {
const originalInfo = console.info;
const forwarded = [];
console.info = (...args) => forwarded.push(args);
try {
const f = await fixture();
console.info('Closing session:', { currentRatchet: { rootKey: Buffer.from('segredo') } });
console.info('log seguro', 1);
assert.deepEqual(forwarded, [['log seguro', 1]]);
await f.manager.close();
assert.notStrictEqual(console.info, originalInfo);
await fs.rm(f.authDir, { recursive: true, force: true });
} finally {
console.info = originalInfo;
}
});
test('QR fica somente em memoria, altera estado e expira', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { qr: 'segredo-qr' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.QR_REQUIRED);
assert.equal(f.manager.getQr(), 'segredo-qr');
assert.equal(JSON.stringify(f.manager.getStatus()).includes(f.authDir), false);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(f.manager.getQr(), null);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('connection.update abre sessao e sendText serializa envios para JID de usuario', async () => {
let active = 0;
let maxActive = 0;
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.sendMessage = async (...args) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
socket.sent.push(args);
return { key: { id: `id-${socket.sent.length}` } };
};
socket.ev.emit('connection.update', { connection: 'open' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.READY);
const results = await Promise.all([
f.manager.sendText({ number: '+55 (11) 99999-0001', message: 'um', externalKey: 'ext-1' }),
f.manager.sendText({ number: '5511999990002', message: 'dois', externalKey: 'ext-2' })
]);
assert.equal(maxActive, 1);
assert.deepEqual(results, [
{ messageId: 'id-1', resolvedNumber: '5511999990001', variant: 'exata' },
{ messageId: 'id-2', resolvedNumber: '5511999990002', variant: 'exata' }
]);
assert.deepEqual(socket.sent[0], ['5511999990001@s.whatsapp.net', { text: 'um' }]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText rejeita numero que nao existe no WhatsApp antes de reservar ou enviar', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [];
let reserved = false;
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
beforeSend: () => { reserved = true; }
}), /nao esta cadastrado no WhatsApp/i);
assert.equal(reserved, false);
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText consulta numero movel brasileiro com e sem nono digito', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const queries = [];
socket.onWhatsApp = async (...numbers) => {
queries.push(numbers);
return [{ jid: '551198765432@s.whatsapp.net', exists: true }];
};
await f.manager.sendText({ number: '5511998765432', message: 'oi' });
assert.deepEqual(queries[0], ['5511998765432', '551198765432']);
assert.equal(socket.sent[0][0], '551198765432@s.whatsapp.net');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText tenta adicionar nono digito apenas em movel brasileiro legado', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const queries = [];
socket.onWhatsApp = async (...numbers) => {
queries.push(numbers);
return numbers.length === 2 ? [{ jid: `${numbers[1]}@s.whatsapp.net`, exists: true }] : [];
};
await f.manager.sendText({ number: '551198765432', message: 'movel' });
assert.deepEqual(queries[0], ['551198765432', '5511998765432']);
await assert.rejects(f.manager.sendText({ number: '551134567890', message: 'fixo' }), /nao esta cadastrado/i);
assert.deepEqual(queries[1], ['551134567890']);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText prefere correspondencia exata quando as duas variantes existem', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [
{ jid: '551198765432@s.whatsapp.net', exists: true },
{ jid: '5511998765432@s.whatsapp.net', exists: true }
];
await f.manager.sendText({ number: '5511998765432', message: 'oi' });
assert.equal(socket.sent[0][0], '5511998765432@s.whatsapp.net');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber diagnostica variantes sem enviar mensagem', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async (...numbers) => [{ jid: `${numbers[1]}@s.whatsapp.net`, exists: true }];
const result = await f.manager.checkNumber('5575999091780');
assert.deepEqual(result, {
numero: '5575999091780',
status: 'confirmado',
registered: true,
resolvedNumber: '557599091780',
variant: 'sem_nono_digito',
candidatesChecked: 2
});
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber retorna metadados solicitados quando numero nao esta cadastrado', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [];
assert.deepEqual(await f.manager.checkNumber('+55 (11) 99999-0001'), {
numero: '5511999990001',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('manager ignora JID existente fora das variantes solicitadas e nao envia', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [{ jid: '5511888880000@s.whatsapp.net', exists: true }];
const checked = await f.manager.checkNumber('5511999990001');
let reserved = false;
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
beforeSend: () => { reserved = true; }
}), /nao esta cadastrado/i);
assert.deepEqual(checked, {
numero: '5511999990001',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
assert.equal(reserved, false);
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber limita lookup e observa rejeicao tardia do provedor', async () => {
const f = await fixture({ lookupTimeoutMs: 10 });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let rejectLookup;
socket.onWhatsApp = () => new Promise((resolve, reject) => { rejectLookup = reject; });
await assert.rejects(f.manager.checkNumber('5511999990001'), (error) => {
assert.equal(error.statusCode, 504);
assert.equal(error.code, 'WHATSAPP_LOOKUP_TIMEOUT');
assert.equal(error.numero, '5511999990001');
assert.equal(error.status, 'erro_temporario');
assert.equal(error.resolvedNumber, null);
assert.equal(error.variant, null);
assert.equal(error.candidatesChecked, 2);
return true;
});
rejectLookup(new Error('lookup rejeitado depois do timeout'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('messages.update publica ACK, entrega e leitura para persistencia', async () => {
const updates = [];
const f = await fixture({ onMessageStatus: (event) => updates.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.ev.emit('messages.update', [
{ key: { id: 'msg-1', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } },
{ key: { id: 'msg-2', remoteJid: '5511999990002@s.whatsapp.net', fromMe: true }, update: { status: 3 } },
{ key: { id: 'msg-3', remoteJid: '5511999990003@s.whatsapp.net', fromMe: true }, update: { status: 4 } }
]);
await waitFor(() => updates.length === 3);
assert.deepEqual(updates.map(({ messageId, status }) => ({ messageId, status })), [
{ messageId: 'msg-1', status: 'enviado' },
{ messageId: 'msg-2', status: 'entregue' },
{ messageId: 'msg-3', status: 'lido' }
]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('messages.upsert publica uma vez somente mensagens inbound novas de conversa individual', async () => {
const inbound = [];
const f = await fixture({ onInboundMessage: (event) => inbound.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.ev.emit('messages.upsert', {
type: 'append',
messages: [{ key: { id: 'history-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [
{ key: { id: 'inbound-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'self-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: true }, messageTimestamp: 1785326400 },
{ key: { id: 'group-1', remoteJid: '120363000000000000@g.us', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'broadcast-1', remoteJid: 'newsletter@broadcast', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'status-1', remoteJid: 'status@broadcast', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { remoteJid: '5575999091781@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }
]
});
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'inbound-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326460 }]
});
await waitFor(() => inbound.length === 1);
assert.deepEqual(inbound, [{
messageId: 'inbound-1',
jid: '5575999091780@s.whatsapp.net',
number: '5575999091780',
receivedAt: '2026-07-29T12:00:00.000Z'
}]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('falha ao persistir inbound nao escapa do EventEmitter nem gera resposta automatica', async () => {
const logs = [];
const f = await fixture({
onInboundMessage: () => { throw new Error('inbound sqlite fechado'); },
log: (message) => logs.push(message)
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
assert.doesNotThrow(() => socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'inbound-error', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
}));
await waitFor(() => logs.some((message) => message.includes('inbound sqlite fechado')));
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('erro sincrono ao persistir ACK nao escapa do EventEmitter', async () => {
const logs = [];
const f = await fixture({
onMessageStatus: () => { throw new Error('sqlite fechado'); },
log: (message) => logs.push(message)
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
assert.doesNotThrow(() => socket.ev.emit('messages.update', [
{ key: { id: 'sync-error', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } }
]));
await waitFor(() => logs.some((message) => message.includes('sqlite fechado')));
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('ACK recebido antes da persistencia do messageId e reaplicado depois', async () => {
let persisted = false;
const calls = [];
const f = await fixture({
onMessageStatus: (event) => {
calls.push(event);
return persisted;
}
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.sendMessage = async () => {
socket.ev.emit('messages.update', [
{ key: { id: 'early-1', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } }
]);
return { key: { id: 'early-1' } };
};
await f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => { persisted = true; return true; }
});
assert.equal(calls.length, 2);
assert.equal(calls[1].messageId, 'early-1');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('badSession generico preserva credenciais para recuperacao explicita', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.badSession } } }
});
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.ERROR);
assert.equal((await fs.stat(path.join(f.authDir, 'creds.json'))).isFile(), true);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('connect aguarda limpeza fatal da sessao anterior', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
let releaseRemoval;
const originalRm = fs.rm;
fs.rm = async (target, options) => {
if (path.resolve(target) !== path.resolve(f.authDir)) return originalRm(target, options);
await new Promise((resolve) => { releaseRemoval = resolve; });
return originalRm(target, options);
};
try {
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.loggedOut } } }
});
await waitFor(() => Boolean(releaseRemoval));
const reconnecting = f.manager.connect();
await tick();
assert.equal(f.sockets.length, 1);
releaseRemoval();
await reconnecting;
assert.equal(f.sockets.length, 2);
} finally {
fs.rm = originalRm;
}
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('refreshQr encerra socket preso e inicia novo pareamento', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { qr: 'qr-antigo' });
await f.manager.refreshQr();
assert.equal(f.sockets.length, 2);
f.sockets[1].ev.emit('connection.update', { qr: 'qr-novo' });
assert.equal(f.manager.getQr(), 'qr-novo');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('falha transitoria ao iniciar agenda nova tentativa', async () => {
let attempts = 0;
const f = await fixture({
loadBaileys: async () => {
attempts += 1;
if (attempts === 1) throw new Error('rede indisponivel');
return f.module;
}
});
await assert.rejects(f.manager.connect(), /rede indisponivel/);
await waitFor(() => f.sockets.length === 1);
assert.equal(attempts, 2);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('nova conexao manual reinicia o orçamento de reconexoes', async () => {
const f = await fixture({ maxReconnectAttempts: 1 });
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.sockets.length === 2);
f.sockets[1].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.ERROR);
await f.manager.connect();
f.sockets[2].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.sockets.length === 4);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('restartRequired reconecta e loggedOut remove somente o authDir', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.restartRequired } } }
});
await tick();
assert.equal(f.sockets.length, 2);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STARTING);
f.sockets[1].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.loggedOut } } }
});
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.DISCONNECTED);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
await f.manager.close();
});
test('disconnect com logout encerra socket, limpa auth e sendText exige READY', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await assert.rejects(
f.manager.sendText({ number: '5511999990001', message: 'oi' }),
/WhatsApp nao esta conectado/
);
await f.manager.connect();
let loggedOut = false;
f.sockets[0].logout = async () => { loggedOut = true; };
await f.manager.disconnect({ logout: true });
assert.equal(loggedOut, true);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
await f.manager.close();
});
test('falha no logout remoto ainda limpa sessao local e libera lock', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].logout = async () => { throw new Error('logout remoto falhou'); };
await assert.rejects(f.manager.disconnect({ logout: true }), /logout remoto falhou/);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
const second = createWhatsAppManager({ authDir: f.authDir, loadBaileys: async () => f.module });
await second.connect();
await second.close();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText executa callbacks dentro da fila e persiste afterSend antes do proximo envio', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
const events = [];
await Promise.all([
f.manager.sendText({ number: '5511999990001', message: 'um', beforeSend: () => events.push('before-1'), afterSend: () => events.push('after-1') }),
f.manager.sendText({ number: '5511999990002', message: 'dois', beforeSend: () => events.push('before-2'), afterSend: () => events.push('after-2') })
]);
assert.deepEqual(events, ['before-1', 'after-1', 'before-2', 'after-2']);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('pendingSends inclui operacoes enfileiradas e decrementa no finally', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
let release;
f.sockets[0].sendMessage = () => new Promise((resolve) => { release = () => resolve({ key: { id: 'queued-1' } }); });
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
assert.equal(f.manager.getStatus().pendingSends, 1);
await waitFor(() => Boolean(release));
release();
await sending;
assert.equal(f.manager.getStatus().pendingSends, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('disconnect aguarda envio em andamento antes de encerrar socket', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let releaseSend;
let ended = false;
socket.sendMessage = () => new Promise((resolve) => { releaseSend = () => resolve({ key: { id: 'drain-1' } }); });
socket.end = () => { ended = true; };
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
await waitFor(() => Boolean(releaseSend));
const disconnecting = f.manager.disconnect();
await tick();
assert.equal(ended, false);
releaseSend();
await sending;
await disconnecting;
assert.equal(ended, true);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('disconnect limita espera por sendMessage travado', async () => {
const f = await fixture({ sendDrainTimeoutMs: 15 });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let releaseSend;
socket.sendMessage = () => new Promise((resolve) => { releaseSend = () => resolve({ key: { id: 'late-1' } }); });
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
await waitFor(() => Boolean(releaseSend));
await assert.rejects(f.manager.disconnect(), /fila de envios.*prazo/i);
releaseSend();
await sending;
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText chama afterSend uma vez e preserva messageId quando a persistencia falha', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
let calls = 0;
await assert.rejects(
f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => {
calls += 1;
throw new Error('banco indisponivel');
}
}),
(error) => {
assert.match(error.message, /persistir.*message-1/i);
assert.equal(error.messageId, 'message-1');
assert.equal(error.deliverySucceeded, true);
assert.match(error.cause.message, /banco indisponivel/);
return true;
}
);
assert.equal(calls, 1);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText trata retorno false do afterSend como falha de persistencia', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => false
}), /persistir.*message-1/i);
assert.equal(f.manager.getStatus().pendingSends, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText informa falha do provedor ao afterSend exatamente uma vez', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
const providerError = new Error('provedor fora');
f.sockets[0].sendMessage = async () => { throw providerError; };
const calls = [];
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: (...args) => calls.push(args)
}), providerError);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], null);
assert.equal(calls[0][1], providerError);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText trata resposta resolvida sem messageId como entrega desconhecida', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
f.sockets[0].sendMessage = async () => ({ key: {} });
const finalized = [];
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'pode ter sido enviada',
beforeSend: () => 'reservation-without-id',
afterSend: (...args) => finalized.push(args)
}), (error) => {
assert.equal(error.code, 'WHATSAPP_MESSAGE_ID_MISSING');
assert.equal(error.deliveryUnknown, true);
return true;
});
assert.equal(finalized.length, 1);
assert.equal(finalized[0][0], null);
assert.equal(finalized[0][1].deliveryUnknown, true);
assert.equal(finalized[0][2], 'reservation-without-id');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText nao usa socket capturado se conexao muda durante beforeSend', async () => {
const f = await fixture();
await f.manager.connect();
const staleSocket = f.sockets[0];
staleSocket.ev.emit('connection.update', { connection: 'open' });
let staleSendAttempts = 0;
staleSocket.sendMessage = async () => {
staleSendAttempts += 1;
return { key: { id: 'stale-send' } };
};
let releaseBeforeSend;
let notifyBeforeSend;
const beforeSendStarted = new Promise((resolve) => { notifyBeforeSend = resolve; });
const beforeSendGate = new Promise((resolve) => { releaseBeforeSend = resolve; });
const finalized = [];
const sending = f.manager.sendText({
number: '5511999990001',
message: 'nao enviar em socket obsoleto',
beforeSend: async () => {
notifyBeforeSend();
await beforeSendGate;
return 'reservation-stale';
},
afterSend: (...args) => finalized.push(args)
});
await beforeSendStarted;
staleSocket.ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: 408 } } }
});
await waitFor(() => f.sockets.length === 2);
const replacementSocket = f.sockets[1];
replacementSocket.ev.emit('connection.update', { connection: 'open' });
releaseBeforeSend();
await assert.rejects(sending, (error) => {
assert.equal(error.code, 'WHATSAPP_SOCKET_STALE');
assert.equal(error.numero, '5511999990001');
assert.equal(error.resolvedNumber, '5511999990001');
assert.equal(error.variant, 'exata');
return true;
});
assert.equal(staleSendAttempts, 0);
assert.equal(replacementSocket.sent.length, 0);
assert.equal(finalized.length, 1);
assert.equal(finalized[0][0], null);
assert.equal(finalized[0][1].code, 'WHATSAPP_SOCKET_STALE');
assert.equal(finalized[0][2], 'reservation-stale');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText limita envio incerto, finaliza uma vez e invalida socket antes de liberar fila', async () => {
const events = [];
const f = await fixture({ messageTimeoutMs: 10 });
await f.manager.connect();
const uncertainSocket = f.sockets[0];
uncertainSocket.ev.emit('connection.update', { connection: 'open' });
let rejectLateSend;
let ended = false;
let lookupAttempts = 0;
let sendAttempts = 0;
uncertainSocket.onWhatsApp = async (jid) => {
lookupAttempts += 1;
return [{ jid: `${jid}@s.whatsapp.net`, exists: true }];
};
uncertainSocket.sendMessage = () => {
sendAttempts += 1;
return new Promise((resolve, reject) => { rejectLateSend = reject; });
};
uncertainSocket.end = () => { ended = true; };
const first = f.manager.sendText({
number: '5511999990001',
message: 'primeira',
beforeSend: () => { events.push('before'); return 'reservation-1'; },
afterSend: (result, error, context) => events.push(['after', result, error, context])
});
const second = f.manager.sendText({ number: '5511999990002', message: 'segunda' });
const secondOutcome = second.catch((error) => error);
await assert.rejects(first, (error) => {
assert.equal(error.statusCode, 504);
assert.equal(error.code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(error.deliveryUnknown, true);
assert.equal(error.numero, '5511999990001');
assert.equal(error.resolvedNumber, '5511999990001');
assert.equal(error.variant, 'exata');
return true;
});
const secondError = await secondOutcome;
assert.match(secondError.message, /nao esta conectado/i);
assert.equal(events[0], 'before');
assert.equal(events.length, 2);
assert.equal(events[1][0], 'after');
assert.equal(events[1][1], null);
assert.equal(events[1][2].code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(events[1][3], 'reservation-1');
assert.equal(lookupAttempts, 1);
assert.equal(sendAttempts, 1);
assert.equal(ended, true);
assert.equal(f.manager.getStatus().pendingSends, 0);
await waitFor(() => f.sockets.length === 2);
assert.notStrictEqual(f.sockets[1], uncertainSocket);
rejectLateSend(new Error('envio rejeitado depois do timeout'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('timeout tardio do socket anterior nao invalida socket substituto', async () => {
const f = await fixture({ messageTimeoutMs: 100 });
await f.manager.connect();
const staleSocket = f.sockets[0];
staleSocket.ev.emit('connection.update', { connection: 'open' });
let rejectLateSend;
let notifySendStarted;
let staleSendAttempts = 0;
const sendStarted = new Promise((resolve) => { notifySendStarted = resolve; });
staleSocket.sendMessage = () => {
staleSendAttempts += 1;
notifySendStarted();
return new Promise((resolve, reject) => { rejectLateSend = reject; });
};
const uncertainSend = f.manager.sendText({ number: '5511999990001', message: 'incerta' });
await sendStarted;
staleSocket.ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: 408 } } }
});
await waitFor(() => f.sockets.length === 2);
const replacementSocket = f.sockets[1];
replacementSocket.ev.emit('connection.update', { connection: 'open' });
await assert.rejects(uncertainSend, (error) => {
assert.equal(error.code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(error.deliveryUnknown, true);
return true;
});
assert.equal(staleSendAttempts, 1);
assert.equal(f.sockets.length, 2);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.READY);
const result = await f.manager.sendText({ number: '5511999990002', message: 'somente no socket B' });
assert.equal(result.messageId, 'message-1');
assert.deepEqual(replacementSocket.sent, [
['5511999990002@s.whatsapp.net', { text: 'somente no socket B' }]
]);
assert.equal(staleSendAttempts, 1);
rejectLateSend(new Error('socket A rejeitou tarde'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('dois managers nao adquirem o mesmo authDir e lock stale e recuperado', async () => {
const first = await fixture();
await first.manager.connect();
const second = createWhatsAppManager({ authDir: first.authDir, loadBaileys: async () => first.module });
await assert.rejects(second.connect(), /em uso por outro processo/);
await first.manager.close();
await second.connect();
await second.close();
const lockPath = path.join(path.dirname(first.authDir), `${path.basename(first.authDir)}.lock`);
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647 }));
const stale = createWhatsAppManager({ authDir: first.authDir, loadBaileys: async () => first.module });
await stale.connect();
await stale.close();
await fs.rm(first.authDir, { recursive: true, force: true });
});
test('duas aquisicoes concorrentes isolam lock stale sem remover lock do vencedor', async () => {
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'whatsapp-lock-race-'));
const lockPath = path.join(path.dirname(authDir), `${path.basename(authDir)}.lock`);
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647, token: 'stale' }));
const sockets = [];
const module = {
DisconnectReason: {},
useMultiFileAuthState: async () => ({ state: {}, saveCreds: async () => {} }),
default: () => {
const socket = { ev: new EventEmitter(), end() {} };
sockets.push(socket);
return socket;
}
};
const managers = [
createWhatsAppManager({ authDir, loadBaileys: async () => module }),
createWhatsAppManager({ authDir, loadBaileys: async () => module })
];
const originalRename = fs.rename;
let authRenames = 0;
fs.rename = async (from, to) => {
if (from !== lockPath) return originalRename(from, to);
authRenames += 1;
if (authRenames === 2) {
await waitFor(async () => {
try {
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
return holder.pid === process.pid;
} catch {
return false;
}
});
}
return originalRename(from, to);
};
let results;
try {
results = await Promise.allSettled(managers.map((manager) => manager.connect()));
} finally {
fs.rename = originalRename;
}
assert.equal(results.filter(({ status }) => status === 'fulfilled').length, 1);
const rejected = results.find(({ status }) => status === 'rejected');
assert.match(rejected.reason.message, /(?:em uso por outro processo|recuperacao.*(?:andamento|bloqueada))/i);
assert.equal(authRenames, 1);
assert.equal(sockets.length, 1);
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
assert.equal(holder.pid, process.pid);
const loser = managers[results.findIndex(({ status }) => status === 'rejected')];
await loser.close();
assert.equal((await fs.stat(lockPath)).isFile(), true);
const winner = managers[results.findIndex(({ status }) => status === 'fulfilled')];
await winner.close();
await assert.rejects(fs.stat(lockPath), { code: 'ENOENT' });
await fs.rm(authDir, { recursive: true, force: true });
});
test('recovery lock de processo morto e removido com seguranca', async () => {
const f = await fixture();
const lockPath = path.join(path.dirname(f.authDir), `${path.basename(f.authDir)}.lock`);
const recoveryPath = `${lockPath}.recovery`;
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647, token: 'stale' }));
await fs.writeFile(recoveryPath, JSON.stringify({ pid: 2147483646, token: 'recovery-owner' }));
await f.manager.connect();
assert.equal(f.sockets.length, 1);
await assert.rejects(fs.stat(recoveryPath), { code: 'ENOENT' });
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
await fs.rm(lockPath, { force: true });
await fs.rm(recoveryPath, { force: true });
});
test('recuperacao de lock stale usa quarantine por rename atomico', async () => {
const source = await fs.readFile(new URL('../src/whatsapp/manager.mjs', import.meta.url), 'utf8');
const acquireLock = source.slice(source.indexOf('const acquireLock'), source.indexOf('const releaseLock'));
assert.match(acquireLock, /fs\.rename\(lockPath,/);
assert.doesNotMatch(acquireLock, /fs\.rm\(lockPath/);
});
test('disconnect durante await de connect nao publica socket e fatal nao reconecta', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const f = await fixture({ loadBaileys: async () => { await gate; return f.module; } });
const connecting = f.manager.connect();
await f.manager.disconnect();
release();
await connecting;
assert.equal(f.sockets.length, 0);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STOPPED);
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.connectionReplaced } } } });
await tick();
assert.equal(f.sockets.length, 1);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.ERROR);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
+302
View File
@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLeadInserter, openDb } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerWhatsAppRoutes } from '../src/server/routes/whatsapp.mjs';
function setup({ qr = 'raw-qr', checkNumber } = {}) {
const db = openDb(':memory:');
const calls = [];
const whatsapp = {
getStatus: () => ({ state: 'QR_REQUIRED', connected: false, qrExpiresAt: '2026-07-28T12:00:00.000Z' }),
getQr: () => qr,
connect: async () => { calls.push('connect'); },
refreshQr: async () => { calls.push('refreshQr'); },
checkNumber: checkNumber || (async (numero) => ({
numero,
status: 'confirmado',
registered: true,
resolvedNumber: '557599091780',
variant: 'sem_nono_digito',
candidatesChecked: 2
})),
disconnect: async (options) => { calls.push(options); }
};
const router = createRouter();
registerWhatsAppRoutes(router, {
db,
whatsapp,
toDataURL: async (value) => `data:image/png;base64,${value === 'raw-qr' ? 'encoded' : 'bad'}`
});
return { db, router, calls };
}
async function invoke(router, method, pathname, context = {}) {
const responses = [];
const matched = router.match(method, pathname);
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
await matched.handler(null, null, {
params: matched.params,
body: null,
json: (status, data) => responses.push({ status, data }),
...context
});
return responses[0];
}
test('status pode ser consultado por qualquer usuario autenticado sem segredos', async () => {
const { router } = setup();
const response = await invoke(router, 'GET', '/api/whatsapp/status', { user: { role: 'comercial' } });
assert.equal(response.status, 200);
assert.equal(response.data.state, 'QR_REQUIRED');
assert.equal(JSON.stringify(response.data).includes('raw-qr'), false);
assert.equal(JSON.stringify(response.data).includes('auth'), false);
});
test('acoes WhatsApp rejeitam usuario nao-admin', async () => {
const { router, calls } = setup();
for (const [method, pathname] of [
['POST', '/api/whatsapp/connect'],
['POST', '/api/whatsapp/refresh-qr'],
['POST', '/api/whatsapp/check-number'],
['GET', '/api/whatsapp/qr'],
['POST', '/api/whatsapp/disconnect'],
['POST', '/api/whatsapp/logout']
]) {
const response = await invoke(router, method, pathname, { user: { role: 'comercial' } });
assert.equal(response.status, 403);
}
assert.deepEqual(calls, []);
});
test('admin diagnostica numero sem enviar mensagem', async () => {
const { db, router } = setup({
checkNumber: async () => ({
numero: '5511888880000',
status: 'erro_temporario',
registered: true,
resolvedNumber: '557599091780',
variant: 'sem_nono_digito',
candidatesChecked: 2
})
});
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '5575999091780' }
});
assert.equal(response.status, 200);
assert.equal(response.data.numero, '5575999091780');
assert.equal(response.data.status, 'confirmado');
assert.equal(response.data.registered, true);
assert.equal(response.data.resolvedNumber, '557599091780');
assert.equal(response.data.variant, 'sem_nono_digito');
assert.equal(response.data.candidatesChecked, 2);
assert.match(response.data.checked_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() },
{
numero: '5575999091780',
status: 'confirmado',
numero_resolvido: '557599091780',
variante: 'sem_nono_digito'
}
);
db.close();
});
test('diagnostico definitivo nao cadastrado retorna 422 e persiste o resultado', async () => {
const { db, router } = setup({
checkNumber: async (numero) => ({
numero,
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
})
});
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '5511999990001' }
});
assert.equal(response.status, 422);
assert.equal(response.data.status, 'nao_cadastrado');
assert.match(response.data.checked_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
assert.equal(db.prepare('SELECT status FROM whatsapp_number_verifications').get().status, 'nao_cadastrado');
db.close();
});
test('timeout de diagnostico retorna 504 e persiste erro temporario solicitado', async () => {
const { db, router } = setup({
checkNumber: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504,
code: 'WHATSAPP_LOOKUP_TIMEOUT',
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
}
});
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '5511999990001' }
});
assert.equal(response.status, 504);
assert.match(response.data.erro, /prazo/);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status, erro FROM whatsapp_number_verifications').get() },
{ numero: '5511999990001', status: 'erro_temporario', erro: 'consulta excedeu o prazo' }
);
db.close();
});
test('erro definitivo sem statusCode retorna 422 e ignora numero conflitante do provedor', async () => {
const { db, router } = setup({
checkNumber: async () => {
throw Object.assign(new Error('numero nao cadastrado'), {
numero: '5511888880000',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null
});
}
});
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '+55 (11) 99999-0001' }
});
assert.equal(response.status, 422);
assert.deepEqual(
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
{ numero: '5511999990001', status: 'nao_cadastrado' }
);
db.close();
});
test('check-number valida leadId opcional antes de consultar provedor ou cache', async () => {
let providerCalls = 0;
const { db, router } = setup({
checkNumber: async () => {
providerCalls += 1;
return { registered: true };
}
});
createLeadInserter(db)({
dedup_key: 'lead-check-1', empresa: 'Empresa', whatsapp: '5511999990001', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache nao deveria ser tocado'); END;
`);
for (const leadId of [true, '1', '01', '1e0', 1.5, 0, -1, NaN, 999]) {
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { leadId, numero: '5511999990001' }
});
assert.deepEqual(response, { status: 404, data: { erro: 'lead nao encontrado' } });
}
assert.equal(providerCalls, 0);
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 0);
db.close();
});
test('check-number usa numero do corpo mesmo com contexto de lead valido', async () => {
let checkedNumber;
const { db, router } = setup({
checkNumber: async (numero) => {
checkedNumber = numero;
return { registered: false, resolvedNumber: null, variant: null, candidatesChecked: 2 };
}
});
createLeadInserter(db)({
dedup_key: 'lead-check-2', empresa: 'Empresa', whatsapp: '5511888880000', cidade: 'Santos',
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { leadId: 1, numero: '5511999990001' }
});
assert.equal(response.status, 422);
assert.equal(checkedNumber, '5511999990001');
assert.equal(response.data.numero, '5511999990001');
assert.equal(db.prepare('SELECT numero FROM whatsapp_number_verifications').get().numero, '5511999990001');
db.close();
});
test('falha de cache no nao cadastrado preserva 422, JSON e timestamp nulo', async () => {
const { db, router } = setup({
checkNumber: async () => ({ registered: false, resolvedNumber: null, variant: null, candidatesChecked: 2 })
});
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '5511999990001' }
});
assert.equal(response.status, 422);
assert.equal(response.data.status, 'nao_cadastrado');
assert.equal(response.data.checked_at, null);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar|reenvie/i);
db.close();
});
test('falha de cache no timeout de lookup preserva 504, JSON e timestamp nulo', async () => {
const { db, router } = setup({
checkNumber: async () => {
throw Object.assign(new Error('consulta excedeu o prazo'), {
statusCode: 504,
status: 'erro_temporario',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
}
});
db.exec(`
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
`);
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
user: { role: 'admin' }, body: { numero: '5511999990001' }
});
assert.equal(response.status, 504);
assert.equal(response.data.status, 'erro_temporario');
assert.equal(response.data.checked_at, null);
assert.match(response.data.erro, /prazo/);
assert.match(response.data.aviso, /verificacao local/i);
assert.doesNotMatch(response.data.aviso, /tente.*enviar|reenvie/i);
db.close();
});
test('admin conecta, obtem QR vigente como data URL e encerra ou remove sessao', async () => {
const { router, calls } = setup();
const user = { role: 'admin' };
assert.equal((await invoke(router, 'POST', '/api/whatsapp/connect', { user })).status, 202);
assert.equal((await invoke(router, 'POST', '/api/whatsapp/refresh-qr', { user })).status, 202);
const qr = await invoke(router, 'GET', '/api/whatsapp/qr', { user });
assert.deepEqual(qr, {
status: 200,
data: { qr: 'data:image/png;base64,encoded', expiresAt: '2026-07-28T12:00:00.000Z' }
});
assert.equal((await invoke(router, 'POST', '/api/whatsapp/disconnect', { user })).status, 200);
assert.equal((await invoke(router, 'POST', '/api/whatsapp/logout', { user })).status, 200);
assert.deepEqual(calls, ['connect', 'refreshQr', { logout: false }, { logout: true }]);
});
test('QR expirado retorna conflito sem chamar gerador', async () => {
const { router } = setup({ qr: null });
const response = await invoke(router, 'GET', '/api/whatsapp/qr', { user: { role: 'admin' } });
assert.equal(response.status, 409);
assert.match(response.data.erro, /QR/);
});