chore: import maquina-comercial-pessoal history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -0,0 +1,89 @@
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 { createTelegramAlerter } from '../src/alerts/telegram.mjs';
import { loadDotEnv, telegramConfig } from '../src/env.mjs';
test('telegram alert skips when disabled or missing config', async () => {
const calls = [];
const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' });
assert.equal(calls.length, 0);
const missingConfigAlerter = createTelegramAlerter({ enabled: true, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await missingConfigAlerter.alert('qr', 'QR necessario'), { sent: false, reason: 'not_configured' });
assert.equal(calls.length, 0);
});
test('telegram alert sends message without leaking token to text', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl });
assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true });
assert.equal(calls.length, 1);
assert.match(calls[0].url, /sendMessage$/);
const payload = JSON.parse(calls[0].options.body);
assert.equal(payload.chat_id, '456');
assert.equal(payload.text, 'WhatsApp precisa de QR');
assert.doesNotMatch(payload.text, /SECRET/);
});
test('telegram alert deduplicates per key within cooldown', async () => {
let current = 1000;
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 });
assert.equal((await alerter.alert('same', 'A')).sent, true);
assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' });
current = 2501;
assert.equal((await alerter.alert('same', 'A later')).sent, true);
assert.equal(calls.length, 2);
});
test('telegram alert sends first message when clock starts at zero', async () => {
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => 0, cooldownMs: 1000 });
assert.equal((await alerter.alert('zero', 'First')).sent, true);
assert.deepEqual(await alerter.alert('zero', 'Second'), { sent: false, reason: 'cooldown' });
assert.equal(calls.length, 1);
});
test('telegram config reflects values loaded from dotenv', async () => {
const keys = ['TELEGRAM_ALERTS_ENABLED', 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'];
const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'telegram-env-'));
try {
for (const key of keys) delete process.env[key];
await fs.writeFile(
path.join(rootDir, '.env'),
'TELEGRAM_ALERTS_ENABLED=true\nTELEGRAM_BOT_TOKEN=123:SECRET\nTELEGRAM_CHAT_ID=456\n'
);
await loadDotEnv(rootDir);
assert.deepEqual(telegramConfig(), {
enabled: true,
botToken: '123:SECRET',
chatId: '456'
});
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
@@ -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();
});
@@ -0,0 +1,70 @@
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 { DatabaseSync } from 'node:sqlite';
import { createBackup, isCliEntryPoint } from '../scripts/backup-data.mjs';
test('backup creates data copy and keeps configured retention', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-'));
const dataDir = path.join(tmp, 'data');
const backupsDir = path.join(dataDir, 'backups');
const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth');
await fs.mkdir(authDir, { recursive: true });
await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-01'), { recursive: true });
await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-02'), { recursive: true });
await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db');
await fs.writeFile(path.join(authDir, 'session.json'), 'auth');
try {
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
assert.match(result.file, /maquina-pessoal-2026-08-03$/);
assert.equal(await fs.readFile(path.join(result.file, 'maquina-pessoal.db'), 'utf8'), 'db');
assert.equal(await fs.readFile(path.join(result.file, 'whatsapp-auth', 'session.json'), 'utf8'), 'auth');
assert.deepEqual(await fs.readdir(backupsDir), ['maquina-pessoal-2026-08-03']);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
test('CLI guard matches Windows file URLs from argv paths', () => {
assert.equal(
isCliEntryPoint('file:///D:/PROJETOS/COMERCIAL%20GTI/MAQUINA%20COMERCIAL%20PESSOAL/scripts/backup-data.mjs', 'D:\\PROJETOS\\COMERCIAL GTI\\MAQUINA COMERCIAL PESSOAL\\scripts\\backup-data.mjs'),
true
);
});
test('backup captures committed WAL data while source database remains open', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-wal-'));
const dataDir = path.join(tmp, 'data');
const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth');
const dbPath = path.join(dataDir, 'maquina-pessoal.db');
let db;
await fs.mkdir(authDir, { recursive: true });
await fs.writeFile(path.join(authDir, 'session.json'), 'auth');
try {
db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('CREATE TABLE records (id INTEGER PRIMARY KEY, name TEXT NOT NULL);');
db.exec("INSERT INTO records (name) VALUES ('committed in wal');");
await fs.stat(`${dbPath}-wal`);
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
const backupDb = new DatabaseSync(path.join(result.file, 'maquina-pessoal.db'), { readOnly: true });
try {
assert.deepEqual(backupDb.prepare('SELECT name FROM records').all().map((row) => row.name), ['committed in wal']);
} finally {
backupDb.close();
}
} finally {
db?.close();
await fs.rm(tmp, { recursive: true, force: true });
}
});
@@ -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);
});
});
});
@@ -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);
});
}
@@ -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);
}
});
@@ -0,0 +1,699 @@
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 = [];
const queueIds = [];
for (let i = 0; i < 11; i++) {
const lid = addLead(db, 100 + i);
leads.push(lid);
queueIds.push(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', queueId: queueIds[i] });
}
for (let i = 8; i < 10; i++) {
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
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 = [];
const queueIds = [];
for (let i = 0; i < 10; i++) {
const lid = addLead(db, 200 + i);
leads.push(lid);
queueIds.push(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', queueId: queueIds[i] });
}
addSend(db, { leadId: leads[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[9] }); // 1/10 = 10% → not > 10%
const result = evaluateCampaignHealth(db, campaign.id, now);
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();
});
// Mesmo bug do F1, so que em evaluateCampaignHealth (que tem suas proprias
// consultas de attemptsToday/failureCount, separadas de campaignMetrics):
// antes, o join usava lead_id+campaign_id, entao um envio automatico com
// erro em OUTRA campanha (mesmo lead) podia contar como falha desta
// campanha e dispara-la a pausar por high_failure_rate indevidamente.
test('F1b: evaluateCampaignHealth nao conta falha de envio de outra campanha (mesmo lead)', async () => {
const { evaluateCampaignHealth } = await metricsModule();
const { db, campaign } = setup();
const now = new Date('2026-07-29T12:00:00.000Z');
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
const campaign2 = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1 OFFSET 1').get();
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign2.id);
const leads = [];
const queueIds = [];
for (let i = 0; i < 10; i++) {
const lid = addLead(db, 8100 + i);
leads.push(lid);
queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 8100 + i }));
}
for (let i = 0; i < 10; i++) {
addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] });
}
// Mesmos leads, agora com uma fila (e envio com erro) numa SEGUNDA campanha.
// Sem o vinculo por outreach_send_id, essas falhas contaminariam a campanha 1.
for (let i = 0; i < 5; i++) {
const qid2 = addQueue(db, campaign2, { leadId: leads[i], status: 'sent', suffix: 8200 + i });
addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: qid2 });
}
const result = evaluateCampaignHealth(db, campaign.id, now);
assert.equal(result.pause, false, JSON.stringify(result));
assert.equal(result.reason, null);
db.close();
});
// --- F2 regression: negative/opt-out count uses COUNT(DISTINCT) ---
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();
});
@@ -0,0 +1,654 @@
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,
addSharedNumberOverride,
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 });
addSharedNumberOverride(db, '5575999091780', 'numeros equivalentes usados apenas para testar o dedup de verificacao');
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();
});
test('numero compartilhado por mais de um lead fica fora da fila ate liberacao manual', async () => {
const { db, campaign } = setupCampaign();
const sharedA = insertLead(db, { key: 'shared-a', empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', score: 100 });
const sharedB = insertLead(db, { key: 'shared-b', empresa: 'Contabilidade Cliente B', whatsapp: '5575999091780', score: 90 });
const solo = insertLead(db, { key: 'solo', whatsapp: '5575992220000', score: 80 });
for (const numero of ['5575999091780', '5575992220000']) confirmed(db, numero);
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')
});
let queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(queue.map(({ lead_id }) => lead_id), [solo]);
assert.equal(queue.some(({ lead_id }) => [sharedA, sharedB].includes(lead_id)), false);
addSharedNumberOverride(db, '5575999091780', 'confirmado contato direto');
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')
});
queue = listCampaignQueue(db, campaign.id);
assert.deepEqual(queue.map(({ lead_id }) => lead_id).sort((a, b) => a - b), [sharedA, sharedB, solo].sort((a, b) => a - b));
db.close();
});
// 557533334444 vs 5575933334444: equivalentPhoneNumbers so relaciona o segundo
// com o primeiro. Os dois precisam ficar fora da fila.
test('par 12/13 digitos assimetrico fica fora da fila nos dois sentidos', async () => {
const { db, campaign } = setupCampaign();
const doze = insertLead(db, { key: 'assim-12', whatsapp: '557533334444', score: 100 });
const treze = insertLead(db, { key: 'assim-13', whatsapp: '5575933334444', score: 99 });
const solo = insertLead(db, { key: 'assim-solo', whatsapp: '5575992220000', score: 80 });
for (const numero of ['557533334444', '5575933334444', '5575992220000']) confirmed(db, numero);
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), [solo]);
addSharedNumberOverride(db, '557533334444', 'contabilidade confirmada');
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).sort((a, b) => a - b),
[doze, treze, solo].sort((a, b) => a - b)
);
db.close();
});
test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila', async () => {
const { db, campaign } = setupCampaign();
const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 });
const eightDigits = insertLead(db, { key: 'eight-digits', whatsapp: '557599091780', score: 90 });
confirmed(db, '5575999091780');
confirmed(db, '557599091780');
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.equal(queue.some(({ lead_id }) => [nineDigits, eightDigits].includes(lead_id)), false);
db.close();
});
// Opt-out registrado na outra forma de um par 12/13 digitos assimetrico
// precisa excluir o lead da fila nos dois sentidos (mesma logica de chave
// canonica ja usada para numero compartilhado nesta consulta).
test('opt-out na outra forma de um par 12/13 digitos assimetrico exclui o lead da fila', async () => {
for (const [leadNumero, optOutNumero] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setupCampaign();
const lead = insertLead(db, { key: 'assim-optout', whatsapp: leadNumero, score: 100 });
confirmed(db, leadNumero);
addOptOut(db, optOutNumero, 'pediu para nao receber mais');
await prepareDailyQueue({
db,
whatsapp: noSendWhatsapp(async () => { throw new Error('numero possui cache'); }),
campaignId: campaign.id,
now: new Date('2026-07-29T14:00:00-03:00')
});
assert.equal(
listCampaignQueue(db, campaign.id).some((row) => row.lead_id === lead),
false,
`${leadNumero} / opt-out em ${optOutNumero}`
);
db.close();
}
});
@@ -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();
});
@@ -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);
});
@@ -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();
});
@@ -0,0 +1,741 @@
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 toasts = [];
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/outreach/shared-numbers') return { groups: [] };
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(message) { toasts.push(message); },
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, toasts };
}
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/outreach/shared-numbers') return { groups: [] };
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);
});
// Regressao: uma falha ao buscar /replies/pending nao pode deixar o
// placeholder "nenhuma pendente" no lugar — os dois ficam indistinguiveis e
// uma resposta de cliente passaria despercebida.
test('pending replies fetch failure shows an error state, not the empty-state placeholder', 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 origGet = app.context.API.get;
app.context.API.get = async (url) => {
app.gets.push(url);
if (url === '/api/campaigns/replies/pending') throw new Error('falha de rede');
if (url === '/api/campaigns') return { campaigns };
if (url.includes('/metrics')) return { metrics: {} };
return origGet(url);
};
vm.runInContext(await read('public/js/campanhas.js'), app.context);
await app.context.window.Campanhas.ativar();
const t = nodeText(app.element('pending-replies'));
assert.doesNotMatch(t, /nenhuma resposta pendente/i);
assert.match(t, /erro/i);
assert.ok(app.toasts.some((message) => /erro/i.test(message)), app.toasts.join(' | '));
});
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/);
});
// Regressao: uma falha ao buscar /metrics nao pode deixar o card de metricas
// mudo sem nenhum indicio de que algo deu errado.
test('metrics fetch failure shows an error state and toasts, not a silent no-op', 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);
app.context.API.get = async (url) => {
if (url.includes('/metrics')) throw new Error('falha de rede');
if (url === '/api/campaigns') return { campaigns };
if (url.includes('/queue')) return { queue: [] };
throw new Error(`GET inesperado: ${url}`);
};
const verFilaBtns = Array.from(app.element('campaign-cards').children[0].children)
.flatMap((row) => row.children || [])
.filter((node) => /Ver fila/.test(nodeText(node)));
await verFilaBtns[0].click();
const segmentoSelect = app.element('filtro-metrics-segmento');
await segmentoSelect.listeners.change();
const t = nodeText(app.element('campaign-metrics'));
assert.match(t, /erro/i);
assert.ok(app.toasts.some((message) => /erro ao carregar m.tricas/i.test(message)), app.toasts.join(' | '));
});
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);
}
});
@@ -0,0 +1,761 @@
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,
addSharedNumberOverride,
openDb,
recoverPendingOutreach,
removeSharedNumberOverride,
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)],
['numero virou compartilhado', 'shared_number', ({ db, item }) => db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-compartilhado', 'Outro lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(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();
});
// Cenario real do operador: numero compartilhado liberado em Configuracoes,
// item entra na fila e e agendado, operador reconsidera e clica "Bloquear de
// novo". Sem a revalidacao de ultima milha o envio ja agendado sairia mesmo
// assim, porque a checagem de compartilhamento so acontecia na selecao.
test('libera, agenda e rebloqueia numero compartilhado cancela o envio ja agendado', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [name, reblock, expected] of [
['override mantido', false, { tentados: 1, enviados: 1, erros: 0, pulados: 0 }],
['override removido antes do envio', true, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }]
]) {
const { db, campaign } = setup();
const item = addQueue(db, campaign, { suffix: 600 });
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('worker-600-b', 'Cliente da mesma contabilidade', ?, 'restaurantes', 1)`
).run(item.numero);
addSharedNumberOverride(db, item.numero, 'contabilidade confirmada');
if (reblock) assert.equal(removeSharedNumberOverride(db, item.numero), true, name);
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.deepEqual(summary, expected, name);
assert.equal(whatsapp.providerCalls, reblock ? 0 : 1, name);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
reblock ? { status: 'cancelled', cancel_reason: 'shared_number' } : { status: 'sent', cancel_reason: null },
name
);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, reblock ? 0 : 1, name);
db.close();
}
});
// Par assimetrico: 557533334444 (local '33334444', fora da faixa 6-9) e
// 5575933334444. equivalentPhoneNumbers so relaciona o segundo com o primeiro,
// entao antes da chave canonica o lead de 12 digitos passava batido aqui.
test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, outro] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-fila', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-outro', 'Lead com o mesmo telefone', ?, 'restaurantes', 1)`
).run(outro);
saveWhatsAppVerification(db, { numero: queued, 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', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
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: 0, pulados: 1 }, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'shared_number' },
queued
);
db.close();
}
});
// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam
// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma
// do numero podia nao bloquear o envio quando a fila tinha a outra forma.
test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => {
const { runDueCampaignQueue } = await workerModule();
for (const [queued, optedOutForm] of [
['557533334444', '5575933334444'],
['5575933334444', '557533334444']
]) {
const { db, campaign } = setup();
const leadId = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
VALUES ('assimetrico-optout', 'Lead na fila', ?, 'restaurantes', 1)`
).run(queued).lastInsertRowid);
saveWhatsAppVerification(db, { numero: queued, 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', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
addOptOut(db, optedOutForm, 'pediu para nao receber mais');
const whatsapp = whatsappFake();
const summary = await runDueCampaignQueue({
db,
whatsapp,
now: new Date('2026-07-29T12:00:00.000Z'),
log: () => {}
});
assert.equal(summary.enviados, 0, queued);
assert.equal(whatsapp.providerCalls, 0, queued);
assert.deepEqual(
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
{ status: 'cancelled', cancel_reason: 'opt_out' },
queued
);
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();
});
@@ -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();
});
@@ -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']);
});
@@ -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 });
}
});
@@ -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();
});
+864
View File
@@ -0,0 +1,864 @@
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, 19);
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('migracao v19 cria tabela de overrides de numero compartilhado', () => {
const db = openDb(':memory:');
assert.equal(db.prepare('SELECT MAX(version) v FROM schema_migrations').get().v, 19);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
assert.ok(tables.includes('outreach_shared_number_overrides'));
const columns = db.prepare('PRAGMA table_info(outreach_shared_number_overrides)').all().map((r) => r.name);
assert.deepEqual(columns, ['numero', 'motivo', 'created_at']);
db.close();
});
test('isNumberSharedAcrossLeads detecta duplicidade exata e por variante 8/9 digitos', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5575999091780' }));
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '557599091780' }));
insert(sampleLead({ dedup_key: 'solo', empresa: 'Empresa C', whatsapp: '5511999990001' }));
assert.equal(database.isNumberSharedAcrossLeads(db, '5575999091780'), true);
assert.equal(database.isNumberSharedAcrossLeads(db, '557599091780'), true);
assert.equal(database.isNumberSharedAcrossLeads(db, '5511999990001'), false);
const sharing = database.leadsSharingNumber(db, '5575999091780');
assert.deepEqual(sharing.map((l) => l.empresa).sort(), ['Empresa A', 'Empresa B']);
db.close();
});
test('override libera e remover override rebloqueia numero compartilhado', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5575999091780' }));
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5575999091780' }));
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), false);
const override = database.addSharedNumberOverride(db, '5575999091780', 'confirmado contato direto');
assert.equal(override.numero, '5575999091780');
assert.equal(override.motivo, 'confirmado contato direto');
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), true);
assert.equal(database.removeSharedNumberOverride(db, '5575999091780'), true);
assert.equal(database.isSharedNumberOverridden(db, '5575999091780'), false);
db.close();
});
test('listSharedNumberGroups agrupa por equivalencia e reflete override', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780' }));
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Contabilidade Cliente B', whatsapp: '557599091780' }));
insert(sampleLead({ dedup_key: 'solo', empresa: 'Empresa Unica', whatsapp: '5511999990001' }));
let groups = database.listSharedNumberGroups(db);
assert.equal(groups.length, 1);
assert.equal(groups[0].leads.length, 2);
assert.deepEqual(groups[0].leads.map((l) => l.empresa).sort(), ['Contabilidade Cliente A', 'Contabilidade Cliente B']);
assert.equal(groups[0].liberado, false);
database.addSharedNumberOverride(db, '5575999091780', 'revisado');
groups = database.listSharedNumberGroups(db);
assert.equal(groups[0].liberado, true);
assert.equal(groups[0].motivo, 'revisado');
db.close();
});
// equivalentPhoneNumbers nao e simetrica: 5575933334444 expande para
// 557533334444 (remove o nono digito sem condicao), mas 557533334444 nao volta
// para 5575933334444 (o local '33334444' esta fora da faixa 6-9). O par
// precisa ser detectado, agrupado e bloqueado nos dois sentidos.
test('par 12/13 digitos assimetrico compartilha numero nos dois sentidos', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'assim-a', empresa: 'Empresa A', whatsapp: '557533334444' }));
insert(sampleLead({ dedup_key: 'assim-b', empresa: 'Empresa B', whatsapp: '5575933334444' }));
insert(sampleLead({ dedup_key: 'assim-solo', empresa: 'Empresa Solo', whatsapp: '5511999990001' }));
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(database.isNumberSharedAcrossLeads(db, numero), true, numero);
assert.deepEqual(
database.leadsSharingNumber(db, numero).map((lead) => lead.empresa).sort(),
['Empresa A', 'Empresa B'],
numero
);
}
assert.equal(database.isNumberSharedAcrossLeads(db, '5511999990001'), false);
const groups = database.listSharedNumberGroups(db);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].leads.map((lead) => lead.empresa).sort(), ['Empresa A', 'Empresa B']);
// Envio manual bloqueado pelas duas formas, e liberado pelas duas assim que o
// operador revisa o grupo unico da tela de Configuracoes.
for (const numero of ['557533334444', '5575933334444']) {
assert.throws(
() => reserveOutreachSend(db, { numero, mensagem: 'oi' }),
(error) => error.statusCode === 409,
numero
);
}
database.addSharedNumberOverride(db, groups[0].numero, 'contabilidade confirmada');
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(database.isSharedNumberOverridden(db, numero), true, numero);
assert.ok(reserveOutreachSend(db, { numero, mensagem: 'oi' }) > 0, numero);
}
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;
DROP TABLE IF EXISTS outreach_shared_number_overrides;
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, 19);
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 TABLE IF EXISTS outreach_shared_number_overrides;
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, 19);
} 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('reserveOutreachSend bloqueia numero compartilhado sem override e libera com override', () => {
const db = openDb(':memory:');
const insert = createLeadInserter(db);
insert(sampleLead({ dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009' }));
insert(sampleLead({ dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009' }));
assert.throws(
() => reserveOutreachSend(db, { numero: '5511999990009', mensagem: 'oi' }),
(error) => error.statusCode === 409
);
database.addSharedNumberOverride(db, '5511999990009', 'liberado');
const sendId = reserveOutreachSend(db, { numero: '5511999990009', mensagem: 'oi' });
assert.ok(sendId > 0);
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();
db.prepare("INSERT INTO outreach_shared_number_overrides (numero, motivo) VALUES ('5511999990002', 'revisado')").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);
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_shared_number_overrides').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();
});
@@ -0,0 +1,142 @@
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();
}
});
// Antes, um erro no stream de escrita (disco cheio, pasta inacessivel) virava
// excecao nao capturada e derrubava o processo do job inteiro, sem nunca
// chegar ao catch de export-job.mjs; e o arquivo final podia ficar pela
// metade no lugar do arquivo definitivo. Forca EISDIR criando um diretorio
// no caminho .tmp esperado do CSV, no lugar de um disco cheio de verdade.
test('exportExcel rejeita (nao derruba o processo) quando o stream falha, e nao deixa arquivo pela metade', async () => {
const db = openDb(':memory:');
seedLead(db);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-falha-'));
try {
await fs.mkdir(path.join(dir, 'leads-excel.csv.tmp'));
await assert.rejects(() => exportExcel({ db, filtro: {}, dir }));
assert.equal(await fs.stat(path.join(dir, 'leads-excel.csv')).then(() => true, () => false), false);
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls')).then(() => true, () => false), false);
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls.tmp')).then(() => true, () => false), false);
} finally {
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();
});
@@ -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;
}
});
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { collect } from '../src/sources/google-places.mjs';
function fakeCtx() {
const logs = [];
return { emit: () => {}, log: (message) => logs.push(message), isCancelled: () => false, logs };
}
// Antes, uma falha na pagina 2/3 (comum: o next_page_token do Google ainda
// nao ativou apesar da espera de 2.5s entre paginas, ou uma falha transitoria
// de rede) fazia search() lancar e descartar TODOS os leads ja obtidos nas
// paginas anteriores bem-sucedidas, nao so a pagina que falhou.
test('falha na pagina 2 nao descarta os leads ja obtidos na pagina 1', async () => {
process.env.GOOGLE_PLACES_API_KEY = 'chave-teste';
const originalFetch = globalThis.fetch;
let textsearchCalls = 0;
globalThis.fetch = async (url) => {
const href = url.toString();
if (href.includes('textsearch')) {
textsearchCalls += 1;
if (textsearchCalls === 1) {
return new Response(JSON.stringify({
status: 'OK',
results: [{ place_id: 'p1', name: 'Pizzaria Um' }],
next_page_token: 'tok-pagina-2'
}));
}
// Pagina 2: falha transitoria (token ainda nao ativo, erro do provedor, etc.)
return new Response(JSON.stringify({ status: 'INVALID_REQUEST' }));
}
if (href.includes('details')) {
return new Response(JSON.stringify({ result: { place_id: 'p1', name: 'Pizzaria Um' } }));
}
throw new Error(`fetch inesperado: ${href}`);
};
try {
const ctx = fakeCtx();
const params = {
uf: 'BA',
cities: ['Salvador'],
segments: [{ name: 'restaurantes', queries: ['pizzaria'], interest: 'comida', pains: [] }],
limits: { maxPerQuery: 10, googlePages: 3 }
};
const batches = [];
for await (const batch of collect(params, ctx)) batches.push(batch);
assert.equal(batches.length, 1, JSON.stringify(batches));
assert.equal(batches[0].leads.length, 1);
assert.equal(batches[0].leads[0].company.name, 'Pizzaria Um');
assert.ok(ctx.logs.some((message) => /pagina 2/.test(message)), ctx.logs.join(' | '));
} finally {
globalThis.fetch = originalFetch;
delete process.env.GOOGLE_PLACES_API_KEY;
}
});
// Uma falha logo na primeira pagina (nada acumulado ainda) continua
// propagando e sendo tratada pelo chamador (loga e pula a query inteira) —
// esse caminho nao muda.
test('falha na primeira pagina ainda propaga e pula a query', async () => {
process.env.GOOGLE_PLACES_API_KEY = 'chave-teste';
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ status: 'INVALID_REQUEST' }));
try {
const ctx = fakeCtx();
const params = {
uf: 'BA',
cities: ['Salvador'],
segments: [{ name: 'restaurantes', queries: ['pizzaria'], interest: 'comida', pains: [] }],
limits: { maxPerQuery: 10, googlePages: 3 }
};
const batches = [];
for await (const batch of collect(params, ctx)) batches.push(batch);
assert.equal(batches.length, 0);
assert.ok(ctx.logs.some((message) => /falhou em/.test(message)), ctx.logs.join(' | '));
} finally {
globalThis.fetch = originalFetch;
delete process.env.GOOGLE_PLACES_API_KEY;
}
});
@@ -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();
});
@@ -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();
});
@@ -0,0 +1,217 @@
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);
});
// Cenario real do capture-job.mjs: main() chama io.done() apos o trabalho
// terminar (leads ja gravados pelo proprio filho), mas o finally (fechar
// lease/db) falha depois disso — main().catch escreve uma linha "error"
// explicita e sai com codigo != 0. O done ja reportado nao pode virar erro
// so por causa da falha de limpeza que veio depois dele.
test('erro explicito apos done (falha de limpeza) nao sobrescreve o done', 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 });
const jobId = jobs.startJob({ type: 'captura', script: 'fake', meta: { runId } });
const events = [];
jobs.subscribe(jobId, (event) => events.push(event));
child.stdout.write('{"t":"done","summary":{"new":42}}\n');
await new Promise((resolve) => setImmediate(resolve));
child.stdout.write('{"t":"error","message":"falha ao fechar conexao do banco"}\n');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(jobs.getJob(jobId).status, 'running');
child.emit('exit', 1, null);
child.emit('close', 1, null);
assert.equal(jobs.getJob(jobId).status, 'done');
assert.equal(events.at(-1).t, 'done');
assert.equal(events.at(-1).summary.new, 42);
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'done');
db.close();
});
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();
});
@@ -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/);
});
@@ -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'));
});
});
@@ -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;
});
@@ -0,0 +1,573 @@
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 manual bloqueia numero compartilhado por leads distintos ate liberacao', async () => {
const { db, router } = setup();
const insert = createLeadInserter(db);
insert({
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
insert({
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const blocked = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990009', mensagem: 'Oi' }
});
assert.equal(blocked.status, 409);
await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'confirmado contato direto' }, user: { role: 'admin' }
});
const allowed = await invoke(router, 'POST', '/api/outreach/enviar-manual', {
body: { numero: '5511999990009', mensagem: 'Oi' }
});
assert.equal(allowed.status, 202);
db.close();
});
test('rotas de numeros compartilhados listam e restringem liberacao a admin', async () => {
const { db, router } = setup();
const insert = createLeadInserter(db);
insert({
dedup_key: 'shared-a', empresa: 'Empresa A', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
insert({
dedup_key: 'shared-b', empresa: 'Empresa B', whatsapp: '5511999990009', cidade: 'Salvador',
produtos_json: '[]', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
});
const listed = await invoke(router, 'GET', '/api/outreach/shared-numbers');
assert.equal(listed.data.groups.length, 1);
assert.equal(listed.data.groups[0].leads.length, 2);
const denied = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'teste' }, user: { role: 'comercial' }
});
assert.equal(denied.status, 403);
const missingMotivo = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: {}, user: { role: 'admin' }
});
assert.equal(missingMotivo.status, 400);
const created = await invoke(router, 'POST', '/api/outreach/shared-numbers/5511999990009/liberar', {
body: { motivo: 'confirmado' }, user: { role: 'admin' }
});
assert.equal(created.status, 201);
const removeDenied = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'comercial' } });
assert.equal(removeDenied.status, 403);
const removed = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
assert.equal(removed.status, 200);
const removedAgain = await invoke(router, 'DELETE', '/api/outreach/shared-numbers/5511999990009/liberar', { user: { role: 'admin' } });
assert.equal(removedAgain.status, 404);
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();
});
@@ -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();
});
@@ -0,0 +1,363 @@
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();
});
// equivalentPhoneNumbers nao e simetrica para um par 12/13 digitos fora da
// faixa 6-9 (ver phone.mjs). addOptOut/isOptedOut usavam essa expansao
// diretamente: bloquear numa forma do numero podia nao bloquear a outra forma
// do mesmo numero, deixando passar um envio para quem pediu para nao receber.
test('opt-out bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
const { openDb, addOptOut, isOptedOut, removeOptOut, reserveOutreachSend } = await import('../src/db.mjs');
const db = openDb(':memory:');
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-optout', 'Empresa Assimetrica', '557533334444', 1)`
).run();
addOptOut(db, '5575933334444', 'pedido do contato');
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(isOptedOut(db, numero), true, numero);
assert.throws(
() => reserveOutreachSend(db, { numero, mensagem: 'oi' }),
(error) => error.statusCode === 409,
numero
);
}
assert.equal(removeOptOut(db, '557533334444'), true);
for (const numero of ['557533334444', '5575933334444']) {
assert.equal(isOptedOut(db, numero), false, numero);
}
db.close();
});
// Number(...) || 4 tratava prioridade=0 (ou texto nao numerico) como P4,
// ja que 0/NaN sao falsy em JS — um filtro invalido devolvia leads de P4 em
// vez de nao bater com nada.
test('filtro de prioridade invalido nao e silenciosamente tratado como P4', async () => {
const { openDb } = await import('../src/db.mjs');
const { listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, prioridade, telefone_valido)
VALUES ('p1', 'Empresa P1', '5511999990001', 1, 1)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, prioridade, telefone_valido)
VALUES ('p4', 'Empresa P4', '5511999990004', 4, 1)`
).run();
assert.deepEqual(listLeads(db, { prioridade: '0' }).rows.map((r) => r.empresa), []);
assert.deepEqual(listLeads(db, { prioridade: 'abc' }).rows.map((r) => r.empresa), []);
assert.deepEqual(listLeads(db, { prioridade: '4' }).rows.map((r) => r.empresa), ['Empresa P4']);
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();
});
test('listagem e detalhe marcam numero compartilhado por mais de um lead', async () => {
const { openDb, addSharedNumberOverride } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
const sharedA = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('shared-a', 'Contabilidade Cliente A', '5575999091780', 1)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('shared-b', 'Contabilidade Cliente B', '557599091780', 1)`
).run();
const solo = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('solo', 'Empresa Unica', '5511999990001', 1)`
).run();
const rows = listLeads(db).rows;
const shared = rows.find((row) => row.id === Number(sharedA.lastInsertRowid));
const unique = rows.find((row) => row.id === Number(solo.lastInsertRowid));
assert.equal(shared.numero_compartilhado, true);
assert.equal(shared.numero_compartilhado_total, 2);
assert.equal(shared.numero_compartilhado_liberado, false);
assert.equal(unique.numero_compartilhado, false);
assert.equal(unique.numero_compartilhado_total, 0);
const detail = getLead(db, Number(sharedA.lastInsertRowid));
assert.equal(detail.numero_compartilhado, true);
addSharedNumberOverride(db, '5575999091780', 'revisado');
const refreshed = getLead(db, Number(sharedA.lastInsertRowid));
assert.equal(refreshed.numero_compartilhado_liberado, true);
db.close();
});
// Par assimetrico de equivalentPhoneNumbers (557533334444 / 5575933334444):
// os dois lados precisam aparecer marcados na tela de tratamento.
test('listagem marca os dois lados de um par 12/13 digitos assimetrico', async () => {
const { openDb } = await import('../src/db.mjs');
const { getLead, listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
const doze = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-12', 'Empresa 12 digitos', '557533334444', 1)`
).run().lastInsertRowid);
const treze = Number(db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido)
VALUES ('assim-13', 'Empresa 13 digitos', '5575933334444', 1)`
).run().lastInsertRowid);
const rows = new Map(listLeads(db).rows.map((row) => [row.id, row]));
for (const id of [doze, treze]) {
assert.equal(rows.get(id).numero_compartilhado, true, String(id));
assert.equal(rows.get(id).numero_compartilhado_total, 2, String(id));
assert.equal(getLead(db, id).numero_compartilhado, true, String(id));
}
db.close();
});
// A versao anterior carregava TODOS os leads com telefone em JS a cada
// listagem. Agora o estado sai de subconsultas correlacionadas por linha: o
// plano precisa usar idx_leads_whatsapp e a listagem paginada nao pode custar
// proporcional ao tamanho da tabela.
test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira', async () => {
const { openDb } = await import('../src/db.mjs');
const { listLeads } = await import('../src/treatment/queries.mjs');
const db = openDb(':memory:');
db.exec(`
WITH RECURSIVE sequence(value) AS (
VALUES(1) UNION ALL SELECT value + 1 FROM sequence WHERE value < 60000
)
INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
SELECT 'ruido-' || value, 'Ruido ' || value, '5511' || printf('%08d', value), 1, 4, 0
FROM sequence;
`);
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-compartilhado-a', 'Compartilhado A', '5575999091780', 1, 1, 100)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-compartilhado-b', 'Compartilhado B', '557599091780', 1, 1, 99)`
).run();
db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score)
VALUES ('pagina-unico', 'Unico', '5575992220000', 1, 1, 98)`
).run();
// Conta TODAS as linhas que chegam ao JS durante a listagem: a implementacao
// antiga trazia os 60 mil leads com telefone para montar o indice em memoria.
const trace = { sql: null, args: null, rowsRead: 0 };
const tracedDb = new Proxy(db, {
get(target, property) {
if (property !== 'prepare') {
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
}
return (sql) => {
const statement = target.prepare(sql);
if (sql.includes('numero_compartilhado_total_raw')) trace.sql = sql;
return new Proxy(statement, {
get(stmt, name) {
if (name === 'all') {
return (...args) => {
const rows = stmt.all(...args);
trace.rowsRead += rows.length;
if (sql.includes('numero_compartilhado_total_raw')) trace.args = args;
return rows;
};
}
const value = stmt[name];
return typeof value === 'function' ? value.bind(stmt) : value;
}
});
};
}
});
const startedAt = performance.now();
const rows = listLeads(tracedDb, { pageSize: 3 }).rows;
const elapsedMs = performance.now() - startedAt;
assert.deepEqual(
rows.map((row) => [row.empresa, row.numero_compartilhado, row.numero_compartilhado_total]),
[
['Compartilhado A', true, 2],
['Compartilhado B', true, 2],
['Unico', false, 0]
]
);
assert.ok(
trace.rowsRead <= 10,
`listagem trouxe ${trace.rowsRead} linhas para o JS com pageSize 3 (base de 60003 leads)`
);
assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`);
// Prova estrutural na consulta real de hidratacao (a que traz as colunas de
// numero compartilhado): ela busca so os ids ja paginados (nao faz o
// LIMIT/OFFSET nem o sort ela mesma — isso acontece antes, na consulta so-id
// — e a subconsulta correlacionada procura os irmaos pelo indice do
// telefone.
assert.ok(trace.sql, 'consulta de hidratacao nao foi observada');
assert.match(trace.sql, /WHERE leads\.id IN \(/);
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
.map((row) => row.detail).join('\n');
assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan);
assert.doesNotMatch(plan, /SCAN sharing/i, plan);
// A ordenacao padrao (prioridade:asc, score:desc, cidade:asc, empresa:asc)
// so casa parcialmente com o indice: os 60 mil leads de "ruido" empatam em
// prioridade=4/score=0, um bloco unico que o sort do SQLite materializa
// inteiro antes de aplicar LIMIT/OFFSET. A pagina 1 (os 3 leads especiais,
// prioridade=1) nao entra nesse bloco e por isso nao prova nada sobre ele;
// a pagina 2 cai direto nele. Se as subconsultas de numero compartilhado
// fossem reavaliadas para o bloco inteiro durante o sort (em vez de so para
// a pagina, apos paginar por id), essa consulta ficaria uma ordem de
// grandeza mais lenta aqui.
const page2StartedAt = performance.now();
const page2 = listLeads(db, { pageSize: 3, page: 2 }).rows;
const page2ElapsedMs = performance.now() - page2StartedAt;
assert.equal(page2.length, 3);
assert.ok(page2.every((row) => row.empresa.startsWith('Ruido')), page2.map((r) => r.empresa).join(','));
assert.ok(
page2ElapsedMs < elapsedMs * 5 + 200,
`pagina 2 (dentro do bloco de 60 mil empatados) levou ${page2ElapsedMs.toFixed(1)}ms `
+ `vs ${elapsedMs.toFixed(1)}ms na pagina 1 — sugere reavaliacao das subconsultas para o bloco inteiro`
);
db.close();
});
@@ -0,0 +1,670 @@
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/outreach/shared-numbers') return { groups: [] };
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('docker deployment files preserve persistent data and do not include secrets', async () => {
const dockerfile = await read('Dockerfile');
const compose = await read('docker-compose.yml');
const envExample = await read('.env.production.example');
const caddy = await read('Caddyfile.example');
assert.match(dockerfile, /FROM node:24/);
assert.match(compose, /restart: unless-stopped/);
assert.match(compose, /APP_HOST=0\.0\.0\.0/);
assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/);
assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/);
assert.match(caddy, /reverse_proxy app:3910/);
assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/);
});
test('oracle deployment runbook changes default admin password before public ingress', async () => {
const runbook = await read('docs/deployment/oracle-free-docker.md');
const firstPrivateStep = runbook.indexOf('Do not open `80/tcp`, `443/tcp`, or public DNS yet.');
const passwordChangeStep = runbook.indexOf('Open **Configuracoes > Alterar minha senha**');
const publicIngressStep = runbook.indexOf('open OCI ingress for `80/tcp` and `443/tcp` from the internet');
assert.ok(firstPrivateStep > -1);
assert.ok(passwordChangeStep > firstPrivateStep);
assert.ok(publicIngressStep > passwordChangeStep);
assert.match(runbook, /127\.0\.0\.1:3910:3910/);
});
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('env exposes APP_HOST with local-safe fallback', async () => {
const previous = process.env.APP_HOST;
process.env.APP_HOST = '0.0.0.0';
try {
const env = await import(`../src/env.mjs?host-test=${Date.now()}`);
assert.equal(env.APP_HOST, '0.0.0.0');
} finally {
if (previous === undefined) delete process.env.APP_HOST;
else process.env.APP_HOST = previous;
}
});
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\("#"\)/);
});
// pg-prev/pg-next nao usam comCarregando de proposito (ver comentario em
// bindEvents), entao nada impede dois cliques rapidos de disparar duas
// consultas concorrentes. Antes, se a resposta da pagina anterior chegasse
// DEPOIS da pagina mais recente, ela sobrescrevia a tabela com dados de uma
// pagina que o operador ja nao esta mais vendo.
test('clique duplo em pg-next nao deixa resposta atrasada sobrescrever a pagina mais recente', async () => {
const app = await executeApp();
const tick = () => new Promise((resolve) => setImmediate(resolve));
const page2 = deferred();
const page3 = deferred();
const rowFor = (label) => [{
id: 1, empresa: label, whatsapp: '', segmento: '', segmento_label: '',
prioridade: 1, score: 0, classificacao: 'bom', produto_principal: '', exported_at: null
}];
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: 1, com_whatsapp: 1, p1: 1, p2: 0, p3: 0, p4: 0,
potencial_pct: 100, cobertura_pct: 0, exportados: 0, disponiveis: 1,
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 === '/api/exports?limit=12') return { exports: [] };
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 (url.startsWith('/api/leads?')) {
const page = new URLSearchParams(url.split('?')[1]).get('page');
if (page === '1') return { rows: rowFor('Pagina 1'), total: 150, page: 1, pageSize: 50 };
if (page === '2') { await page2.promise; return { rows: rowFor('Pagina 2 (atrasada)'), total: 150, page: 2, pageSize: 50 }; }
if (page === '3') { await page3.promise; return { rows: rowFor('Pagina 3'), total: 150, page: 3, pageSize: 50 }; }
throw new Error(`pagina inesperada: ${page}`);
}
throw new Error(`GET inesperado no tratamento: ${url}`);
};
vm.runInContext(await read('public/js/tratamento.js'), app.context);
await app.context.window.Tratamento.ativar();
app.context.window.Tratamento.bindEvents();
app.element('pg-next').click(); // pagina 1 -> 2, fica presa em page2.promise
await tick();
app.element('pg-next').click(); // pagina 2 -> 3, fica presa em page3.promise
await tick();
// Resolve fora de ordem: a pagina 3 (mais recente) responde primeiro.
page3.resolve();
await tick();
await tick();
page2.resolve();
await tick();
await tick();
const t = nodeText(app.element('tabela-leads-tbody'));
assert.match(t, /Pagina 3/);
assert.doesNotMatch(t, /atrasada/);
});
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('tabela de tratamento sinaliza numero compartilhado por mais de um lead', async () => {
const app = await executeApp();
const rows = [
{
id: 1, empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', whatsapp_status: 'confirmado',
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 90, classificacao: 'bom',
produto_principal: '', afinidade: 'media',
numero_compartilhado: true, numero_compartilhado_total: 2, numero_compartilhado_liberado: false
},
{
id: 2, empresa: 'Empresa Unica', whatsapp: '5511999990001', whatsapp_status: 'confirmado',
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 80, classificacao: 'bom',
produto_principal: '', afinidade: 'media',
numero_compartilhado: false, numero_compartilhado_total: 0, numero_compartilhado_liberado: false
}
];
const tbody = await renderTreatment(app, rows);
const texts = tbody.children.map(nodeText);
assert.match(texts[0], /N[uú]mero compartilhado \(2 leads\)/);
assert.doesNotMatch(texts[1], /compartilhado/i);
});
test('secao de numeros compartilhados lista grupos e libera/bloqueia via API', async () => {
const app = await executeApp();
const groups = [{
numero: '5511999990009',
leads: [{ id: 1, empresa: 'Empresa A', cnpj: '' }, { id: 2, empresa: 'Empresa B', cnpj: '' }],
liberado: false,
motivo: null
}];
app.context.API.get = async (url) => {
if (url === '/api/outreach/shared-numbers') return { groups };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
throw new Error(`GET inesperado: ${url}`);
};
await app.element('btn-settings').click();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
const list = app.element('shared-numbers-list');
assert.match(nodeText(list), /5511999990009/);
assert.match(nodeText(list), /Empresa A/);
assert.match(nodeText(list), /Empresa B/);
const row = list.children[0];
const motivoInput = row.children.find((child) => child.tagName === 'INPUT');
const liberarButton = row.children.find((child) => child.tagName === 'BUTTON');
motivoInput.value = 'confirmado contato direto';
await liberarButton.click();
assert.deepEqual(JSON.parse(JSON.stringify(app.posts.at(-1))), {
url: '/api/outreach/shared-numbers/5511999990009/liberar',
body: { motivo: 'confirmado contato direto' }
});
});
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);
});
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import {
canonicalKeySql,
canonicalPhoneKey,
canonicalPhoneVariants,
canonicalVariantSql,
equivalentPhoneNumbers,
equivalentSql,
normalizePhone,
normalizedSql
} from '../src/phone.mjs';
// A regra 8/9 digitos existe duas vezes: em JS (equivalentPhoneNumbers /
// canonicalPhoneKey, usadas pelo envio manual e pela revalidacao de ultima
// milha) e em SQL (equivalentSql / canonicalKeySql, usadas pela selecao de
// candidatos da campanha e pela listagem de tratamento). Se alguem editar uma
// sem a outra, o bloqueio automatico e o manual passam a discordar em silencio.
// Estes testes rodam o mesmo conjunto de numeros pelos dois caminhos.
const NUMBERS = [
'5575999091780', // celular BR de 13 digitos com nono digito
'557599091780', // mesma linha em 8 digitos (local comeca com 9)
'5571993335451', // outro celular de 13 digitos
'557193335451', // fixo/8 digitos que comeca com 9 -> tem par
'557133334444', // fixo de 12 digitos, local comeca com 3 (sem par direto)
'5575933334444', // par assimetrico do numero abaixo
'557533334444', // 12 digitos, local '33334444' fora da faixa 6-9
'551140028922', // 12 digitos, local comeca com 4
'557561234567', // 12 digitos, local comeca com 6 (limite inferior da faixa)
'557551234567', // 12 digitos, local comeca com 5 (logo abaixo da faixa)
'557591234567', // 12 digitos, local comeca com 9 (limite superior da faixa)
'5511987654321', // 13 digitos comum
'11987654321', // sem DDI 55
'5575', // curto demais
'' // vazio
];
function sqlDb() {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE amostra (bruto TEXT)');
return db;
}
function sqlValues(db, numero) {
db.prepare('DELETE FROM amostra').run();
db.prepare('INSERT INTO amostra (bruto) VALUES (?)').run(numero);
return db.prepare(
`SELECT ${normalizedSql('bruto')} AS normalizado,
${equivalentSql('bruto')} AS equivalente,
${canonicalKeySql('bruto')} AS canonico,
${canonicalVariantSql('bruto')} AS variante
FROM amostra`
).get();
}
test('normalizedSql concorda com normalizePhone', () => {
const db = sqlDb();
for (const numero of [...NUMBERS, '+55 (75) 99909-1780', '55 71 9333-5451']) {
assert.equal(sqlValues(db, numero).normalizado, normalizePhone(numero), numero);
}
db.close();
});
test('equivalentSql concorda com equivalentPhoneNumbers em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const equivalentes = equivalentPhoneNumbers(numero);
// equivalentPhoneNumbers devolve [proprio, alternativo?]; equivalentSql
// devolve apenas o "outro lado" (ou o proprio quando nao ha alternativa).
const esperado = equivalentes.length > 1 ? equivalentes[1] : normalizePhone(numero);
assert.equal(sqlValues(db, numero).equivalente, esperado, numero);
}
db.close();
});
test('canonicalKeySql concorda com canonicalPhoneKey em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
assert.equal(sqlValues(db, numero).canonico, canonicalPhoneKey(numero), numero);
}
db.close();
});
test('canonicalKeySql + canonicalVariantSql cobrem o mesmo conjunto de canonicalPhoneVariants', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const { canonico, variante } = sqlValues(db, numero);
const sqlSet = [...new Set([canonico, variante].filter(Boolean))].sort();
const jsSet = [...new Set(canonicalPhoneVariants(numero))].sort();
assert.deepEqual(sqlSet, jsSet, numero);
}
db.close();
});
test('a chave canonica e simetrica onde equivalentPhoneNumbers nao e', () => {
// Caso concreto: A tem 12 digitos com local fora da faixa 6-9, B tem 13.
assert.deepEqual(equivalentPhoneNumbers('557533334444'), ['557533334444']);
assert.deepEqual(equivalentPhoneNumbers('5575933334444'), ['5575933334444', '557533334444']);
assert.equal(canonicalPhoneKey('557533334444'), '557533334444');
assert.equal(canonicalPhoneKey('5575933334444'), '557533334444');
// E os pares 8/9 digitos ja cobertos pela suite continuam batendo entre si.
assert.equal(canonicalPhoneKey('5575999091780'), canonicalPhoneKey('557599091780'));
assert.equal(canonicalPhoneKey('5571993335451'), canonicalPhoneKey('557193335451'));
// Simetria total do conjunto de casamento nas amostras brasileiras.
for (const numero of NUMBERS) {
for (const outro of canonicalPhoneVariants(numero)) {
assert.ok(
canonicalPhoneVariants(outro).includes(normalizePhone(numero)),
`${numero} <-> ${outro}`
);
}
}
// Numeros de linhas diferentes nao podem colidir.
assert.notEqual(canonicalPhoneKey('557533334444'), canonicalPhoneKey('557133334444'));
assert.notEqual(canonicalPhoneKey('5511987654321'), canonicalPhoneKey('551140028922'));
});
@@ -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();
});
@@ -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);
});
@@ -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);
});
@@ -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();
});
@@ -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();
});
@@ -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');
});
@@ -0,0 +1,116 @@
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';
import {
hasCampaignsPausedByRestart,
notifyInitialReconnectFailure,
notifyStartupState,
notifyWhatsAppTransition,
startInitialWhatsAppReconnect
} from '../src/alerts/runtime-alerts.mjs';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
test('startup alerts notify restart pause and QR requirement once', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyStartupState({
alerter,
whatsappStatus: 'QR_REQUIRED',
campaignsPausedByRestart: true
});
assert.deepEqual(alerts.map((alert) => alert.key), ['campaigns-paused-restart', 'whatsapp-qr']);
});
test('startup alert delivery failures are logged and do not reject startup', async () => {
const logs = [];
const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } };
await assert.doesNotReject(() => notifyStartupState({
alerter,
whatsappStatus: 'QR_REQUIRED',
campaignsPausedByRestart: true,
log: (message) => logs.push(message)
}));
assert.equal(logs.length, 2);
assert.match(logs[0], /telegram: alerta campaigns-paused-restart falhou/);
assert.match(logs[1], /telegram: alerta whatsapp-qr falhou/);
assert.doesNotMatch(logs.join('\n'), /SECRET|token/i);
});
test('initial WhatsApp reconnect failure alerts operator without real send', async () => {
const alerts = [];
const logs = [];
let resolveAlert;
const alerted = new Promise((resolve) => { resolveAlert = resolve; });
const alerter = {
alert: async (key, text) => {
alerts.push({ key, text });
resolveAlert();
}
};
const started = await startInitialWhatsAppReconnect({
hasSession: async () => true,
whatsapp: { connect: async () => { throw new Error('falha fake'); } },
alerter,
log: (message) => logs.push(message)
});
await alerted;
assert.equal(started, true);
assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-initial-reconnect-failed']);
assert.match(logs[0], /reconexao inicial falhou: falha fake/);
});
test('initial reconnect alert delivery failures are logged and do not reject', async () => {
const logs = [];
const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } };
await assert.doesNotReject(() => notifyInitialReconnectFailure({
alerter,
error: new Error('falha fake'),
log: (message) => logs.push(message)
}));
assert.match(logs[0], /reconexao inicial falhou: falha fake/);
assert.match(logs[1], /telegram: alerta whatsapp-initial-reconnect-failed falhou/);
assert.doesNotMatch(logs.join('\n'), /SECRET|token/i);
});
test('WhatsApp transition alerts when leaving READY and requiring QR', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyWhatsAppTransition({ alerter, previousState: 'READY', nextState: 'QR_REQUIRED' });
assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-left-ready', 'whatsapp-qr']);
});
test('restart pause detector only matches paused campaigns with server_restart reason', () => {
let sql;
const db = {
prepare: (statement) => {
sql = statement;
return { get: () => ({ found: 1 }) };
}
};
assert.equal(hasCampaignsPausedByRestart(db), true);
assert.match(sql, /status = 'paused'/);
assert.match(sql, /pause_reason = 'server_restart'/);
});
test('server startup does not await Telegram startup alert delivery before listen', async () => {
const source = await fs.readFile(path.join(root, 'scripts', 'servidor.mjs'), 'utf8');
assert.doesNotMatch(source, /await notifyStartupState/);
assert.match(source, /void notifyStartupState\(\{[^}]*log/s);
assert.ok(source.indexOf('notifyStartupState') < source.indexOf('server.listen'));
});
@@ -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'));
});
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { createAppServer } from '../src/server/index.mjs';
async function startTestServer() {
const db = openDb(':memory:');
const jobs = { subscribe: () => null, activeJobs: () => [] };
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true }),
sendText: async () => { throw new Error('sendText nao deveria ser chamado'); }
};
const server = createAppServer({ db, jobs, whatsapp });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const close = async () => {
await new Promise((resolve) => server.close(resolve));
db.close();
};
return { server, baseUrl, close };
}
test('health endpoint is public and does not expose secrets', async () => {
const { server, baseUrl, close } = await startTestServer();
try {
const res = await fetch(`${baseUrl}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.ok(body.app);
assert.ok(body.database);
assert.ok(body.whatsapp);
assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i);
} finally {
await close(server);
}
});
@@ -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/);
});
});
File diff suppressed because it is too large Load Diff
@@ -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/);
});