Files
COMERCIAL-GTI/tests/db.test.mjs
T
Gustavo d330bb5ff0 Baseline commit of the existing Maquina Comercial Pessoal project
Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
2026-07-31 10:54:07 -03:00

740 lines
33 KiB
JavaScript

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();
});