Tracks the app that was already in place (capture, treatment, outreach, WhatsApp integration, server, frontend, tests, docs) so future feature work can branch and use isolated worktrees.
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();
|
|
});
|