Fix opt-out asymmetric 8/9-digit bypass letting sends reach blocked numbers
isOptedOut/addOptOut/removeOptOut used equivalentPhoneNumbers, which is not symmetric for a 12-digit local outside the 6-9 range vs its 13-digit counterpart (same root cause already fixed for shared-number detection). An opt-out registered against one form of such a number silently failed to block sends to a lead whose number was stored in the other form, on both the manual send path (reserveOutreachSend) and, independently, in two inlined checks that bypassed isOptedOut entirely: the last-mile recheck in campaign-worker.mjs's reserveCampaignQueueSend, and the opt-out NOT EXISTS clause in campaign-queue.mjs's candidate query. All three now use the symmetric canonicalPhoneVariants key already introduced for shared-number overrides, and the worker's last-mile check now calls isOptedOut directly instead of re-deriving its own number list. Found via a full-project audit; reproduced and verified before fixing.
This commit is contained in:
@@ -588,6 +588,51 @@ test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico',
|
||||
}
|
||||
});
|
||||
|
||||
// Mesmo par assimetrico, agora para opt-out: addOptOut/isOptedOut usavam
|
||||
// equivalentPhoneNumbers diretamente, entao um opt-out registrado numa forma
|
||||
// do numero podia nao bloquear o envio quando a fila tinha a outra forma.
|
||||
test('ultima milha bloqueia opt-out registrado na outra forma de um par 12/13 digitos assimetrico', async () => {
|
||||
const { runDueCampaignQueue } = await workerModule();
|
||||
for (const [queued, optedOutForm] 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-optout', 'Lead na fila', ?, 'restaurantes', 1)`
|
||||
).run(queued).lastInsertRowid);
|
||||
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);
|
||||
addOptOut(db, optedOutForm, 'pediu para nao receber mais');
|
||||
|
||||
const whatsapp = whatsappFake();
|
||||
const summary = await runDueCampaignQueue({
|
||||
db,
|
||||
whatsapp,
|
||||
now: new Date('2026-07-29T12:00:00.000Z'),
|
||||
log: () => {}
|
||||
});
|
||||
|
||||
assert.equal(summary.enviados, 0, 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: 'opt_out' },
|
||||
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