Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
182 lines
7.2 KiB
JavaScript
182 lines
7.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, saveWhatsAppVerification } from '../src/db.mjs';
|
|
import { seedCampaigns } from '../src/outreach/campaign-store.mjs';
|
|
import { runDueCampaignQueue } from '../src/outreach/campaign-worker.mjs';
|
|
import { recordInboundReply } from '../src/outreach/inbound.mjs';
|
|
|
|
function setup() {
|
|
const db = openDb(':memory:');
|
|
seedCampaigns(db);
|
|
const campaigns = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id').all();
|
|
return { db, campaigns };
|
|
}
|
|
|
|
function addLead(db, suffix, numero) {
|
|
return Number(db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido)
|
|
VALUES (?, ?, ?, 'restaurantes', 1)`
|
|
).run(`inbound-${suffix}`, `Empresa ${suffix}`, numero).lastInsertRowid);
|
|
}
|
|
|
|
function addQueue(db, campaign, { leadId, numero, suffix, status = 'preview' }) {
|
|
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)
|
|
VALUES (?, ?, ?, ?, ?, 'restaurantes', ?, 'inbound-test', 'preview')`
|
|
).run(campaign.id, leadId, variantId, campaign.produto, numero, `Mensagem ${suffix}`).lastInsertRowid);
|
|
if (status === 'approved') {
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'approved' WHERE id = ?").run(queueId);
|
|
} else if (status === 'scheduled') {
|
|
db.prepare(
|
|
"UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-29T12:00:00.000Z' WHERE id = ?"
|
|
).run(queueId);
|
|
}
|
|
return queueId;
|
|
}
|
|
|
|
function addHistoricalSend(db, campaign, { leadId, numero, suffix, sentAt }) {
|
|
const queueId = addQueue(db, campaign, { leadId, numero, suffix, status: 'scheduled' });
|
|
const sendId = Number(db.prepare(
|
|
`INSERT INTO outreach_sends
|
|
(lead_id, produto, numero, mensagem_enviada, status, origem, sent_at, provider_message_id)
|
|
VALUES (?, ?, ?, ?, 'aceito', 'automatico', ?, ?)`
|
|
).run(leadId, campaign.produto, numero, `Mensagem ${suffix}`, sentAt, `outbound-${suffix}`).lastInsertRowid);
|
|
db.prepare(
|
|
"UPDATE outreach_campaign_queue SET status = 'reserved', outreach_send_id = ? WHERE id = ?"
|
|
).run(sendId, queueId);
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent' WHERE id = ?").run(queueId);
|
|
return queueId;
|
|
}
|
|
|
|
test('inbound usa envio de campanha mais recente entre variantes e aplica pausa idempotente', () => {
|
|
const { db, campaigns } = setup();
|
|
const withNine = '5575999091780';
|
|
const withoutNine = '557599091780';
|
|
const oldLeadId = addLead(db, 'old', withNine);
|
|
const latestLeadId = addLead(db, 'latest', withoutNine);
|
|
addHistoricalSend(db, campaigns[0], {
|
|
leadId: oldLeadId,
|
|
numero: withNine,
|
|
suffix: 'old',
|
|
sentAt: '2026-07-29T10:00:00.000Z'
|
|
});
|
|
addHistoricalSend(db, campaigns[1], {
|
|
leadId: latestLeadId,
|
|
numero: withoutNine,
|
|
suffix: 'latest',
|
|
sentAt: '2026-07-29T11:00:00.000Z'
|
|
});
|
|
db.prepare(
|
|
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at, updated_at)
|
|
VALUES (?, 'interessado_gti_vendas', '2026-07-20T09:00:00.000Z', '2026-07-20T09:00:00.000Z')`
|
|
).run(latestLeadId);
|
|
|
|
const mutable = ['preview', 'approved', 'scheduled'].map((status, index) => {
|
|
const leadId = addLead(db, `mutable-${status}`, index % 2 ? withoutNine : withNine);
|
|
return addQueue(db, campaigns[index], {
|
|
leadId,
|
|
numero: index % 2 ? withoutNine : withNine,
|
|
suffix: status,
|
|
status
|
|
});
|
|
});
|
|
|
|
const event = {
|
|
messageId: 'provider-inbound-1',
|
|
jid: `${withNine}@s.whatsapp.net`,
|
|
number: withNine,
|
|
receivedAt: '2026-07-29T12:00:00.000Z'
|
|
};
|
|
assert.deepEqual(recordInboundReply(db, event), {
|
|
inserted: true,
|
|
leadId: latestLeadId,
|
|
cancelled: 3
|
|
});
|
|
assert.deepEqual(recordInboundReply(db, event), {
|
|
inserted: false,
|
|
leadId: latestLeadId,
|
|
cancelled: 0
|
|
});
|
|
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT lead_id, numero, received_at FROM outreach_inbound_replies').get() },
|
|
{ lead_id: latestLeadId, numero: withNine, received_at: event.receivedAt }
|
|
);
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT stage, first_reply_at, updated_at FROM outreach_lead_states WHERE lead_id = ?').get(latestLeadId) },
|
|
{ stage: 'respondeu', first_reply_at: '2026-07-20T09:00:00.000Z', updated_at: event.receivedAt }
|
|
);
|
|
assert.deepEqual(
|
|
db.prepare('SELECT lead_id, campaign_id, stage, created_at FROM outreach_stage_events').all().map((row) => ({ ...row })),
|
|
[{ lead_id: latestLeadId, campaign_id: campaigns[1].id, stage: 'respondeu', created_at: event.receivedAt }]
|
|
);
|
|
assert.deepEqual(
|
|
db.prepare(`SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id IN (${mutable.map(() => '?').join(',')}) ORDER BY id`).all(...mutable).map((row) => ({ ...row })),
|
|
mutable.map(() => ({ status: 'cancelled', cancel_reason: 'inbound_reply' }))
|
|
);
|
|
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_opt_outs').get().total, 0);
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS total FROM outreach_lead_states WHERE stage <> 'respondeu' AND lead_id = ?").get(latestLeadId).total, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('inbound vence corrida no beforeSend e impede chamada ao provedor', async () => {
|
|
const { db, campaigns } = setup();
|
|
const campaign = campaigns[0];
|
|
db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id);
|
|
const numero = '5575999091780';
|
|
const leadId = addLead(db, 'race', numero);
|
|
saveWhatsAppVerification(db, { numero, status: 'confirmado' });
|
|
const queueId = addQueue(db, campaign, { leadId, numero, suffix: 'race', status: 'scheduled' });
|
|
let releaseGuard;
|
|
let guardStarted;
|
|
const blocked = new Promise((resolve) => { guardStarted = resolve; });
|
|
const gate = new Promise((resolve) => { releaseGuard = resolve; });
|
|
let providerCalls = 0;
|
|
const whatsapp = {
|
|
getStatus: () => ({ state: 'READY' }),
|
|
sendText: async (args) => {
|
|
const originalBeforeSend = args.beforeSend;
|
|
args.beforeSend = async () => {
|
|
guardStarted();
|
|
await gate;
|
|
return originalBeforeSend();
|
|
};
|
|
const context = await args.beforeSend();
|
|
providerCalls += 1;
|
|
const result = { messageId: 'must-not-send' };
|
|
await args.afterSend(result, null, context);
|
|
return result;
|
|
}
|
|
};
|
|
|
|
const running = runDueCampaignQueue({
|
|
db,
|
|
whatsapp,
|
|
now: new Date('2026-07-29T12:00:00.000Z'),
|
|
log: () => {}
|
|
});
|
|
await blocked;
|
|
assert.deepEqual(recordInboundReply(db, {
|
|
messageId: 'provider-race-1',
|
|
jid: `${numero}@s.whatsapp.net`,
|
|
number: numero,
|
|
receivedAt: '2026-07-29T11:59:59.000Z'
|
|
}), { inserted: true, leadId: null, cancelled: 1 });
|
|
releaseGuard();
|
|
const summary = await running;
|
|
|
|
assert.equal(providerCalls, 0);
|
|
assert.equal(summary.enviados, 0);
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) },
|
|
{ status: 'cancelled', cancel_reason: 'inbound_reply' }
|
|
);
|
|
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM outreach_sends').get().total, 0);
|
|
db.close();
|
|
});
|