The last-mile validator in campaign-worker.mjs revalidated opt-out/inbound/ dedupe right before sending but never rechecked shared-number state, so re-blocking a released number after scheduling didn't stop the send. equivalentPhoneNumbers isn't symmetric for all 8/9-digit pairs, which made isNumberSharedAcrossLeads and listSharedNumberGroups disagree for a 12-digit local outside the 6-9 range vs its 13-digit counterpart: one side of the pair could be enforcement-blocked while invisible and unreleasable in the review screen. A new canonicalPhoneKey/canonicalPhoneVariants pair in phone.mjs (plus SQL mirrors) makes the matching relation symmetric by construction. listSharedNumberGroups did a full leads-table scan on every listLeads/getLead call, the app's primary paginated screen. Replaced with per-row correlated subqueries against idx_leads_whatsapp so cost tracks the page, not the table. normalizedSql/equivalentSql (SQL) duplicated equivalentPhoneNumbers (JS) with no test proving they agreed; moved into phone.mjs alongside their canonical counterparts and added tests/phone.test.mjs asserting agreement across a shared number set, including the asymmetric case.
865 lines
40 KiB
JavaScript
865 lines
40 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, 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();
|
|
});
|