import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); test('package e ambiente usam identidade e caminhos pessoais', async () => { const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); assert.equal(pkg.name, 'maquina-comercial-pessoal'); assert.equal(pkg.dependencies.baileys, '7.0.0-rc14'); assert.equal(pkg.dependencies.qrcode, '1.5.4'); const previous = { ...process.env }; delete process.env.APP_PORT; delete process.env.APP_DB_PATH; delete process.env.APP_WHATSAPP_AUTH_DIR; const env = await import(`../src/env.mjs?personal=${Date.now()}`); assert.equal(env.APP_PORT, 3910); assert.equal(env.paths.db, path.join(root, 'data', 'maquina-pessoal.db')); assert.equal(env.paths.whatsappAuth, path.join(root, 'data', 'runtime', 'whatsapp-auth')); process.env = previous; }); test('schema pessoal possui opt-outs e identificador generico de mensagem', async () => { const { openDb } = await import('../src/db.mjs'); const db = openDb(':memory:'); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(({ name }) => name); assert.ok(tables.includes('outreach_opt_outs')); const columns = db.prepare('PRAGMA table_info(outreach_sends)').all().map(({ name }) => name); assert.ok(columns.includes('provider_message_id')); assert.ok(!columns.includes('zpro_ticket_id')); db.close(); }); test('settings pessoais sao conservadores e nao incluem credenciais corporativas', async () => { const { openDb, seedSettings, getSetting } = await import('../src/db.mjs'); const db = openDb(':memory:'); seedSettings(db); assert.equal(getSetting(db, 'outreach.ativo'), 'false'); assert.equal(getSetting(db, 'outreach.mensagensPorDia'), '10'); assert.equal(getSetting(db, 'outreach.intervaloMinMs'), '5000'); assert.equal(getSetting(db, 'outreach.intervaloMaxMs'), '12000'); assert.equal(db.prepare("SELECT COUNT(*) total FROM settings WHERE key LIKE 'zpro.%' OR key LIKE 'gchat.%'").get().total, 0); db.close(); }); test('helpers de opt-out adicionam, listam, consultam e removem sem o reset apagar', async () => { const { openDb, addOptOut, listOptOuts, isOptedOut, removeOptOut, resetLeadsBase } = await import('../src/db.mjs'); const db = openDb(':memory:'); addOptOut(db, '55 (11) 99999-0001', 'pedido do contato'); assert.equal(isOptedOut(db, '5511999990001'), true); assert.equal(listOptOuts(db)[0].motivo, 'pedido do contato'); resetLeadsBase(db); assert.equal(isOptedOut(db, '5511999990001'), true); assert.equal(removeOptOut(db, '5511999990001'), true); assert.equal(isOptedOut(db, '5511999990001'), false); db.close(); }); test('opt-out bloqueia e remove as formas equivalentes com e sem nono digito', async () => { const { openDb, addOptOut, isOptedOut, removeOptOut } = await import('../src/db.mjs'); const db = openDb(':memory:'); addOptOut(db, '5575999091780', 'solicitado'); assert.equal(isOptedOut(db, '557599091780'), true); assert.equal(removeOptOut(db, '557599091780'), true); assert.equal(isOptedOut(db, '5575999091780'), false); db.close(); }); // equivalentPhoneNumbers nao e simetrica para um par 12/13 digitos fora da // faixa 6-9 (ver phone.mjs). addOptOut/isOptedOut usavam essa expansao // diretamente: bloquear numa forma do numero podia nao bloquear a outra forma // do mesmo numero, deixando passar um envio para quem pediu para nao receber. test('opt-out bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => { const { openDb, addOptOut, isOptedOut, removeOptOut, reserveOutreachSend } = await import('../src/db.mjs'); const db = openDb(':memory:'); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('assim-optout', 'Empresa Assimetrica', '557533334444', 1)` ).run(); addOptOut(db, '5575933334444', 'pedido do contato'); for (const numero of ['557533334444', '5575933334444']) { assert.equal(isOptedOut(db, numero), true, numero); assert.throws( () => reserveOutreachSend(db, { numero, mensagem: 'oi' }), (error) => error.statusCode === 409, numero ); } assert.equal(removeOptOut(db, '557533334444'), true); for (const numero of ['557533334444', '5575933334444']) { assert.equal(isOptedOut(db, numero), false, numero); } db.close(); }); // Number(...) || 4 tratava prioridade=0 (ou texto nao numerico) como P4, // ja que 0/NaN sao falsy em JS — um filtro invalido devolvia leads de P4 em // vez de nao bater com nada. test('filtro de prioridade invalido nao e silenciosamente tratado como P4', async () => { const { openDb } = await import('../src/db.mjs'); const { listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, prioridade, telefone_valido) VALUES ('p1', 'Empresa P1', '5511999990001', 1, 1)` ).run(); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, prioridade, telefone_valido) VALUES ('p4', 'Empresa P4', '5511999990004', 4, 1)` ).run(); assert.deepEqual(listLeads(db, { prioridade: '0' }).rows.map((r) => r.empresa), []); assert.deepEqual(listLeads(db, { prioridade: 'abc' }).rows.map((r) => r.empresa), []); assert.deepEqual(listLeads(db, { prioridade: '4' }).rows.map((r) => r.empresa), ['Empresa P4']); db.close(); }); test('listagem e detalhe derivam o estado da verificacao equivalente mais recente', async () => { const { openDb, saveWhatsAppVerification } = await import('../src/db.mjs'); const { getLead, listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); const inserted = db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('telefone:5575999091780', 'Lead verificado', '5575999091780', 1)` ).run(); saveWhatsAppVerification(db, { numero: '5575999091780', status: 'confirmado', numeroResolvido: '5575999091780', variante: 'exata' }); saveWhatsAppVerification(db, { numero: '557599091780', status: 'nao_cadastrado' }); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run(); const cached = db.prepare( 'SELECT numero, rowid FROM whatsapp_number_verifications ORDER BY rowid' ).all(); assert.deepEqual(cached.map((row) => row.numero), ['5575999091780', '557599091780']); assert.ok(cached[1].rowid > cached[0].rowid); const detail = getLead(db, Number(inserted.lastInsertRowid)); assert.equal(detail.whatsapp_status, 'nao_cadastrado'); assert.equal(detail.whatsapp_numero_resolvido, null); assert.equal(detail.whatsapp_checked_at, '2026-07-29 12:00:00'); assert.equal(detail.telefone_valido, 1); const listed = listLeads(db).rows[0]; assert.equal(listed.whatsapp_status, 'nao_cadastrado'); assert.equal(listed.whatsapp_numero_resolvido, null); assert.equal(listed.whatsapp_checked_at, '2026-07-29 12:00:00'); assert.equal(listed.telefone_valido, 1); saveWhatsAppVerification(db, { numero: '5575999091780', status: 'confirmado', numeroResolvido: '557599091780', variante: 'sem_nono_digito' }); db.prepare("UPDATE whatsapp_number_verifications SET checked_at = '2026-07-29 12:00:00'").run(); const updated = getLead(db, Number(inserted.lastInsertRowid)); assert.equal(updated.whatsapp_status, 'confirmado'); assert.equal(updated.whatsapp_numero_resolvido, '557599091780'); db.close(); }); test('lead sem verificacao equivalente permanece nao consultado', async () => { const { openDb } = await import('../src/db.mjs'); const { getLead, listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); const inserted = db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('telefone:5511999990001', 'Lead novo', '5511999990001', 1)` ).run(); const detail = getLead(db, Number(inserted.lastInsertRowid)); const listed = listLeads(db).rows[0]; for (const lead of [detail, listed]) { assert.equal(lead.whatsapp_status, 'nao_consultado'); assert.equal(lead.whatsapp_numero_resolvido, null); assert.equal(lead.whatsapp_checked_at, null); assert.equal(lead.telefone_valido, 1); } db.close(); }); test('listagem e detalhe marcam numero compartilhado por mais de um lead', async () => { const { openDb, addSharedNumberOverride } = await import('../src/db.mjs'); const { getLead, listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); const sharedA = db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('shared-a', 'Contabilidade Cliente A', '5575999091780', 1)` ).run(); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('shared-b', 'Contabilidade Cliente B', '557599091780', 1)` ).run(); const solo = db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('solo', 'Empresa Unica', '5511999990001', 1)` ).run(); const rows = listLeads(db).rows; const shared = rows.find((row) => row.id === Number(sharedA.lastInsertRowid)); const unique = rows.find((row) => row.id === Number(solo.lastInsertRowid)); assert.equal(shared.numero_compartilhado, true); assert.equal(shared.numero_compartilhado_total, 2); assert.equal(shared.numero_compartilhado_liberado, false); assert.equal(unique.numero_compartilhado, false); assert.equal(unique.numero_compartilhado_total, 0); const detail = getLead(db, Number(sharedA.lastInsertRowid)); assert.equal(detail.numero_compartilhado, true); addSharedNumberOverride(db, '5575999091780', 'revisado'); const refreshed = getLead(db, Number(sharedA.lastInsertRowid)); assert.equal(refreshed.numero_compartilhado_liberado, true); db.close(); }); // Par assimetrico de equivalentPhoneNumbers (557533334444 / 5575933334444): // os dois lados precisam aparecer marcados na tela de tratamento. test('listagem marca os dois lados de um par 12/13 digitos assimetrico', async () => { const { openDb } = await import('../src/db.mjs'); const { getLead, listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); const doze = Number(db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('assim-12', 'Empresa 12 digitos', '557533334444', 1)` ).run().lastInsertRowid); const treze = Number(db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) VALUES ('assim-13', 'Empresa 13 digitos', '5575933334444', 1)` ).run().lastInsertRowid); const rows = new Map(listLeads(db).rows.map((row) => [row.id, row])); for (const id of [doze, treze]) { assert.equal(rows.get(id).numero_compartilhado, true, String(id)); assert.equal(rows.get(id).numero_compartilhado_total, 2, String(id)); assert.equal(getLead(db, id).numero_compartilhado, true, String(id)); } db.close(); }); // A versao anterior carregava TODOS os leads com telefone em JS a cada // listagem. Agora o estado sai de subconsultas correlacionadas por linha: o // plano precisa usar idx_leads_whatsapp e a listagem paginada nao pode custar // proporcional ao tamanho da tabela. test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira', async () => { const { openDb } = await import('../src/db.mjs'); const { listLeads } = await import('../src/treatment/queries.mjs'); const db = openDb(':memory:'); db.exec(` WITH RECURSIVE sequence(value) AS ( VALUES(1) UNION ALL SELECT value + 1 FROM sequence WHERE value < 60000 ) INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) SELECT 'ruido-' || value, 'Ruido ' || value, '5511' || printf('%08d', value), 1, 4, 0 FROM sequence; `); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) VALUES ('pagina-compartilhado-a', 'Compartilhado A', '5575999091780', 1, 1, 100)` ).run(); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) VALUES ('pagina-compartilhado-b', 'Compartilhado B', '557599091780', 1, 1, 99)` ).run(); db.prepare( `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) VALUES ('pagina-unico', 'Unico', '5575992220000', 1, 1, 98)` ).run(); // Conta TODAS as linhas que chegam ao JS durante a listagem: a implementacao // antiga trazia os 60 mil leads com telefone para montar o indice em memoria. const trace = { sql: null, args: null, rowsRead: 0 }; const tracedDb = new Proxy(db, { get(target, property) { if (property !== 'prepare') { const value = target[property]; return typeof value === 'function' ? value.bind(target) : value; } return (sql) => { const statement = target.prepare(sql); if (sql.includes('numero_compartilhado_total_raw')) trace.sql = sql; return new Proxy(statement, { get(stmt, name) { if (name === 'all') { return (...args) => { const rows = stmt.all(...args); trace.rowsRead += rows.length; if (sql.includes('numero_compartilhado_total_raw')) trace.args = args; return rows; }; } const value = stmt[name]; return typeof value === 'function' ? value.bind(stmt) : value; } }); }; } }); const startedAt = performance.now(); const rows = listLeads(tracedDb, { pageSize: 3 }).rows; const elapsedMs = performance.now() - startedAt; assert.deepEqual( rows.map((row) => [row.empresa, row.numero_compartilhado, row.numero_compartilhado_total]), [ ['Compartilhado A', true, 2], ['Compartilhado B', true, 2], ['Unico', false, 0] ] ); assert.ok( trace.rowsRead <= 10, `listagem trouxe ${trace.rowsRead} linhas para o JS com pageSize 3 (base de 60003 leads)` ); assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`); // Prova estrutural na consulta real de hidratacao (a que traz as colunas de // numero compartilhado): ela busca so os ids ja paginados (nao faz o // LIMIT/OFFSET nem o sort ela mesma — isso acontece antes, na consulta so-id // — e a subconsulta correlacionada procura os irmaos pelo indice do // telefone. assert.ok(trace.sql, 'consulta de hidratacao nao foi observada'); assert.match(trace.sql, /WHERE leads\.id IN \(/); const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args) .map((row) => row.detail).join('\n'); assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan); assert.doesNotMatch(plan, /SCAN sharing/i, plan); // A ordenacao padrao (prioridade:asc, score:desc, cidade:asc, empresa:asc) // so casa parcialmente com o indice: os 60 mil leads de "ruido" empatam em // prioridade=4/score=0, um bloco unico que o sort do SQLite materializa // inteiro antes de aplicar LIMIT/OFFSET. A pagina 1 (os 3 leads especiais, // prioridade=1) nao entra nesse bloco e por isso nao prova nada sobre ele; // a pagina 2 cai direto nele. Se as subconsultas de numero compartilhado // fossem reavaliadas para o bloco inteiro durante o sort (em vez de so para // a pagina, apos paginar por id), essa consulta ficaria uma ordem de // grandeza mais lenta aqui. const page2StartedAt = performance.now(); const page2 = listLeads(db, { pageSize: 3, page: 2 }).rows; const page2ElapsedMs = performance.now() - page2StartedAt; assert.equal(page2.length, 3); assert.ok(page2.every((row) => row.empresa.startsWith('Ruido')), page2.map((r) => r.empresa).join(',')); assert.ok( page2ElapsedMs < elapsedMs * 5 + 200, `pagina 2 (dentro do bloco de 60 mil empatados) levou ${page2ElapsedMs.toFixed(1)}ms ` + `vs ${elapsedMs.toFixed(1)}ms na pagina 1 — sugere reavaliacao das subconsultas para o bloco inteiro` ); db.close(); });