Fix final-review findings: last-mile shared-number recheck, symmetric canonical key, per-row treatment query, JS/SQL agreement test
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.
This commit is contained in:
@@ -175,3 +175,115 @@ test('listagem e detalhe marcam numero compartilhado por mais de um lead', async
|
||||
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: a subconsulta correlacionada procura os
|
||||
// irmaos pelo indice do telefone, e a pagina e limitada no SQL.
|
||||
assert.ok(trace.sql, 'consulta real de listagem nao foi observada');
|
||||
assert.match(trace.sql, /LIMIT \? OFFSET \?/);
|
||||
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);
|
||||
db.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user