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