import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import * as database from '../src/db.mjs'; import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings, seedMessageTemplates, updateMessageTemplate, resetLeadsBase, reserveOutreachSend, finishOutreachSend } from '../src/db.mjs'; function sampleLead(overrides = {}) { return { lead_uid: 'MCP-TEST-1', dedup_key: 'cnpj:57557263000113', empresa: 'PREMIUM EXPRESS', legal_name: 'PREMIUM EXPRESS LTDA', whatsapp: '557193335451', ddd: '71', email: '', cidade: 'Salvador', uf: 'BA', segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: 'Autoatendimento para WhatsApp', cnpj: '57557263000113', cnae: '5611203', endereco: 'RUA X, 1', website: '', pains_json: '["atendimento"]', origem: 'cnpj-base', telefone_valido: 1, cnpj_valido: 1, empresa_valida: 1, score: 100, classificacao: 'bom', motivo_classificacao: 'ok', capture_run_id: null, captured_at: '2026-07-09T14:26:18.560Z', exported_at: null, ...overrides }; } test('openDb cria esquema e aplica migrations uma unica vez', () => { const db = openDb(':memory:'); const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v; assert.ok(version >= 1); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name); for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) { assert.ok(tables.includes(table), `tabela ${table} ausente`); } for (const table of [ 'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue', 'outreach_inbound_replies', 'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments' ]) { assert.ok(tables.includes(table), `tabela ${table} ausente`); } assert.equal(version, 18); for (const table of ['capture_runs', 'extractions']) { const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name); assert.ok(columns.includes('owner_token')); assert.ok(columns.includes('heartbeat_at')); } const exportColumns = db.prepare('PRAGMA table_info(exports)').all().map((row) => row.name); for (const column of ['pid', 'owner_token', 'heartbeat_at']) assert.ok(exportColumns.includes(column)); db.close(); }); test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-')); const dbPath = path.join(tempDir, 'legacy.sqlite'); let db; try { db = openDb(dbPath); db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('legacy-v14', 'Empresa Legada')").run(); db.prepare("INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, provider_message_id) VALUES (1, 'ERP', '5511999990001', 'mensagem legada', 'aceito', 'legacy-message')").run(); db.prepare("INSERT INTO outreach_opt_outs (numero, motivo) VALUES ('5511888880001', 'pedido legado')").run(); db.prepare("INSERT INTO whatsapp_number_verifications (numero, status, numero_resolvido, variante) VALUES ('5511999990001', 'confirmado', '5511999990001', 'exata')").run(); db.exec(` DROP INDEX idx_leads_campaign_order; DROP TABLE outreach_appointments; DROP TABLE outreach_stage_events; DROP TABLE outreach_lead_states; DROP TABLE outreach_inbound_replies; DROP TABLE outreach_campaign_queue; DROP TABLE outreach_campaign_variants; DROP TABLE outreach_campaigns; DELETE FROM schema_migrations WHERE version >= 15; `); db.close(); db = null; db = openDb(dbPath); assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18); assert.deepEqual({ ...db.prepare('SELECT dedup_key, empresa FROM leads').get() }, { dedup_key: 'legacy-v14', empresa: 'Empresa Legada' }); assert.deepEqual({ ...db.prepare('SELECT lead_id, produto, numero, mensagem_enviada, status, provider_message_id FROM outreach_sends').get() }, { lead_id: 1, produto: 'ERP', numero: '5511999990001', mensagem_enviada: 'mensagem legada', status: 'aceito', provider_message_id: 'legacy-message' }); assert.deepEqual({ ...db.prepare('SELECT numero, motivo FROM outreach_opt_outs').get() }, { numero: '5511888880001', motivo: 'pedido legado' }); assert.deepEqual({ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() }, { numero: '5511999990001', status: 'confirmado', numero_resolvido: '5511999990001', variante: 'exata' }); } finally { db?.close(); fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } }); test('v17 corrige trigger v16 ja aplicada sem bloquear agendamento legitimo', () => { const dbSource = fs.readFileSync(new URL('../src/db.mjs', import.meta.url), 'utf8'); const v16Sql = dbSource.split('// v16')[1].split('// v17')[0]; assert.match(v16Sql, /WHEN OLD\.status <> 'preview'\s+AND \(NEW\.variant_id <> OLD\.variant_id\s+OR NEW\.rendered_message IS NOT OLD\.rendered_message\s+OR NEW\.scheduled_at IS NOT OLD\.scheduled_at\)/); const db = openDb(':memory:'); try { const campaignId = Number(db.prepare( "INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('upgrade-v17', 'Upgrade v17', 'GTI Vendas', '[]')" ).run().lastInsertRowid); const variantId = Number(db.prepare( "INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'mensagem original')" ).run(campaignId).lastInsertRowid); const otherVariantId = Number(db.prepare( "INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'B', 'mensagem alternativa')" ).run(campaignId).lastInsertRowid); const leadId = Number(db.prepare( "INSERT INTO leads (dedup_key, empresa) VALUES ('upgrade-v17-lead', 'Upgrade Lead')" ).run().lastInsertRowid); const queueId = Number(db.prepare( `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (?, ?, ?, 'GTI Vendas', '5511999990017', 'restaurantes', 'mensagem original', 'upgrade-test', 'preview')` ).run(campaignId, leadId, variantId).lastInsertRowid); db.exec(` DELETE FROM schema_migrations WHERE version > 16; DROP TRIGGER outreach_campaign_queue_payload_immutable; CREATE TRIGGER outreach_campaign_queue_payload_immutable BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue WHEN OLD.status <> 'preview' AND (NEW.variant_id <> OLD.variant_id OR NEW.rendered_message IS NOT OLD.rendered_message OR NEW.scheduled_at IS NOT OLD.scheduled_at) BEGIN SELECT RAISE(ABORT, 'variant_id, rendered_message e scheduled_at sao imutaveis apos preview'); END; `); database.migrate(db); assert.throws( () => db.prepare( `UPDATE outreach_campaign_queue SET status = 'scheduled', variant_id = ?, rendered_message = ?, scheduled_at = ? WHERE id = ?` ).run(otherVariantId, 'mensagem adulterada', '2026-07-30T09:30:00.000Z', queueId), /imutav|immutable|constraint/i ); assert.deepEqual({ ...db.prepare( 'SELECT status, variant_id, rendered_message, scheduled_at FROM outreach_campaign_queue WHERE id = ?' ).get(queueId) }, { status: 'preview', variant_id: variantId, rendered_message: 'mensagem original', scheduled_at: null }); assert.doesNotThrow(() => db.prepare( "UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?" ).run('2026-07-30T09:30:00.000Z', queueId)); assert.equal(db.prepare('SELECT MAX(version) version FROM schema_migrations').get().version, 18); } finally { db.close(); } }); test('v15 aplica defaults, chaves estrangeiras, indices e checks operacionais', () => { const db = openDb(':memory:'); const campaignId = Number(db.prepare( "INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('schema-test', 'Schema', 'GTI Vendas', '[]')" ).run().lastInsertRowid); const campaign = db.prepare( `SELECT status, week_number, approved_daily_limit, windows_json, interval_min_ms, interval_max_ms, hourly_limit, verification_multiplier FROM outreach_campaigns WHERE id = ?` ).get(campaignId); assert.deepEqual({ ...campaign }, { status: 'paused', week_number: 1, approved_daily_limit: 10, windows_json: '[{"start":"09:30","end":"11:30"},{"start":"14:00","end":"17:00"}]', interval_min_ms: 720000, interval_max_ms: 1500000, hourly_limit: 4, verification_multiplier: 1.5 }); const foreignKeys = (table) => db.prepare(`PRAGMA foreign_key_list(${table})`).all() .map(({ table: target, from, to, on_delete }) => ({ table: target, from, to, on_delete })) .sort((a, b) => a.from.localeCompare(b.from) || a.table.localeCompare(b.table) || a.to.localeCompare(b.to)); assert.deepEqual(foreignKeys('outreach_campaign_variants'), [ { table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'CASCADE' } ]); assert.deepEqual(foreignKeys('outreach_campaign_queue'), [ { table: 'outreach_campaign_variants', from: 'campaign_id', to: 'campaign_id', on_delete: 'NO ACTION' }, { table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'CASCADE' }, { table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' }, { table: 'outreach_sends', from: 'outreach_send_id', to: 'id', on_delete: 'NO ACTION' }, { table: 'outreach_campaign_variants', from: 'variant_id', to: 'id', on_delete: 'NO ACTION' } ]); assert.deepEqual(foreignKeys('outreach_inbound_replies'), [ { table: 'leads', from: 'lead_id', to: 'id', on_delete: 'SET NULL' } ]); assert.deepEqual(foreignKeys('outreach_lead_states'), [ { table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' } ]); assert.deepEqual(foreignKeys('outreach_stage_events'), [ { table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'SET NULL' }, { table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' } ]); assert.deepEqual(foreignKeys('outreach_appointments'), [ { table: 'outreach_campaigns', from: 'campaign_id', to: 'id', on_delete: 'SET NULL' }, { table: 'leads', from: 'lead_id', to: 'id', on_delete: 'CASCADE' } ]); const indexes = Object.fromEntries(db.prepare( `SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name IN ( 'idx_campaign_queue_active_lead', 'idx_campaign_queue_active_product_lead', 'idx_campaign_queue_due', 'idx_outreach_inbound_lead', 'idx_outreach_inbound_numero', 'idx_leads_campaign_order' )` ).all().map(({ name, sql }) => [name, sql])); assert.match(indexes.idx_campaign_queue_active_lead, /UNIQUE INDEX[\s\S]+campaign_id, lead_id[\s\S]+WHERE status IN \('preview','approved','scheduled','reserved','sent','unknown'\)/i); assert.match(indexes.idx_campaign_queue_due, /\(status, scheduled_at\)/i); assert.match(indexes.idx_campaign_queue_active_product_lead, /UNIQUE INDEX[\s\S]+produto, lead_id[\s\S]+WHERE status IN \('preview','approved','scheduled','reserved','sent','unknown'\)/i); assert.match(indexes.idx_outreach_inbound_lead, /\(lead_id\)/i); assert.match(indexes.idx_outreach_inbound_numero, /\(numero\)/i); assert.match(indexes.idx_leads_campaign_order, /\(segmento, prioridade, score DESC, id ASC\)/i); assert.throws( () => db.prepare('UPDATE outreach_campaigns SET verification_multiplier = 1.6 WHERE id = ?').run(campaignId), /verification_multiplier|constraint/i ); const insertCampaign = db.prepare( 'INSERT INTO outreach_campaigns (code, name, produto, status, week_number, approved_daily_limit, segments_json) VALUES (?, ?, ?, ?, ?, ?, ?)' ); for (const values of [ ['bad-product', 'Bad', 'Outro', 'paused', 1, 10, '[]'], ['bad-status', 'Bad', 'GTI Vendas', 'invalid', 1, 10, '[]'], ['bad-week', 'Bad', 'GTI Vendas', 'paused', 5, 10, '[]'], ['bad-limit', 'Bad', 'GTI Vendas', 'paused', 1, 11, '[]'] ]) { assert.throws(() => insertCampaign.run(...values), /constraint/i); } assert.throws( () => db.prepare("INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'D', 'invalida')").run(campaignId), /constraint/i ); const variantId = Number(db.prepare( "INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'valida')" ).run(campaignId).lastInsertRowid); const secondVariantId = Number(db.prepare( "INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'B', 'valida B')" ).run(campaignId).lastInsertRowid); db.prepare("INSERT INTO leads (dedup_key, empresa) VALUES ('schema-v15-lead', 'Schema Lead')").run(); const insertQueue = db.prepare( 'INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); assert.throws( () => insertQueue.run(campaignId, 1, variantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'invalid'), /constraint/i ); assert.throws( () => insertQueue.run(campaignId, 999, variantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'preview'), /foreign key constraint/i ); const otherCampaignId = Number(db.prepare( "INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('schema-other', 'Other', 'GTI Vendas', '[]')" ).run().lastInsertRowid); const foreignVariantId = Number(db.prepare( "INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, 'A', 'estrangeira')" ).run(otherCampaignId).lastInsertRowid); assert.throws( () => insertQueue.run(campaignId, 1, foreignVariantId, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'schema', 'preview'), /variant|foreign key|constraint/i ); for (const [index, status] of ['approved', 'scheduled', 'reserved', 'sent', 'unknown'].entries()) { const leadId = Number(db.prepare( 'INSERT INTO leads (dedup_key, empresa) VALUES (?, ?)' ).run(`immutable-${status}`, `Immutable ${status}`).lastInsertRowid); const queueId = Number(insertQueue.run( campaignId, leadId, variantId, 'GTI Vendas', `5511777700${index}`, 'restaurantes', `mensagem ${status}`, 'schema', status ).lastInsertRowid); assert.throws( () => db.prepare('UPDATE outreach_campaign_queue SET rendered_message = ? WHERE id = ?').run('alterada', queueId), /imutav|immutable|constraint/i, `rendered_message ${status}` ); assert.throws( () => db.prepare('UPDATE outreach_campaign_queue SET variant_id = ? WHERE id = ?').run(secondVariantId, queueId), /imutav|immutable|constraint/i, `variant_id ${status}` ); assert.throws( () => db.prepare('UPDATE outreach_campaign_queue SET scheduled_at = ? WHERE id = ?').run('2026-07-30T09:30:00.000Z', queueId), /imutav|immutable|constraint/i, `scheduled_at ${status}` ); } const insertTransitionRow = (suffix) => { const leadId = Number(db.prepare( 'INSERT INTO leads (dedup_key, empresa) VALUES (?, ?)' ).run(`transition-${suffix}`, `Transition ${suffix}`).lastInsertRowid); return Number(insertQueue.run( campaignId, leadId, variantId, 'GTI Vendas', `55116666${String(suffix).padStart(4, '0')}`, 'restaurantes', 'mensagem transicao', 'schema', 'preview' ).lastInsertRowid); }; const transition = db.prepare('UPDATE outreach_campaign_queue SET status = ?, scheduled_at = COALESCE(?, scheduled_at) WHERE id = ?'); for (const [suffix, targetStatus] of [[11, 'approved'], [12, 'scheduled']]) { const queueId = insertTransitionRow(suffix); assert.throws( () => db.prepare( `UPDATE outreach_campaign_queue SET status = ?, variant_id = ?, rendered_message = ?, scheduled_at = ? WHERE id = ?` ).run(targetStatus, secondVariantId, 'mensagem adulterada', '2026-07-29T12:30:00.000Z', queueId), /imutav|immutable|constraint/i, `preview->${targetStatus} com payload adulterado` ); assert.deepEqual({ ...db.prepare( 'SELECT status, variant_id, rendered_message, scheduled_at FROM outreach_campaign_queue WHERE id = ?' ).get(queueId) }, { status: 'preview', variant_id: variantId, rendered_message: 'mensagem transicao', scheduled_at: null }); } const sentPath = insertTransitionRow(1); assert.doesNotThrow(() => transition.run('approved', '2026-07-29T12:30:00.000Z', sentPath)); assert.doesNotThrow(() => transition.run('scheduled', null, sentPath)); assert.doesNotThrow(() => transition.run('reserved', null, sentPath)); assert.doesNotThrow(() => transition.run('sent', null, sentPath)); assert.throws(() => transition.run('scheduled', null, sentPath), /transicao|transition|constraint/i); for (const [suffix, path] of [ [2, ['scheduled', 'cancelled']], [3, ['cancelled']], [4, ['approved', 'cancelled']], [5, ['scheduled', 'reserved', 'unknown']], [6, ['scheduled', 'reserved', 'error']] ]) { const queueId = insertTransitionRow(suffix); for (const status of path) { const scheduledAt = status === 'approved' || status === 'scheduled' ? '2026-07-29T12:30:00.000Z' : null; assert.doesNotThrow(() => transition.run(status, scheduledAt, queueId), `${suffix}:${status}`); } assert.throws(() => transition.run('preview', null, queueId), /transicao|transition|constraint/i); } for (const [suffix, from, to] of [ [7, 'preview', 'reserved'], [8, 'approved', 'preview'], [9, 'scheduled', 'sent'], [10, 'reserved', 'cancelled'] ]) { const queueId = insertTransitionRow(suffix); if (from === 'approved') transition.run('approved', '2026-07-29T12:30:00.000Z', queueId); if (from === 'scheduled') transition.run('scheduled', '2026-07-29T12:30:00.000Z', queueId); if (from === 'reserved') { transition.run('scheduled', '2026-07-29T12:30:00.000Z', queueId); transition.run('reserved', null, queueId); } assert.throws(() => transition.run(to, null, queueId), /transicao|transition|constraint/i, `${from}->${to}`); } const insertAppointment = db.prepare( 'INSERT INTO outreach_appointments (lead_id, campaign_id, kind) VALUES (1, ?, ?)' ); assert.doesNotThrow(() => insertAppointment.run(campaignId, 'demonstracao')); assert.doesNotThrow(() => insertAppointment.run(campaignId, 'teste_g_delivery')); assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_appointments').get().total, 2); assert.throws(() => insertAppointment.run(campaignId, 'visita'), /constraint/i); db.close(); }); test('limite manual e uso publicado compartilham origem e status exatos', () => { const db = openDb(':memory:'); setSetting(db, 'outreach.mensagensPorDia', '1'); db.prepare( "INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990001', 'auto', 'aceito', 'automatico')" ).run(); db.prepare( "INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem) VALUES ('ERP', '5511999990002', 'erro', 'erro', 'manual')" ).run(); assert.equal(typeof database.countDailyOutreachUsage, 'function'); assert.equal(database.countDailyOutreachUsage(db, { origem: 'manual' }), 0); const sendId = reserveOutreachSend(db, { produto: 'ERP', numero: '5511999990003', mensagem: 'manual', origem: 'manual' }); assert.ok(sendId > 0); assert.equal(database.countDailyOutreachUsage(db, { origem: 'manual' }), 1); assert.throws( () => reserveOutreachSend(db, { produto: 'ERP', numero: '5511999990004', mensagem: 'manual 2', origem: 'manual' }), (error) => error.statusCode === 429 ); db.close(); }); test('cache de verificacao salva somente o numero solicitado e consulta equivalentes', () => { const db = openDb(':memory:'); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name); assert.ok(tables.includes('whatsapp_number_verifications')); const saved = database.saveWhatsAppVerification(db, { numero: '55 (75) 99909-1780', status: 'confirmado', numeroResolvido: '557599091780', variante: 'sem_nono_digito' }); assert.equal(saved.numero, '5575999091780'); assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 1); assert.equal(database.getWhatsAppVerification(db, '557599091780').status, 'confirmado'); assert.deepEqual(database.getWhatsAppVerification(db, '5511999990001'), { numero: '5511999990001', status: 'nao_consultado', numero_resolvido: null, variante: null, erro: null, checked_at: null }); db.close(); }); test('cache de verificacao faz upsert pela chave normalizada solicitada', () => { const db = openDb(':memory:'); database.saveWhatsAppVerification(db, { numero: '55 (11) 99999-0001', status: 'erro_temporario', erro: 'timeout' }); database.saveWhatsAppVerification(db, { numero: '5511999990001', status: 'nao_cadastrado' }); assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 1); const result = database.getWhatsAppVerification(db, '5511999990001'); assert.equal(result.status, 'nao_cadastrado'); assert.equal(result.erro, null); db.close(); }); test('listConfirmedWhatsAppNumbers retorna variantes somente do estado equivalente mais recente', () => { const db = openDb(':memory:'); database.saveWhatsAppVerification(db, { numero: '5575999091780', status: 'confirmado' }); database.saveWhatsAppVerification(db, { numero: '557599091780', status: 'nao_cadastrado' }); database.saveWhatsAppVerification(db, { numero: '5511999990001', status: 'confirmado' }); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 10:00:00' WHERE numero = '5575999091780'").run(); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 11:00:00' WHERE numero = '557599091780'").run(); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00' WHERE numero = '5511999990001'").run(); assert.deepEqual(database.listConfirmedWhatsAppNumbers(db).sort(), [ '551199990001', '5511999990001' ]); db.close(); }); test('listConfirmedWhatsAppNumbers nao duplica variantes quando ambas foram confirmadas', () => { const db = openDb(':memory:'); database.saveWhatsAppVerification(db, { numero: '5575999091780', status: 'confirmado' }); database.saveWhatsAppVerification(db, { numero: '557599091780', status: 'confirmado' }); assert.deepEqual(database.listConfirmedWhatsAppNumbers(db).sort(), [ '557599091780', '5575999091780' ]); db.close(); }); test('listConfirmedWhatsAppNumbers desempata estados equivalentes pelo rowid mais recente', () => { const db = openDb(':memory:'); database.saveWhatsAppVerification(db, { numero: '5575999091780', status: 'confirmado' }); database.saveWhatsAppVerification(db, { numero: '557599091780', status: 'erro_temporario' }); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run(); assert.deepEqual(database.listConfirmedWhatsAppNumbers(db), []); db.close(); }); test('createLeadInserter insere e ignora duplicata pela dedup_key', () => { const db = openDb(':memory:'); const insert = createLeadInserter(db); assert.equal(insert(sampleLead()), 'inserted'); assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate'); const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t; assert.equal(total, 1); db.close(); }); test('transaction faz rollback em erro', () => { const db = openDb(':memory:'); const insert = createLeadInserter(db); assert.throws(() => transaction(db, () => { insert(sampleLead()); throw new Error('boom'); }) ); assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0); db.close(); }); test('settings: get/set e seed com defaults pessoais', () => { const db = openDb(':memory:'); seedSettings(db); assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '10'); setSetting(db, 'outreach.mensagensPorDia', '9'); assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '9'); // seed nao sobrescreve valor ja definido pelo usuario seedSettings(db); assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '9'); db.close(); }); test('message_templates atualiza somente mensagem e ativo com texto pessoal neutro', () => { const db = openDb(':memory:'); seedMessageTemplates(db, ['ERP']); const original = db.prepare('SELECT * FROM message_templates WHERE produto = ?').get('ERP'); assert.equal(original.mensagem.includes('Grupo GTI'), false); const updated = updateMessageTemplate(db, 'ERP', { mensagem: 'Ola {empresa}', ativo: false }); assert.equal(updated.mensagem, 'Ola {empresa}'); assert.equal(updated.ativo, 0); db.close(); }); test('seeds de segments, municipios e geo_cache', () => { const db = openDb(':memory:'); seedSegments(db, [ { name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] } ]); const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas'); assert.equal(segment.label, 'Clinicas Medicas'); assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']); seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]); const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA'); assert.equal(municipio.nome_clean, 'santo antonio de jesus'); seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]); const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus'); assert.equal(bbox.south, -12.995); assert.equal(bbox.provider, 'config-legacy'); db.close(); }); test('schema v10 inclui resultado desconhecido no indice automatico unico', () => { const db = openDb(':memory:'); const columns = db.prepare('PRAGMA table_info(message_templates)').all().map((row) => row.name); assert.deepEqual(columns, ['id', 'produto', 'mensagem', 'ativo', 'updated_at']); createLeadInserter(db)(sampleLead()); db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'a', 'pendente', 'automatico')").run(); assert.throws(() => db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'b', 'enviado', 'automatico')").run()); const index = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_outreach_automatico_unico'").get(); assert.match(index.sql, /desconhecido/); db.close(); }); test('outreach persiste aceite, ACK, entrega e leitura sem regredir status', () => { const db = openDb(':memory:'); assert.equal(typeof database.updateOutreachMessageStatus, 'function'); const columns = db.prepare('PRAGMA table_info(outreach_sends)').all().map((row) => row.name); for (const column of ['accepted_at', 'server_ack_at', 'delivered_at', 'read_at']) assert.ok(columns.includes(column)); const id = reserveOutreachSend(db, { numero: '5511999990001', mensagem: 'oi' }); assert.equal(finishOutreachSend(db, id, { messageId: 'native-1' }), true); assert.equal(db.prepare('SELECT status FROM outreach_sends WHERE id = ?').get(id).status, 'aceito'); assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'entregue' }), true); assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'enviado' }), false); assert.equal(database.updateOutreachMessageStatus(db, { messageId: 'native-1', status: 'lido' }), true); const row = db.prepare('SELECT status, accepted_at, server_ack_at, delivered_at, read_at FROM outreach_sends WHERE id = ?').get(id); assert.equal(row.status, 'lido'); assert.ok(row.accepted_at); assert.ok(row.server_ack_at); assert.ok(row.delivered_at); assert.ok(row.read_at); db.close(); }); test('recovery converte aceite sem ACK em resultado desconhecido', () => { const db = openDb(':memory:'); const id = reserveOutreachSend(db, { numero: '5511999990001', mensagem: 'oi' }); finishOutreachSend(db, id, { messageId: 'sem-ack' }); assert.equal(database.recoverPendingOutreach(db), 1); const row = db.prepare('SELECT status, erro FROM outreach_sends WHERE id = ?').get(id); assert.equal(row.status, 'desconhecido'); assert.match(row.erro, /sem confirmacao/i); db.close(); }); test('migracao v10 normaliza pendentes legados antes de recriar indice', async () => { const db = openDb(':memory:'); createLeadInserter(db)(sampleLead()); db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, origem) VALUES (1, 'ERP', 'a', 'pendente', 'automatico')").run(); db.exec('DROP INDEX idx_outreach_automatico_unico'); db.prepare('DELETE FROM schema_migrations WHERE version = 10').run(); db.exec("CREATE UNIQUE INDEX idx_outreach_automatico_unico ON outreach_sends(lead_id, produto) WHERE origem = 'automatico' AND status IN ('pendente', 'enviado') AND lead_id IS NOT NULL"); const { migrate } = await import('../src/db.mjs'); migrate(db); const row = db.prepare('SELECT status, erro FROM outreach_sends').get(); assert.equal(row.status, 'desconhecido'); assert.match(row.erro, /processo encerrou sem confirmacao/i); db.close(); }); test('reset apaga outreach antes de leads e preserva opt-outs', () => { const db = openDb(':memory:'); createLeadInserter(db)(sampleLead()); db.prepare("INSERT INTO outreach_campaigns (code, name, produto, segments_json) VALUES ('reset-test', 'Reset', 'GTI Vendas', '[]')").run(); db.prepare("INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (1, 'A', 'oi')").run(); db.prepare("INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada) VALUES (1, 'ERP', 'oi')").run(); db.prepare("INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status) VALUES (1, 1, 1, 'GTI Vendas', '5511999990001', 'restaurantes', 'oi', 'reset-test', 'preview')").run(); db.prepare("INSERT INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at) VALUES ('reply-reset', 1, '5511999990001', datetime('now'))").run(); db.prepare("INSERT INTO outreach_lead_states (lead_id, stage) VALUES (1, 'respondeu')").run(); db.prepare("INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (1, 1, 'respondeu')").run(); db.prepare("INSERT INTO outreach_appointments (lead_id, campaign_id, kind) VALUES (1, 1, 'demonstracao')").run(); db.prepare("INSERT INTO outreach_opt_outs (numero) VALUES ('5511999990001')").run(); resetLeadsBase(db); assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, 0); assert.equal(db.prepare('SELECT COUNT(*) total FROM leads').get().total, 0); for (const table of [ 'outreach_campaign_queue', 'outreach_inbound_replies', 'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments' ]) { assert.equal(db.prepare(`SELECT COUNT(*) total FROM ${table}`).get().total, 0, `${table} nao foi limpa`); } assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_campaigns').get().total, 1); assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_opt_outs').get().total, 1); db.close(); }); test('v12 garante no maximo um job ativo por tipo e libera novo apos done', () => { const db = openDb(':memory:'); const cases = [ ['capture_runs', "params_json, status", "'{}', 'pending'"], ['extractions', 'uf, status', "'BA', 'running'"], ['exports', 'tipo, status', "'excel', 'pending'"] ]; for (const [table, columns, values] of cases) { const insert = db.prepare(`INSERT INTO ${table} (${columns}) VALUES (${values})`); const first = insert.run(); assert.throws(() => insert.run(), /constraint/i); db.prepare(`UPDATE ${table} SET status = 'done' WHERE id = ?`).run(first.lastInsertRowid); assert.doesNotThrow(() => insert.run()); } db.close(); }); test('v12 normaliza duplicados ativos legados mantendo somente o mais recente', async () => { const db = openDb(':memory:'); for (const table of ['capture_runs', 'extractions', 'exports']) db.exec(`DROP INDEX idx_${table}_single_active`); db.prepare('DELETE FROM schema_migrations WHERE version = 12').run(); db.prepare("INSERT INTO capture_runs (params_json, status) VALUES ('{}', 'pending'), ('{}', 'running')").run(); db.prepare("INSERT INTO extractions (uf, status) VALUES ('BA', 'pending'), ('SP', 'running')").run(); db.prepare("INSERT INTO exports (tipo, status) VALUES ('excel', 'pending'), ('excel', 'running')").run(); const { migrate } = await import('../src/db.mjs'); migrate(db); for (const table of ['capture_runs', 'extractions', 'exports']) { assert.equal(db.prepare(`SELECT COUNT(*) total FROM ${table} WHERE status IN ('pending','running')`).get().total, 1); assert.equal(db.prepare(`SELECT status FROM ${table} ORDER BY id`).get().status, 'error'); } db.close(); });