carregarReplies() swallowed any fetch error, leaving whatever was already in #pending-replies — on first load, the static "Nenhuma resposta pendente." placeholder. A failed load was visually indistinguishable from a genuine empty state, so a customer reply could go unnoticed with no error surfaced (every other action in this file already toasts on failure). Now shows an explicit error state and toasts, and the fetch mock harness in campaign-ui.test.mjs gained toast-call tracking to prove it. Found via a full-project audit.
716 lines
32 KiB
JavaScript
716 lines
32 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 toasts = [];
|
|
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(message) { toasts.push(message); },
|
|
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, toasts };
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// Regressao: uma falha ao buscar /replies/pending nao pode deixar o
|
|
// placeholder "nenhuma pendente" no lugar — os dois ficam indistinguiveis e
|
|
// uma resposta de cliente passaria despercebida.
|
|
test('pending replies fetch failure shows an error state, not the empty-state placeholder', 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 origGet = app.context.API.get;
|
|
app.context.API.get = async (url) => {
|
|
app.gets.push(url);
|
|
if (url === '/api/campaigns/replies/pending') throw new Error('falha de rede');
|
|
if (url === '/api/campaigns') return { campaigns };
|
|
if (url.includes('/metrics')) return { metrics: {} };
|
|
return origGet(url);
|
|
};
|
|
|
|
vm.runInContext(await read('public/js/campanhas.js'), app.context);
|
|
await app.context.window.Campanhas.ativar();
|
|
|
|
const t = nodeText(app.element('pending-replies'));
|
|
assert.doesNotMatch(t, /nenhuma resposta pendente/i);
|
|
assert.match(t, /erro/i);
|
|
assert.ok(app.toasts.some((message) => /erro/i.test(message)), app.toasts.join(' | '));
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|