Files
COMERCIAL-GTI/tests/phone.test.mjs
T
Gustavo afa350de5e 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.
2026-07-31 20:31:31 -03:00

121 lines
4.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import {
canonicalKeySql,
canonicalPhoneKey,
canonicalPhoneVariants,
canonicalVariantSql,
equivalentPhoneNumbers,
equivalentSql,
normalizePhone,
normalizedSql
} from '../src/phone.mjs';
// A regra 8/9 digitos existe duas vezes: em JS (equivalentPhoneNumbers /
// canonicalPhoneKey, usadas pelo envio manual e pela revalidacao de ultima
// milha) e em SQL (equivalentSql / canonicalKeySql, usadas pela selecao de
// candidatos da campanha e pela listagem de tratamento). Se alguem editar uma
// sem a outra, o bloqueio automatico e o manual passam a discordar em silencio.
// Estes testes rodam o mesmo conjunto de numeros pelos dois caminhos.
const NUMBERS = [
'5575999091780', // celular BR de 13 digitos com nono digito
'557599091780', // mesma linha em 8 digitos (local comeca com 9)
'5571993335451', // outro celular de 13 digitos
'557193335451', // fixo/8 digitos que comeca com 9 -> tem par
'557133334444', // fixo de 12 digitos, local comeca com 3 (sem par direto)
'5575933334444', // par assimetrico do numero abaixo
'557533334444', // 12 digitos, local '33334444' fora da faixa 6-9
'551140028922', // 12 digitos, local comeca com 4
'557561234567', // 12 digitos, local comeca com 6 (limite inferior da faixa)
'557551234567', // 12 digitos, local comeca com 5 (logo abaixo da faixa)
'557591234567', // 12 digitos, local comeca com 9 (limite superior da faixa)
'5511987654321', // 13 digitos comum
'11987654321', // sem DDI 55
'5575', // curto demais
'' // vazio
];
function sqlDb() {
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE amostra (bruto TEXT)');
return db;
}
function sqlValues(db, numero) {
db.prepare('DELETE FROM amostra').run();
db.prepare('INSERT INTO amostra (bruto) VALUES (?)').run(numero);
return db.prepare(
`SELECT ${normalizedSql('bruto')} AS normalizado,
${equivalentSql('bruto')} AS equivalente,
${canonicalKeySql('bruto')} AS canonico,
${canonicalVariantSql('bruto')} AS variante
FROM amostra`
).get();
}
test('normalizedSql concorda com normalizePhone', () => {
const db = sqlDb();
for (const numero of [...NUMBERS, '+55 (75) 99909-1780', '55 71 9333-5451']) {
assert.equal(sqlValues(db, numero).normalizado, normalizePhone(numero), numero);
}
db.close();
});
test('equivalentSql concorda com equivalentPhoneNumbers em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const equivalentes = equivalentPhoneNumbers(numero);
// equivalentPhoneNumbers devolve [proprio, alternativo?]; equivalentSql
// devolve apenas o "outro lado" (ou o proprio quando nao ha alternativa).
const esperado = equivalentes.length > 1 ? equivalentes[1] : normalizePhone(numero);
assert.equal(sqlValues(db, numero).equivalente, esperado, numero);
}
db.close();
});
test('canonicalKeySql concorda com canonicalPhoneKey em todas as amostras', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
assert.equal(sqlValues(db, numero).canonico, canonicalPhoneKey(numero), numero);
}
db.close();
});
test('canonicalKeySql + canonicalVariantSql cobrem o mesmo conjunto de canonicalPhoneVariants', () => {
const db = sqlDb();
for (const numero of NUMBERS) {
const { canonico, variante } = sqlValues(db, numero);
const sqlSet = [...new Set([canonico, variante].filter(Boolean))].sort();
const jsSet = [...new Set(canonicalPhoneVariants(numero))].sort();
assert.deepEqual(sqlSet, jsSet, numero);
}
db.close();
});
test('a chave canonica e simetrica onde equivalentPhoneNumbers nao e', () => {
// Caso concreto: A tem 12 digitos com local fora da faixa 6-9, B tem 13.
assert.deepEqual(equivalentPhoneNumbers('557533334444'), ['557533334444']);
assert.deepEqual(equivalentPhoneNumbers('5575933334444'), ['5575933334444', '557533334444']);
assert.equal(canonicalPhoneKey('557533334444'), '557533334444');
assert.equal(canonicalPhoneKey('5575933334444'), '557533334444');
// E os pares 8/9 digitos ja cobertos pela suite continuam batendo entre si.
assert.equal(canonicalPhoneKey('5575999091780'), canonicalPhoneKey('557599091780'));
assert.equal(canonicalPhoneKey('5571993335451'), canonicalPhoneKey('557193335451'));
// Simetria total do conjunto de casamento nas amostras brasileiras.
for (const numero of NUMBERS) {
for (const outro of canonicalPhoneVariants(numero)) {
assert.ok(
canonicalPhoneVariants(outro).includes(normalizePhone(numero)),
`${numero} <-> ${outro}`
);
}
}
// Numeros de linhas diferentes nao podem colidir.
assert.notEqual(canonicalPhoneKey('557533334444'), canonicalPhoneKey('557133334444'));
assert.notEqual(canonicalPhoneKey('5511987654321'), canonicalPhoneKey('551140028922'));
});