Files
COMERCIAL-GTI/tests/campaign-ui.test.mjs
T
Gustavo 18d427e2bd Add Configuracoes review section for shared numbers
Adds an admin-only "Numeros compartilhados entre leads" section to the
settings modal, wired to the Task 4 shared-numbers routes: lists
detected groups, lets an admin liberar (with an inline motivo input)
or bloquear de novo. Updates the two frontend VM-based test files'
default API mocks to answer GET /api/outreach/shared-numbers so
existing settings-modal tests keep passing, and adds a new
interaction test covering the liberar round trip.
2026-07-31 16:33:03 -03:00

690 lines
30 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
function fakeElement(id = '') {
const listeners = {};
const attributes = new Map();
const classes = new Set();
const node = {
id, nodeType: 1, tagName: '', listeners, attributes,
children: [], className: '', textContent: '', value: '', checked: false,
disabled: false, open: false, options: [], selectedOptions: [], dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, force) => {
const active = force === undefined ? !classes.has(name) : force;
if (active) classes.add(name); else classes.delete(name);
return active;
}
},
addEventListener: (type, handler) => { listeners[type] = handler; },
append(...children) { this.children.push(...children.flat()); },
replaceChildren(...children) { this.children = children.flat(); },
querySelectorAll: () => [],
querySelector: (sel) => {
for (const child of node.children) {
if (sel.startsWith('.') && child.className && child.className.split(' ').includes(sel.slice(1))) return child;
if (sel.startsWith('#') && child.id === sel.slice(1)) return child;
if (child.tagName && child.tagName.toLowerCase() === sel.toLowerCase()) return child;
}
return null;
},
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) || null,
setAttribute: (name, value) => attributes.set(name, value),
showModal() { this.open = true; },
close() { this.open = false; listeners.close?.(); },
click() { return listeners.click?.({ preventDefault() {}, stopPropagation() {}, target: this }); }
};
return node;
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function nodeText(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return `${node?.textContent || ''}${(node?.children || []).map(nodeText).join('')}`;
}
async function executeApp({ get, post, patch, del, put } = {}) {
const elements = new Map();
const timers = [];
const gets = [];
const posts = [];
const element = (id) => {
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
};
const apiGet = async (url) => {
gets.push(url);
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/outreach/shared-numbers') return { groups: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
if (get) return get(url);
throw new Error(`GET inesperado: ${url}`);
};
const context = {
API: {
get: apiGet,
post: async (url, body) => {
posts.push({ url, body });
if (post) return post(url, body);
if (url === '/api/outreach/enviar-manual') return { ok: true, status: 'aceito' };
return {};
},
patch: async (url, body) => {
posts.push({ url, body, method: 'PATCH' });
if (patch) return patch(url, body);
return {};
},
put: async (url, body) => {
posts.push({ url, body, method: 'PUT' });
if (put) return put(url, body);
return {};
},
delete: async (url, body) => {
posts.push({ url, body, method: 'DELETE' });
if (del) return del(url, body);
return {};
}
},
Captura: { init: async () => {} },
Tratamento: { bindEvents() {}, ativar() {} },
Campanhas: { bindEvents() {}, ativar() {} },
// Mirrors public/js/api.js's comCarregando — app.js expects it as a
// real global (plain <script> include order), but this sandbox only
// loads app.js, so it must be provided here too.
comCarregando: async (button, fn) => {
if (!button) return fn();
const jaCarregando = button.disabled;
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
try {
return await fn();
} finally {
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
}
},
clearTimeout() {},
dataBr: (value) => value || '—',
document: {
querySelectorAll: () => [],
querySelector: () => null
},
el: element,
fmt: new Intl.NumberFormat('pt-BR'),
h: (tag, attrs = {}, ...children) => {
const node = fakeElement();
node.tagName = tag.toUpperCase();
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) node.setAttribute(key, value);
}
node.append(...children);
const id = node.getAttribute('id') || node.id;
if (id && !elements.has(id)) elements.set(id, node);
return node;
},
setTimeout: (callback, delay) => {
timers.push({ callback, delay });
return timers.length;
},
toast() {},
window: { Campanhas: { bindEvents() {}, ativar() {} }, confirm: () => true, location: {} },
URLSearchParams
};
vm.createContext(context);
vm.runInContext(await read('public/js/app.js'), context);
await new Promise((resolve) => setImmediate(resolve));
return { context, element, elements, gets, posts, timers };
}
async function renderCampaign(app, campaigns, options = {}) {
const { queue = [], replies = [], metrics = null, previewResult = null } = options;
const origGet = app.context.API.get;
app.context.API.get = async (url) => {
app.gets.push(url);
if (url === '/api/auth/me') return { user: { username: 'teste', role: 'admin' } };
if (url === '/api/campaigns') return { campaigns };
if (/^\/api\/campaigns\/\d+$/.test(url)) {
const id = Number(url.match(/\d+/)[0]);
return campaigns.find((c) => c.id === id);
}
if (url.includes('/queue')) return { queue };
if (url === '/api/campaigns/replies/pending') return { pending: replies };
if (url.includes('/metrics')) return { metrics: metrics || {} };
if (url === '/api/settings') return { settings: {} };
if (url === '/api/outreach/templates') return { templates: [] };
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
if (url === '/api/outreach/shared-numbers') return { groups: [] };
if (url === '/api/whatsapp/status') return { state: 'READY' };
if (origGet) return origGet(url);
throw new Error(`GET inesperado (campaign): ${url}`);
};
vm.runInContext(await read('public/js/campanhas.js'), app.context);
await app.context.window.Campanhas.ativar();
}
test('campaign cards show status, week, daily limit and pause reason', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery Alimentação', produto: 'G-Delivery', status: 'paused', pause_reason: 'server_restart', week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], usage: { manual: { used: 2, limit: 10 }, campaign: { used: 1 } } },
{ id: 2, code: 'gti-food', name: 'GTI Vendas Alimentação', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 2, approved_daily_limit: 12, segments: ['restaurantes', 'mercados'], usage: { manual: { used: 0, limit: 10 }, campaign: { used: 0 } } }
];
await renderCampaign(app, campaigns);
const cards = app.element('campaign-cards');
assert.ok(cards.children.length >= 2);
const t1 = nodeText(cards.children[0]);
const t2 = nodeText(cards.children[1]);
assert.match(t1, /G-Delivery/);
assert.match(t1, /Semana 1/);
assert.match(t1, /10\/dia/);
assert.match(t1, /server_restart/);
assert.match(t1, /Pausada/);
assert.match(t1, /Manual: 2/);
assert.match(t1, /Automático: 1/);
assert.match(t2, /GTI Vendas/);
assert.match(t2, /Semana 2/);
assert.match(t2, /12\/dia/);
assert.doesNotMatch(t2, /server_restart/);
assert.match(t2, /Pronta/);
});
test('campaign cards have prepare action button', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'paused', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
await renderCampaign(app, campaigns);
const card = app.element('campaign-cards').children[0];
assert.match(nodeText(card), /[Pp]reparar/);
const btn = app.element('btn-preview-1');
assert.ok(btn);
await btn.click();
assert.ok(app.posts.some((p) => /\/api\/campaigns\/1\/prepare/.test(p.url)));
});
test('"Avançar semana" calls PUT /api/campaigns/:id/week with week+1, and is hidden once week 4 is reached', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 2, approved_daily_limit: 12, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 },
{ id: 2, code: 'gti-food', name: 'GTI Vendas', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 4, approved_daily_limit: 18, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
let putWeekBody = null;
app.context.API.put = async (url, body) => {
app.posts.push({ url, body, method: 'PUT' });
if (/\/api\/campaigns\/1\/week/.test(url)) putWeekBody = body;
return { campaign: { id: 1, week_number: body.week } };
};
await renderCampaign(app, campaigns);
const advanceBtn1 = app.element('btn-advance-week-1');
assert.ok(advanceBtn1, 'expected an advance-week button for a week-2 campaign');
await advanceBtn1.click();
assert.ok(putWeekBody);
assert.equal(putWeekBody.week, 3);
// Week 4 is the max (WEEK_LIMITS only has entries for 1-4) — no button to go past it.
assert.equal(app.element('btn-advance-week-2').tagName, '');
});
test('preview rows show exact server-rendered text not editable after approval', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá, pessoal da Pizzaria Central, tudo bem? Me chamo Gustavo, sou representante do Grupo GTI.', segmento: 'restaurantes', selection_reason: 'gdelivery-food:prioridade-1', status: 'preview' },
{ id: 102, lead_id: 2, empresa: 'Mercado do João', rendered_message: 'Olá! Me chamo Gustavo, sou representante do Grupo GTI e escrevo para apresentar meu serviço.', segmento: 'mercados', selection_reason: 'gdelivery-food:prioridade-2', status: 'preview' }
];
app.context.API.post = async (url) => {
app.posts.push({ url });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
return {};
};
await renderCampaign(app, campaigns, { queue });
// Trigger queue loading by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const qt = nodeText(app.element('campaign-queue'));
assert.match(qt, /Pizzaria Central/);
assert.match(qt, /Olá, pessoal da Pizzaria Central/);
assert.match(qt, /Mercado do João/);
assert.match(qt, /Olá! Me chamo Gustavo/);
const approveBtn = app.element('btn-approve');
assert.ok(approveBtn);
assert.match(nodeText(approveBtn), /Aprovar/);
});
test('"Ver fila" shows queue items past preview (scheduled/sent/cancelled), not just preview — a running campaign has no preview items left', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá!', segmento: 'restaurantes', selection_reason: 'p-1', status: 'sent' },
{ id: 102, lead_id: 2, empresa: 'Mercado do João', rendered_message: 'Oi!', segmento: 'mercados', selection_reason: 'p-2', status: 'scheduled' },
{ id: 103, lead_id: 3, empresa: 'Padaria Central', rendered_message: 'Olá!', segmento: 'padarias', selection_reason: 'p-3', status: 'cancelled' }
];
await renderCampaign(app, campaigns, { queue });
let queueUrl = null;
const getAfterRender = app.context.API.get;
app.context.API.get = async (url) => {
if (url.includes('/queue')) queueUrl = url;
return getAfterRender(url);
};
const verFilaBtn = app.element('btn-queue-1');
assert.ok(verFilaBtn);
await verFilaBtn.click();
// No status query string: the backend returns every status when it's omitted.
assert.ok(queueUrl && !queueUrl.includes('status='), `expected no status filter, got ${queueUrl}`);
const t = nodeText(app.element('campaign-queue'));
assert.match(t, /Pizzaria Central/);
assert.match(t, /Mercado do João/);
assert.match(t, /Padaria Central/);
assert.match(t, /Enviado/);
assert.match(t, /Agendado/);
assert.match(t, /Cancelado/);
// Cancelled rows aren't removable on the backend — no "Remover" button for them.
assert.equal(app.element('btn-remove-queue-103').tagName, '');
assert.ok(app.element('btn-remove-queue-102'));
// No preview items in this queue, so there is nothing to approve.
assert.equal(app.element('btn-approve').tagName, '');
});
test('"Ver fila" button is available for paused campaigns too', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'paused', pause_reason: 'motivo', week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
await renderCampaign(app, campaigns);
assert.ok(app.element('btn-queue-1'));
});
test('per-row removal calls DELETE /api/campaigns/:id/queue/:queueId', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Pizzaria Central', rendered_message: 'Olá!', segmento: 'restaurantes', selection_reason: 'prioridade-1', status: 'preview' }
];
let deletedId = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
return {};
};
app.context.API.delete = async (url) => {
app.posts.push({ url, method: 'DELETE' });
const match = url.match(/\/api\/campaigns\/(\d+)\/queue\/(\d+)/);
if (match) deletedId = Number(match[2]);
return { ok: true };
};
await renderCampaign(app, campaigns, { queue });
// Load queue by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const removeBtn = app.element('btn-remove-queue-101');
assert.ok(removeBtn);
await removeBtn.click();
assert.equal(deletedId, 101);
assert.ok(app.posts.some((p) => p.method === 'DELETE' && /\/api\/campaigns\/1\/queue\/101$/.test(p.url)));
});
test('explicit approval flow calls POST /api/campaigns/:id/approve with queueIds and date (backend requires date; see generateDailySchedule)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const queue = [
{ id: 101, lead_id: 1, empresa: 'Teste', rendered_message: 'Mensagem de teste', segmento: 'restaurantes', selection_reason: 'prioridade-1', status: 'preview' }
];
let approved = false;
let approveBody = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/\d+\/prepare/.test(url)) return { summary: { checked: 5, previewed: 3, skipped: 0 } };
if (/\/api\/campaigns\/\d+\/approve/.test(url)) {
approved = true;
approveBody = body;
return { ok: true, scheduled: queue.length };
}
return {};
};
await renderCampaign(app, campaigns, { queue });
// Load queue by clicking prepare
const prepareBtn = app.element('btn-preview-1');
assert.ok(prepareBtn);
await prepareBtn.click();
const approveBtn = app.element('btn-approve');
assert.ok(approveBtn);
await approveBtn.click();
assert.ok(approved);
assert.ok(app.posts.some((p) => p.url.includes('/approve')));
assert.ok(approveBody);
assert.equal(approveBody.queueIds.length, 1);
assert.equal(approveBody.queueIds[0], 101);
// generateDailySchedule() on the backend rejects a missing/invalid date with
// 400 "data local invalida; use YYYY-MM-DD" — date is NOT optional there.
assert.match(approveBody.date, /^\d{4}-\d{2}-\d{2}$/);
});
test('start control calls POST /api/campaigns/:id/start (API has no patch method)', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }
];
// Real api.js only defines get/post/put/delete — no patch. Remove the test
// double's patch stub so this test fails the way the real browser does if
// campanhas.js ever calls API.patch again.
delete app.context.API.patch;
let postedStart = false;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (url.includes('/start')) postedStart = true;
return { ok: true };
};
await renderCampaign(app, campaigns);
// The confirm listener is attached once in bindEvents(), not per "Iniciar"
// click — otherwise it would accumulate a new handler (closing over a stale
// campaignId) on every open, firing multiple /start calls on one confirm.
app.context.window.Campanhas.bindEvents();
const startBtn = app.element('btn-start-1');
assert.ok(startBtn);
await startBtn.click();
const modal = app.element('modal-campaign-start');
assert.ok(modal.open);
const confirmBtn = app.element('btn-confirm-start');
assert.ok(confirmBtn);
await confirmBtn.click();
assert.ok(postedStart);
});
test('confirming "Iniciar" does not fire /start for a previously-opened, cancelled campaign (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [
{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 },
{ id: 3, code: 'gti-varejo', name: 'GTI Varejo', produto: 'GTI Vendas', status: 'ready', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['lojas'], manual_usage: 0, auto_usage: 0 }
];
delete app.context.API.patch;
const startedCampaignIds = [];
app.context.API.post = async (url) => {
app.posts.push({ url });
const match = url.match(/\/api\/campaigns\/(\d+)\/start/);
if (match) startedCampaignIds.push(Number(match[1]));
return { ok: true };
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
// Open "Iniciar" for campaign 1, then cancel (never confirm) — this used to
// leave a dangling listener closed over campaignId=1.
await app.element('btn-start-1').click();
app.element('modal-campaign-start').close();
// Now open and confirm "Iniciar" for campaign 3 only.
await app.element('btn-start-3').click();
await app.element('btn-confirm-start').click();
assert.deepEqual(startedCampaignIds, [3], `expected only campaign 3 to start, got ${JSON.stringify(startedCampaignIds)}`);
});
test('separate manual and automatic usage display', async () => {
const app = await executeApp();
const campaigns = [{
id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery',
status: 'paused', pause_reason: null, week_number: 1, approved_daily_limit: 15,
segments: ['restaurantes'], usage: { manual: { used: 4, limit: 10 }, campaign: { used: 7 } }
}];
await renderCampaign(app, campaigns);
const t = nodeText(app.element('campaign-cards').children[0]);
assert.match(t, /Manual: 4/);
assert.match(t, /Automático: 7/);
assert.doesNotMatch(t, /11\/15/);
});
test('pending replies section shows reply items', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const replies = [
{ lead_id: 5, stage: 'respondeu', empresa: 'Padaria do Centro', whatsapp: '5511999999999', campaign_id: 1, updated_at: '2026-07-29T10:30:00Z', note: 'Olá, gostaria de mais informações' },
{ lead_id: 8, stage: 'respondeu', empresa: 'Açougue do João', whatsapp: '5511888888888', campaign_id: 1, updated_at: '2026-07-29T11:00:00Z', note: 'Não tenho interesse no momento' }
];
await renderCampaign(app, campaigns, { replies });
const t = nodeText(app.element('pending-replies'));
assert.match(t, /Padaria do Centro/);
assert.match(t, /Açougue do João/);
assert.match(t, /mais informações/);
assert.match(t, /não tenho interesse/i);
});
test('stage classification card shows stage area', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
await renderCampaign(app, campaigns);
const t = nodeText(app.element('stage-classification'));
assert.match(t, /classificar/i);
});
test('appointment form triggers POST on schedule', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
let appointmentPosted = false;
let appointmentBody = null;
app.context.API.post = async (url, body) => {
app.posts.push({ url, body });
if (/\/api\/campaigns\/leads\/\d+\/appointments/.test(url)) {
appointmentPosted = true;
appointmentBody = body;
return { ok: true, id: 1 };
}
return {};
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
const scheduleBtn = app.element('btn-schedule-appointment');
assert.ok(scheduleBtn);
await scheduleBtn.click();
const leadInput = app.element('inp-appointment-lead-id');
if (leadInput) leadInput.value = '5';
const confirmBtn = app.element('btn-confirm-appointment');
assert.ok(confirmBtn);
await confirmBtn.click();
assert.ok(appointmentBody);
assert.ok('participantName' in appointmentBody);
assert.ok('companyName' in appointmentBody);
assert.ok(appointmentPosted);
});
test('opening the appointment modal twice and confirming once posts exactly once (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
let postCount = 0;
app.context.API.post = async (url) => {
if (/\/api\/campaigns\/leads\/\d+\/appointments/.test(url)) postCount += 1;
return { ok: true, id: 1 };
};
await renderCampaign(app, campaigns);
app.context.window.Campanhas.bindEvents();
await app.element('btn-schedule-appointment').click();
app.element('modal-campaign-appointment').close(); // cancel without confirming
await app.element('btn-schedule-appointment').click(); // reopen
app.element('inp-appointment-lead-id').value = '5';
await app.element('btn-confirm-appointment').click();
assert.equal(postCount, 1, `expected exactly one POST, got ${postCount}`);
});
test('classifying a stage for lead B after opening (without confirming) lead A does not update lead A (regression: listener accumulation)', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const replies = [
{ lead_id: 5, stage: 'respondeu', empresa: 'Padaria do Centro', whatsapp: '5511999999999', campaign_id: 1, updated_at: '2026-07-29T10:30:00Z', note: 'oi' },
{ lead_id: 8, stage: 'respondeu', empresa: 'Açougue do João', whatsapp: '5511888888888', campaign_id: 1, updated_at: '2026-07-29T11:00:00Z', note: 'oi' }
];
const updatedLeadIds = [];
app.context.API.put = async (url, body) => {
app.posts.push({ url, body, method: 'PUT' });
const match = url.match(/\/api\/campaigns\/leads\/(\d+)\/stage/);
if (match) updatedLeadIds.push(Number(match[1]));
return { state: {} };
};
await renderCampaign(app, campaigns, { replies });
app.context.window.Campanhas.bindEvents();
const items = app.element('pending-replies').children;
const classificarBtns = items.map((item) => item.children.find((c) => /Classificar/.test(nodeText(c))));
// Open lead 5's dialog, cancel without saving.
await classificarBtns[0].click();
app.element('modal-campaign-stage').close();
// Open and save lead 8's dialog only.
await classificarBtns[1].click();
await app.element('btn-save-stage').click();
assert.deepEqual(updatedLeadIds, [8], `expected only lead 8 to be updated, got ${JSON.stringify(updatedLeadIds)}`);
});
test('metrics section contains segmento and variant filters', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const metrics = {
previewed: 10, approved: 8, provider: { aceito: 5, erro: 0, desconhecido: 0 }, replies: 1,
groups: { byProduct: {}, bySegment: {}, byVariant: {} }, firstResponseTimeAvg: 120
};
await renderCampaign(app, campaigns, { metrics });
const t = nodeText(app.element('metrics-filters'));
assert.match(t, /Segmento/);
assert.match(t, /Variante/);
});
test('metrics stats render using the real /metrics response shape ({ metrics: { previewed, approved, sent, replies, firstResponseTimeAvg } })', async () => {
const app = await executeApp();
const campaigns = [{ id: 1, code: 'gdelivery-food', name: 'G-Delivery', produto: 'G-Delivery', status: 'running', pause_reason: null, week_number: 1, approved_daily_limit: 10, segments: ['restaurantes'], manual_usage: 0, auto_usage: 0 }];
const realMetrics = {
previewed: 10, approved: 8, removed: 0, sent: 5, attempted: 6,
// provider.aceito deliberately differs from `sent`: it only tracks the
// transient "accepted by WhatsApp" status, not deliveries that progressed
// to entregue/lido — the UI must use the queue-based `sent` count, not this.
provider: { aceito: 2, erro: 0, desconhecido: 1 },
replies: 3, firstResponseTimeAvg: 120,
stages: { positive: 0, negative: 0, optOut: 0 },
appointments: { demos: 0, tests: 0 }, sales: 0,
groups: { byProduct: {}, bySegment: {}, byVariant: {} }
};
await renderCampaign(app, campaigns, { metrics: realMetrics });
// "Ver fila" sets selectedCampaignId, a prerequisite for carregarMetricsData().
const verFilaBtns = Array.from(app.element('campaign-cards').children[0].children)
.flatMap((row) => row.children || [])
.filter((node) => /Ver fila/.test(nodeText(node)));
assert.ok(verFilaBtns.length, 'expected a "Ver fila" button to exist for a running campaign');
await verFilaBtns[0].click();
const segmentoSelect = app.element('filtro-metrics-segmento');
assert.ok(segmentoSelect);
await segmentoSelect.listeners.change();
const t = nodeText(app.element('campaign-metrics'));
assert.match(t, /10/); // previsualizados
assert.match(t, /8/); // aprovados
assert.match(t, /5/); // enviados = metrics.sent (queue status), NOT attempted or provider.aceito
assert.match(t, /3/); // responderam
assert.match(t, /120 min/);
});
test('post-server-restart pause reason is displayed prominently', async () => {
const app = await executeApp();
const campaigns = [{
id: 1, code: 'gdelivery-food', name: 'G-Delivery Alimentação', produto: 'G-Delivery',
status: 'paused', pause_reason: 'server_restart', week_number: 1, approved_daily_limit: 10,
segments: ['restaurantes'], manual_usage: 0, auto_usage: 0
}];
await renderCampaign(app, campaigns);
const card = app.element('campaign-cards').children[0];
const t = nodeText(card);
assert.match(t, /Pausada/);
assert.match(t, /server_restart/);
assert.match(t, /[Pp]ausado: server_restart/);
// Verify pause reason is visible (not hidden)
const pauseEls = [];
for (const child of card.children) {
const childText = nodeText(child);
if (childText.includes('server_restart')) {
pauseEls.push(child);
}
}
assert.ok(pauseEls.length > 0);
for (const el of pauseEls) {
assert.doesNotMatch(el.className || '', /hidden/i);
}
});