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.
477 lines
23 KiB
JavaScript
477 lines
23 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, setSetting } from '../src/db.mjs';
|
|
import { listCampaigns, saveAppointment, seedCampaigns, setLeadCommercialStage } from '../src/outreach/campaign-store.mjs';
|
|
import { createRouter } from '../src/server/router.mjs';
|
|
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
|
|
|
|
function setup({
|
|
state = 'READY',
|
|
now = new Date(2026, 6, 29, 12),
|
|
random = () => 0
|
|
} = {}) {
|
|
const db = openDb(':memory:');
|
|
seedCampaigns(db);
|
|
setSetting(db, 'outreach.mensagensPorDia', '7');
|
|
let checks = 0;
|
|
let sends = 0;
|
|
const whatsapp = {
|
|
getStatus: () => ({ state, connected: state === 'READY' }),
|
|
checkNumber: async (number) => {
|
|
checks += 1;
|
|
return { registered: true, numero: number, numero_resolvido: number };
|
|
},
|
|
sendText: async () => {
|
|
sends += 1;
|
|
throw new Error('rota de campanha nao pode enviar mensagens');
|
|
}
|
|
};
|
|
const router = createRouter();
|
|
registerCampaignRoutes(router, { db, whatsapp, random, now: () => now });
|
|
return { db, router, whatsapp, counts: () => ({ checks, sends }) };
|
|
}
|
|
|
|
async function invoke(router, method, pathname, {
|
|
body,
|
|
user = { role: 'admin' },
|
|
query = new URLSearchParams()
|
|
} = {}) {
|
|
const matched = router.match(method, pathname);
|
|
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
|
|
const calls = [];
|
|
await matched.handler(null, null, {
|
|
params: matched.params,
|
|
body,
|
|
user,
|
|
query,
|
|
json: (status, data) => calls.push({ status, data })
|
|
});
|
|
assert.equal(calls.length, 1);
|
|
return calls[0];
|
|
}
|
|
|
|
function campaignIds(db) {
|
|
return db.prepare('SELECT id FROM outreach_campaigns ORDER BY id').all().map((row) => row.id);
|
|
}
|
|
|
|
function addPreview(db, campaignId, suffix) {
|
|
const campaign = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1').get(campaignId);
|
|
const lead = db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score)
|
|
VALUES (?, ?, ?, 'restaurantes', 1, 90)`
|
|
).run(`lead-${campaignId}-${suffix}`, `Empresa ${suffix}`, `551199999${String(suffix).padStart(4, '0')}`);
|
|
return Number(db.prepare(
|
|
`INSERT INTO outreach_campaign_queue
|
|
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
|
|
VALUES (?, ?, ?, ?, ?, 'restaurantes', 'mensagem aprovada', 'teste', 'preview')`
|
|
).run(campaignId, Number(lead.lastInsertRowid), variant.id, campaign.produto, `551199999${String(suffix).padStart(4, '0')}`).lastInsertRowid);
|
|
}
|
|
|
|
function schedulePreview(db, queueId, hour = 9, minute = 30) {
|
|
const scheduledAt = new Date(2026, 6, 29, hour, minute).toISOString();
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = ? WHERE id = ?")
|
|
.run(scheduledAt, queueId);
|
|
}
|
|
|
|
test('lista, detalha e filtra fila sem misturar uso manual e automatico', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
addPreview(db, campaignId, 1);
|
|
db.prepare(
|
|
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
|
VALUES ('G-Delivery', '5511999999001', 'manual', 'aceito', 'manual')`
|
|
).run();
|
|
for (const [index, status] of ['pendente', 'enviado', 'entregue', 'lido', 'desconhecido', 'erro'].entries()) {
|
|
db.prepare(
|
|
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
|
VALUES ('G-Delivery', ?, 'manual', ?, 'manual')`
|
|
).run(`55118888000${index}`, status);
|
|
}
|
|
const automaticSendId = Number(db.prepare(
|
|
`INSERT INTO outreach_sends (produto, numero, mensagem_enviada, status, origem)
|
|
VALUES ('G-Delivery', '5511999999002', 'campanha', 'aceito', 'automatico')`
|
|
).run().lastInsertRowid);
|
|
const queued = addPreview(db, campaignId, 2);
|
|
schedulePreview(db, queued);
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(queued);
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent', outreach_send_id = ? WHERE id = ?")
|
|
.run(automaticSendId, queued);
|
|
|
|
const listed = await invoke(router, 'GET', '/api/campaigns', { user: { role: 'comercial' } });
|
|
assert.equal(listed.status, 200);
|
|
assert.equal(listed.data.campaigns.length, 3);
|
|
const campaign = listed.data.campaigns.find((item) => item.id === campaignId);
|
|
assert.deepEqual(campaign.usage, {
|
|
manual: { limit: 7, used: 6, remaining: 1 },
|
|
campaign: { limit: 10, used: 1, remaining: 9 }
|
|
});
|
|
|
|
const detail = await invoke(router, 'GET', `/api/campaigns/${campaignId}`, { user: { role: 'comercial' } });
|
|
assert.equal(detail.data.campaign.id, campaignId);
|
|
assert.deepEqual(detail.data.usage, campaign.usage);
|
|
const queue = await invoke(router, 'GET', `/api/campaigns/${campaignId}/queue`, {
|
|
user: { role: 'comercial' }, query: new URLSearchParams({ status: 'preview' })
|
|
});
|
|
assert.deepEqual(queue.data.queue.map((row) => row.status), ['preview']);
|
|
db.close();
|
|
});
|
|
|
|
test('todas as mutacoes rejeitam usuario nao-admin antes de alterar estado', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
const queueId = addPreview(db, campaignId, 3);
|
|
const requests = [
|
|
['POST', `/api/campaigns/${campaignId}/prepare`, {}],
|
|
['POST', `/api/campaigns/${campaignId}/approve`, { queueIds: [queueId], date: '2026-07-27' }],
|
|
['DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`, undefined],
|
|
['POST', `/api/campaigns/${campaignId}/start`, {}],
|
|
['POST', `/api/campaigns/${campaignId}/pause`, { reason: 'revisao' }],
|
|
['PUT', `/api/campaigns/${campaignId}/week`, { week: 2 }]
|
|
];
|
|
for (const [method, path, body] of requests) {
|
|
const response = await invoke(router, method, path, { body, user: { role: 'comercial' } });
|
|
assert.equal(response.status, 403, `${method} ${path}`);
|
|
}
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(queueId).status, 'preview');
|
|
assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1);
|
|
db.close();
|
|
});
|
|
|
|
test('preparacao usa verificacao gradual e nunca chama envio', async () => {
|
|
const { db, router, counts } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
db.prepare(
|
|
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score)
|
|
VALUES ('eligible', 'Hamburgueria Sol', '5511999990001', 'hamburguerias', 1, 100)`
|
|
).run();
|
|
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/prepare`, { body: {} });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.data.summary.previewed, 1);
|
|
assert.deepEqual(counts(), { checks: 1, sends: 0 });
|
|
db.close();
|
|
});
|
|
|
|
test('aprovacao exige IDs explicitos da propria campanha e faz transicao atomica', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId, otherCampaignId] = campaignIds(db);
|
|
const selected = addPreview(db, campaignId, 10);
|
|
const removed = addPreview(db, campaignId, 11);
|
|
const foreign = addPreview(db, otherCampaignId, 12);
|
|
const originalPayload = db.prepare(
|
|
'SELECT variant_id, rendered_message FROM outreach_campaign_queue WHERE id = ?'
|
|
).get(selected);
|
|
|
|
const missing = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { date: '2026-07-27' }
|
|
});
|
|
assert.equal(missing.status, 400);
|
|
|
|
const mixed = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { queueIds: [selected, foreign], date: '2026-07-27' }
|
|
});
|
|
assert.equal(mixed.status, 409);
|
|
assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id IN (?, ?) ORDER BY id').all(selected, removed).map((row) => row.status), ['preview', 'preview']);
|
|
|
|
const approved = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { queueIds: [selected], date: '2026-07-27' }
|
|
});
|
|
assert.equal(approved.status, 200);
|
|
assert.equal(approved.data.scheduled, 1);
|
|
const rows = db.prepare(
|
|
'SELECT id, status, variant_id, rendered_message, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
|
|
).all(campaignId);
|
|
assert.equal(rows[0].status, 'scheduled');
|
|
assert.match(rows[0].scheduled_at, /^2026-07-27T/);
|
|
assert.equal(rows[0].variant_id, originalPayload.variant_id);
|
|
assert.equal(rows[0].rendered_message, originalPayload.rendered_message);
|
|
const { variant_id: ignoredVariant, rendered_message: ignoredMessage, ...removedRow } = rows[1];
|
|
void ignoredVariant;
|
|
void ignoredMessage;
|
|
assert.deepEqual({ ...removedRow }, { id: removed, status: 'cancelled', scheduled_at: null, cancel_reason: 'removed_by_operator' });
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'ready');
|
|
db.close();
|
|
});
|
|
|
|
test('aprovacao reverte tudo quando a data ou janelas nao fornecem slots suficientes', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
const first = addPreview(db, campaignId, 20);
|
|
const second = addPreview(db, campaignId, 21);
|
|
db.prepare("UPDATE outreach_campaigns SET windows_json = '[{\"start\":\"09:30\",\"end\":\"09:30\"}]' WHERE id = ?").run(campaignId);
|
|
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { queueIds: [first, second], date: '2026-07-27' }
|
|
});
|
|
assert.equal(response.status, 409);
|
|
assert.deepEqual(db.prepare('SELECT status FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id').all(campaignId).map((row) => row.status), ['preview', 'preview']);
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused');
|
|
db.close();
|
|
});
|
|
|
|
test('aprovacao rejeita excesso somando fila ja ativa no mesmo dia local', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
const existing = Array.from({ length: 10 }, (_, index) => addPreview(db, campaignId, 100 + index));
|
|
existing.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
|
const selected = addPreview(db, campaignId, 111);
|
|
|
|
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { queueIds: [selected], date: '2026-07-29' }
|
|
});
|
|
assert.equal(response.status, 409);
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaign_queue WHERE id = ?').get(selected).status, 'preview');
|
|
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 10);
|
|
db.close();
|
|
});
|
|
|
|
test('remocao cancela somente item mutavel pertencente a campanha', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId, otherCampaignId] = campaignIds(db);
|
|
const removable = addPreview(db, campaignId, 30);
|
|
const foreign = addPreview(db, otherCampaignId, 31);
|
|
const sent = addPreview(db, campaignId, 32);
|
|
schedulePreview(db, sent);
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'reserved' WHERE id = ?").run(sent);
|
|
db.prepare("UPDATE outreach_campaign_queue SET status = 'sent' WHERE id = ?").run(sent);
|
|
|
|
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${foreign}`)).status, 404);
|
|
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${sent}`)).status, 409);
|
|
assert.equal((await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${removable}`)).status, 200);
|
|
assert.deepEqual({ ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(removable) }, {
|
|
status: 'cancelled', cancel_reason: 'removed_by_operator'
|
|
});
|
|
db.close();
|
|
});
|
|
|
|
test('remocao de item agendado preserva timestamp imutavel de auditoria', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
const queueId = addPreview(db, campaignId, 33);
|
|
schedulePreview(db, queueId);
|
|
const before = db.prepare('SELECT scheduled_at FROM outreach_campaign_queue WHERE id = ?').get(queueId).scheduled_at;
|
|
|
|
const response = await invoke(router, 'DELETE', `/api/campaigns/${campaignId}/queue/${queueId}`);
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual({ ...db.prepare('SELECT status, scheduled_at, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) }, {
|
|
status: 'cancelled', scheduled_at: before, cancel_reason: 'removed_by_operator'
|
|
});
|
|
db.close();
|
|
});
|
|
|
|
test('inicio exige fila agendada, WhatsApp READY e estado ready ou paused', async () => {
|
|
const noSchedule = setup();
|
|
const [campaignId] = campaignIds(noSchedule.db);
|
|
assert.equal((await invoke(noSchedule.router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} })).status, 409);
|
|
noSchedule.db.close();
|
|
|
|
const disconnected = setup({ state: 'DISCONNECTED' });
|
|
const [disconnectedId] = campaignIds(disconnected.db);
|
|
const queueId = addPreview(disconnected.db, disconnectedId, 40);
|
|
disconnected.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(queueId);
|
|
assert.equal((await invoke(disconnected.router, 'POST', `/api/campaigns/${disconnectedId}/start`, { body: {} })).status, 409);
|
|
assert.equal(disconnected.db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(disconnectedId).status, 'paused');
|
|
disconnected.db.close();
|
|
|
|
const ready = setup();
|
|
const [readyId] = campaignIds(ready.db);
|
|
const scheduled = addPreview(ready.db, readyId, 41);
|
|
ready.db.prepare("UPDATE outreach_campaign_queue SET status = 'scheduled', scheduled_at = '2026-07-27T09:30:00.000' WHERE id = ?").run(scheduled);
|
|
const started = await invoke(ready.router, 'POST', `/api/campaigns/${readyId}/start`, { body: {} });
|
|
assert.equal(started.status, 200);
|
|
assert.equal(started.data.campaign.status, 'running');
|
|
ready.db.close();
|
|
});
|
|
|
|
test('inicio rejeita dia local com tentativas ativas acima do limite aprovado', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 200 + index));
|
|
queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
|
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/start`, { body: {} });
|
|
assert.equal(response.status, 409);
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId).status, 'paused');
|
|
db.close();
|
|
});
|
|
|
|
test('reducao de semana rejeita limite abaixo da fila ativa do dia sem alterar controles', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
db.prepare('UPDATE outreach_campaigns SET week_number = 4, approved_daily_limit = 18 WHERE id = ?').run(campaignId);
|
|
const queueIds = Array.from({ length: 11 }, (_, index) => addPreview(db, campaignId, 300 + index));
|
|
queueIds.forEach((queueId, index) => schedulePreview(db, queueId, 9, 30 + index));
|
|
|
|
const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 1 } });
|
|
assert.equal(response.status, 409);
|
|
assert.deepEqual({ ...db.prepare('SELECT week_number, approved_daily_limit FROM outreach_campaigns WHERE id = ?').get(campaignId) }, {
|
|
week_number: 4, approved_daily_limit: 18
|
|
});
|
|
assert.equal(db.prepare("SELECT COUNT(*) total FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'scheduled'").get(campaignId).total, 11);
|
|
db.close();
|
|
});
|
|
|
|
test('pausa exige motivo e semana muda somente pelo mapa explicito', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
assert.equal((await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: {} })).status, 400);
|
|
const paused = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, { body: { reason: 'revisao diaria' } });
|
|
assert.equal(paused.status, 200);
|
|
assert.equal(paused.data.campaign.pause_reason, 'revisao diaria');
|
|
|
|
assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: {} })).status, 400);
|
|
assert.equal(db.prepare('SELECT week_number FROM outreach_campaigns WHERE id = ?').get(campaignId).week_number, 1);
|
|
for (const [week, limit] of [[1, 10], [2, 12], [3, 15], [4, 18]]) {
|
|
const changed = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week } });
|
|
assert.equal(changed.status, 200);
|
|
assert.equal(changed.data.campaign.week_number, week);
|
|
assert.equal(changed.data.campaign.approved_daily_limit, limit);
|
|
}
|
|
assert.equal((await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 5 } })).status, 400);
|
|
db.close();
|
|
});
|
|
|
|
test('pausa aceita somente ready, running ou paused e preserva estados terminais', async () => {
|
|
const { db, router } = setup();
|
|
const [, draftId, completedId] = campaignIds(db);
|
|
db.prepare("UPDATE outreach_campaigns SET status = 'draft' WHERE id = ?").run(draftId);
|
|
db.prepare("UPDATE outreach_campaigns SET status = 'completed' WHERE id = ?").run(completedId);
|
|
for (const campaignId of [draftId, completedId]) {
|
|
const response = await invoke(router, 'POST', `/api/campaigns/${campaignId}/pause`, {
|
|
body: { reason: 'nao deve alterar' }
|
|
});
|
|
assert.equal(response.status, 409);
|
|
}
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(draftId).status, 'draft');
|
|
assert.equal(db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(completedId).status, 'completed');
|
|
db.close();
|
|
});
|
|
|
|
test('falha operacional retorna 500 generico sem vazar mensagem SQLite', async () => {
|
|
const { db, router } = setup();
|
|
const [campaignId] = campaignIds(db);
|
|
db.exec(`
|
|
CREATE TRIGGER campaign_operational_failure
|
|
BEFORE UPDATE ON outreach_campaigns
|
|
BEGIN SELECT RAISE(FAIL, 'sqlite-secret-detail'); END;
|
|
`);
|
|
const response = await invoke(router, 'PUT', `/api/campaigns/${campaignId}/week`, { body: { week: 2 } });
|
|
assert.deepEqual(response, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } });
|
|
assert.doesNotMatch(JSON.stringify(response), /sqlite-secret-detail/i);
|
|
db.close();
|
|
});
|
|
|
|
test('aprovacao separa validacao conhecida de falha inesperada do agendador', async () => {
|
|
const invalid = setup();
|
|
const [invalidCampaignId] = campaignIds(invalid.db);
|
|
const invalidQueueId = addPreview(invalid.db, invalidCampaignId, 500);
|
|
const validation = await invoke(invalid.router, 'POST', `/api/campaigns/${invalidCampaignId}/approve`, {
|
|
body: { queueIds: [invalidQueueId], date: '2026-02-30' }
|
|
});
|
|
assert.equal(validation.status, 400);
|
|
assert.match(validation.data.erro, /data local invalida/i);
|
|
invalid.db.close();
|
|
|
|
const secret = 'SCHEDULE-SENTINEL-SECRET';
|
|
const unexpected = setup({ random: () => { throw new Error(secret); } });
|
|
const [campaignId] = campaignIds(unexpected.db);
|
|
const first = addPreview(unexpected.db, campaignId, 501);
|
|
const second = addPreview(unexpected.db, campaignId, 502);
|
|
const failure = await invoke(unexpected.router, 'POST', `/api/campaigns/${campaignId}/approve`, {
|
|
body: { queueIds: [first, second], date: '2026-07-29' }
|
|
});
|
|
assert.deepEqual(failure, { status: 500, data: { erro: 'falha interna ao atualizar campanha' } });
|
|
assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret, 'i'));
|
|
assert.deepEqual(unexpected.db.prepare('SELECT status FROM outreach_campaign_queue ORDER BY id').all().map((row) => row.status), ['preview', 'preview']);
|
|
unexpected.db.close();
|
|
});
|
|
|
|
test('GET /api/campaigns/replies/pending retorna lista de leads que responderam', async () => {
|
|
const { db, router } = setup();
|
|
const campaigns = listCampaigns(db);
|
|
const leadId1 = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-1', 'Lead Respondeu', '5511999991001')"
|
|
).run().lastInsertRowid);
|
|
const leadId2 = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-pending-2', 'Lead Avancado', '5511999991002')"
|
|
).run().lastInsertRowid);
|
|
setLeadCommercialStage(db, { leadId: leadId1, campaignId: campaigns[0].id, stage: 'respondeu', note: 'quer info' });
|
|
setLeadCommercialStage(db, { leadId: leadId2, campaignId: campaigns[0].id, stage: 'enviado' });
|
|
|
|
const response = await invoke(router, 'GET', '/api/campaigns/replies/pending');
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.data.pending.length, 1);
|
|
assert.equal(response.data.pending[0].lead_id, leadId1);
|
|
db.close();
|
|
});
|
|
|
|
test('PUT /api/campaigns/leads/:leadId/stage atualiza etapa comercial', async () => {
|
|
const { db, router } = setup();
|
|
const leadId = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-1', 'Lead Stage', '5511999991010')"
|
|
).run().lastInsertRowid);
|
|
|
|
const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, {
|
|
body: { stage: 'interessado_gti_vendas', note: 'demonstracao marcada' }
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.data.state.stage, 'interessado_gti_vendas');
|
|
db.close();
|
|
});
|
|
|
|
test('PUT /api/campaigns/leads/:leadId/stage rejeita etapa invalida', async () => {
|
|
const { db, router } = setup();
|
|
const leadId = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-stage-2', 'Lead Inv', '5511999991020')"
|
|
).run().lastInsertRowid);
|
|
|
|
const response = await invoke(router, 'PUT', `/api/campaigns/leads/${leadId}/stage`, {
|
|
body: { stage: 'etapa_inexistente' }
|
|
});
|
|
assert.equal(response.status, 400);
|
|
assert.match(JSON.stringify(response.data), /etapa comercial invalida/i);
|
|
db.close();
|
|
});
|
|
|
|
test('POST /api/campaigns/leads/:leadId/appointments cria agendamento', async () => {
|
|
const { db, router } = setup();
|
|
const campaigns = listCampaigns(db);
|
|
const leadId = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-1', 'Lead App', '5511999991030')"
|
|
).run().lastInsertRowid);
|
|
|
|
const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, {
|
|
body: {
|
|
campaignId: campaigns[0].id,
|
|
kind: 'demonstracao',
|
|
participantName: 'Maria',
|
|
companyName: 'Empresa App',
|
|
preferredDay: '2026-08-10',
|
|
preferredTime: '15:00'
|
|
}
|
|
});
|
|
assert.equal(response.status, 201);
|
|
assert.equal(response.data.appointment.kind, 'demonstracao');
|
|
assert.equal(response.data.appointment.participant_name, 'Maria');
|
|
db.close();
|
|
});
|
|
|
|
test('POST /api/campaigns/leads/:leadId/appointments rejeita demonstracao sem campos obrigatorios', async () => {
|
|
const { db, router } = setup();
|
|
const leadId = Number(db.prepare(
|
|
"INSERT INTO leads (dedup_key, empresa, whatsapp) VALUES ('route-app-2', 'Lead App Fail', '5511999991040')"
|
|
).run().lastInsertRowid);
|
|
|
|
const response = await invoke(router, 'POST', `/api/campaigns/leads/${leadId}/appointments`, {
|
|
body: { kind: 'demonstracao' }
|
|
});
|
|
assert.equal(response.status, 400);
|
|
db.close();
|
|
});
|
|
|
|
test('GET /api/campaigns/replies/pending funciona sem autenticacao de admin', async () => {
|
|
const { db, router } = setup();
|
|
const response = await invoke(router, 'GET', '/api/campaigns/replies/pending', { user: { role: 'comercial' } });
|
|
assert.equal(response.status, 200);
|
|
assert.ok(Array.isArray(response.data.pending));
|
|
db.close();
|
|
});
|