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.
690 lines
30 KiB
JavaScript
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);
|
|
}
|
|
});
|