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:
@@ -6,8 +6,10 @@ import path from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import {
|
||||
addOptOut,
|
||||
addSharedNumberOverride,
|
||||
openDb,
|
||||
recoverPendingOutreach,
|
||||
removeSharedNumberOverride,
|
||||
reserveOutreachSend,
|
||||
saveWhatsAppVerification,
|
||||
setSetting
|
||||
@@ -448,7 +450,11 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu
|
||||
['envio ativo do mesmo produto', 'duplicate_product_send', ({ db, campaign, item }) => db.prepare(
|
||||
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
||||
VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')`
|
||||
).run(item.leadId, campaign.produto, item.numero)]
|
||||
).run(item.leadId, campaign.produto, item.numero)],
|
||||
['numero virou compartilhado', 'shared_number', ({ db, item }) => db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('worker-compartilhado', 'Outro lead com o mesmo telefone', ?, 'restaurantes', 1)`
|
||||
).run(item.numero)]
|
||||
];
|
||||
|
||||
for (const [name, cancelReason, mutate] of cases) {
|
||||
@@ -495,6 +501,93 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Cenario real do operador: numero compartilhado liberado em Configuracoes,
|
||||
// item entra na fila e e agendado, operador reconsidera e clica "Bloquear de
|
||||
// novo". Sem a revalidacao de ultima milha o envio ja agendado sairia mesmo
|
||||
// assim, porque a checagem de compartilhamento so acontecia na selecao.
|
||||
test('libera, agenda e rebloqueia numero compartilhado cancela o envio ja agendado', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [name, reblock, expected] of [
|
||||
['override mantido', false, { tentados: 1, enviados: 1, erros: 0, pulados: 0 }],
|
||||
['override removido antes do envio', true, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }]
|
||||
]) {
|
||||
const { db, campaign } = setup();
|
||||
const item = addQueue(db, campaign, { suffix: 600 });
|
||||
db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('worker-600-b', 'Cliente da mesma contabilidade', ?, 'restaurantes', 1)`
|
||||
).run(item.numero);
|
||||
addSharedNumberOverride(db, item.numero, 'contabilidade confirmada');
|
||||
if (reblock) assert.equal(removeSharedNumberOverride(db, item.numero), true, name);
|
||||
|
||||
const whatsapp = whatsappFake();
|
||||
const summary = await runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now: new Date('2026-07-29T12:00:00.000Z'),
|
||||
log: () => {}
|
||||
});
|
||||
|
||||
assert.deepEqual(summary, expected, name);
|
||||
assert.equal(whatsapp.providerCalls, reblock ? 0 : 1, name);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) },
|
||||
reblock ? { status: 'cancelled', cancel_reason: 'shared_number' } : { status: 'sent', cancel_reason: null },
|
||||
name
|
||||
);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, reblock ? 0 : 1, name);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Par assimetrico: 557533334444 (local '33334444', fora da faixa 6-9) e
|
||||
// 5575933334444. equivalentPhoneNumbers so relaciona o segundo com o primeiro,
|
||||
// entao antes da chave canonica o lead de 12 digitos passava batido aqui.
|
||||
test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [queued, outro] of [
|
||||
['557533334444', '5575933334444'],
|
||||
['5575933334444', '557533334444']
|
||||
]) {
|
||||
const { db, campaign } = setup();
|
||||
const leadId = Number(db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('assimetrico-fila', 'Lead na fila', ?, 'restaurantes', 1)`
|
||||
).run(queued).lastInsertRowid);
|
||||
db.prepare(
|
||||
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
||||
VALUES ('assimetrico-outro', 'Lead com o mesmo telefone', ?, 'restaurantes', 1)`
|
||||
).run(outro);
|
||||
saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' });
|
||||
const variantId = db.prepare(
|
||||
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1'
|
||||
).get(campaign.id).id;
|
||||
const queueId = Number(db.prepare(
|
||||
`INSERT INTO outreach_campaign_queue
|
||||
(campaign_id, lead_id, variant_id, produto, numero, segmento,
|
||||
rendered_message, selection_reason, status, scheduled_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')`
|
||||
).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid);
|
||||
|
||||
const whatsapp = whatsappFake();
|
||||
const summary = await runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now: new Date('2026-07-29T12:00:00.000Z'),
|
||||
log: () => {}
|
||||
});
|
||||
|
||||
assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }, queued);
|
||||
assert.equal(whatsapp.providerCalls, 0, queued);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
|
||||
{ status: 'cancelled', cancel_reason: 'shared_number' },
|
||||
queued
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [outcome, queueStatus, sendStatus] of [
|
||||
|
||||
Reference in New Issue
Block a user