pg-prev/pg-next deliberately skip comCarregando (it would fight the page-boundary disabled-state logic), so nothing stopped two rapid clicks from firing overlapping GET /api/leads requests. If the earlier page's response resolved after the later one, it silently overwrote the table, pager text and prev/next disabled state with a page the operator was no longer viewing. Adds a per-call sequence number in carregarTabela(); a response is only applied if no newer call has started since, covering every caller (pager clicks, filter changes, tab activation) with one guard. Found via a full-project audit; verified the regression test fails against the pre-fix code and passes against the fix.
632 lines
27 KiB
JavaScript
632 lines
27 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: () => [],
|
|
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;
|
|
let 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({ whatsappState = 'READY', statusResponses = [], qrResponses = [], get, post } = {}) {
|
|
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 statusResponses.length ? statusResponses.shift() : { state: whatsappState };
|
|
if (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null };
|
|
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 {};
|
|
},
|
|
put: async () => ({}),
|
|
delete: async () => ({})
|
|
},
|
|
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: (selector) => selector === '#tabela-leads tbody' ? element('tabela-leads-tbody') : 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);
|
|
return node;
|
|
},
|
|
setTimeout: (callback, delay) => {
|
|
timers.push({ callback, delay });
|
|
return timers.length;
|
|
},
|
|
toast() {},
|
|
URLSearchParams,
|
|
window: { Campanhas: { bindEvents() {}, ativar() {} }, confirm: () => true, location: {} }
|
|
};
|
|
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 renderTreatment(app, rows) {
|
|
app.context.API.get = async (url) => {
|
|
if (url === '/api/leads/facets') return { ufs: [], interesses: [], produtos: [], segmentos: [], cidades: [] };
|
|
if (url.startsWith('/api/dashboard/summary')) return {
|
|
total: rows.length, com_whatsapp: rows.length, p1: rows.length, p2: 0, p3: 0, p4: 0,
|
|
potencial_pct: 100, cobertura_pct: 0, exportados: 0, disponiveis: rows.length,
|
|
p1_pct: 100, p2_pct: 0, p3_pct: 0, p4_pct: 0, meta_diaria: 10,
|
|
meta_pct: 0, hoje_exportados: 0, dias_estimados: 1
|
|
};
|
|
if (url.startsWith('/api/dashboard/evolution')) return { evolution: [] };
|
|
if (url.startsWith('/api/dashboard/oportunidade')) return { oportunidade: null };
|
|
if (url.startsWith('/api/dashboard/rankings')) return { ranking: [] };
|
|
if (url.startsWith('/api/leads?')) return { rows, total: rows.length, page: 1, pageSize: 50 };
|
|
if (url === '/api/exports?limit=12') return { exports: [] };
|
|
if (/^\/api\/leads\/\d+$/.test(url)) return rows.find((lead) => url.endsWith(`/${lead.id}`));
|
|
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/whatsapp/status') return { state: 'READY' };
|
|
throw new Error(`GET inesperado no tratamento: ${url}`);
|
|
};
|
|
vm.runInContext(await read('public/js/tratamento.js'), app.context);
|
|
await app.context.window.Tratamento.ativar();
|
|
return app.element('tabela-leads-tbody');
|
|
}
|
|
|
|
test('interface e arquivos operacionais usam somente a identidade pessoal', async () => {
|
|
const files = await Promise.all([
|
|
'public/index.html',
|
|
'public/login.html',
|
|
'public/js/app.js',
|
|
'public/js/tratamento.js',
|
|
'public/css/app.css',
|
|
'.env.example',
|
|
'README.md',
|
|
'launcher/Program.cs',
|
|
'scripts/build-launcher.ps1'
|
|
].map(read));
|
|
const operationalText = files.join('\n');
|
|
|
|
assert.match(operationalText, /Máquina Comercial Pessoal/);
|
|
assert.doesNotMatch(operationalText, /Grupo GTI|GTI-|GTI_|Z-PRO|ZPRO|WABA|GChat/i);
|
|
await assert.rejects(fs.access(path.join(root, 'public', 'img', 'logo-gti.png')));
|
|
await assert.rejects(fs.access(path.join(root, 'logo-gti-256x256.png')));
|
|
await assert.rejects(fs.access(path.join(root, 'INICIAR-GTI.bat')));
|
|
await fs.access(path.join(root, 'INICIAR-MAQUINA-PESSOAL.bat'));
|
|
await fs.access(path.join(root, 'launcher', 'Maquina-Comercial-Pessoal.csproj'));
|
|
});
|
|
|
|
test('runtime pessoal usa cookie, IDs e variavel de ambiente pessoais', async () => {
|
|
const runtimeText = (await Promise.all([
|
|
'src/server/session.mjs',
|
|
'src/lead-engine.mjs',
|
|
'src/scoring.mjs',
|
|
'src/sources/common.mjs'
|
|
].map(read))).join('\n');
|
|
|
|
assert.match(runtimeText, /maquina_pessoal_session/);
|
|
assert.match(runtimeText, /PERSONAL_COMMERCIAL_WHATSAPP/);
|
|
assert.match(runtimeText, /MCP-/);
|
|
assert.doesNotMatch(runtimeText, /Grupo GTI|GTI-|GTI_/i);
|
|
});
|
|
|
|
test('catalogo de campanhas limita a identidade Grupo GTI aos produtos aprovados', async () => {
|
|
const catalogText = await read('src/outreach/campaign-catalog.mjs');
|
|
assert.match(catalogText, /Grupo GTI/);
|
|
assert.match(catalogText, /GTI Vendas/);
|
|
assert.match(catalogText, /G-Delivery/);
|
|
assert.doesNotMatch(catalogText, /Z-PRO|ZPRO|WABA|GChat/i);
|
|
});
|
|
|
|
test('fontes usam os nomes de ambiente documentados', async () => {
|
|
const envText = await read('src/env.mjs');
|
|
const placesText = await read('src/sources/google-places.mjs');
|
|
assert.match(envText, /process\.env\.CNPJ_BASE_DIR/);
|
|
assert.doesNotMatch(envText, /APP_CNPJ_BASE_DIR/);
|
|
assert.match(placesText, /process\.env\.GOOGLE_PLACES_API_KEY/);
|
|
assert.doesNotMatch(placesText, /GOOGLE_MAPS_API_KEY/);
|
|
});
|
|
|
|
test('catalogo preserva segmentos com um servico neutro por segmento', async () => {
|
|
const config = JSON.parse(await read('prospeccao/config.json'));
|
|
assert.ok(config.segments.length > 0);
|
|
for (const segment of config.segments) {
|
|
assert.deepEqual(segment.products, [{ nome: 'Meu serviço', estrelas: 3 }]);
|
|
}
|
|
});
|
|
|
|
test('comando npm do launcher contorna somente a politica local de scripts', async () => {
|
|
const pkg = JSON.parse(await read('package.json'));
|
|
assert.match(pkg.scripts['build:launcher'], /-ExecutionPolicy Bypass -File scripts\/build-launcher\.ps1/);
|
|
const buildScript = await read('scripts/build-launcher.ps1');
|
|
assert.match(buildScript, /Get-Command dotnet -ErrorAction SilentlyContinue/);
|
|
});
|
|
|
|
test('launcher carrega APP_PORT do .env do root sem sobrescrever ambiente real', async () => {
|
|
const source = await read('launcher/Program.cs');
|
|
assert.match(source, /LoadDotEnv\(root\)/);
|
|
assert.match(source, /BaseUrl\(root\)/);
|
|
assert.match(source, /Environment\.GetEnvironmentVariable\(key\)/);
|
|
assert.match(source, /StartsWith\("#"\)/);
|
|
});
|
|
|
|
// pg-prev/pg-next nao usam comCarregando de proposito (ver comentario em
|
|
// bindEvents), entao nada impede dois cliques rapidos de disparar duas
|
|
// consultas concorrentes. Antes, se a resposta da pagina anterior chegasse
|
|
// DEPOIS da pagina mais recente, ela sobrescrevia a tabela com dados de uma
|
|
// pagina que o operador ja nao esta mais vendo.
|
|
test('clique duplo em pg-next nao deixa resposta atrasada sobrescrever a pagina mais recente', async () => {
|
|
const app = await executeApp();
|
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
const page2 = deferred();
|
|
const page3 = deferred();
|
|
const rowFor = (label) => [{
|
|
id: 1, empresa: label, whatsapp: '', segmento: '', segmento_label: '',
|
|
prioridade: 1, score: 0, classificacao: 'bom', produto_principal: '', exported_at: null
|
|
}];
|
|
|
|
app.context.API.get = async (url) => {
|
|
if (url === '/api/leads/facets') return { ufs: [], interesses: [], produtos: [], segmentos: [], cidades: [] };
|
|
if (url.startsWith('/api/dashboard/summary')) return {
|
|
total: 1, com_whatsapp: 1, p1: 1, p2: 0, p3: 0, p4: 0,
|
|
potencial_pct: 100, cobertura_pct: 0, exportados: 0, disponiveis: 1,
|
|
p1_pct: 100, p2_pct: 0, p3_pct: 0, p4_pct: 0, meta_diaria: 10,
|
|
meta_pct: 0, hoje_exportados: 0, dias_estimados: 1
|
|
};
|
|
if (url.startsWith('/api/dashboard/evolution')) return { evolution: [] };
|
|
if (url.startsWith('/api/dashboard/oportunidade')) return { oportunidade: null };
|
|
if (url.startsWith('/api/dashboard/rankings')) return { ranking: [] };
|
|
if (url === '/api/exports?limit=12') return { exports: [] };
|
|
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/whatsapp/status') return { state: 'READY' };
|
|
if (url.startsWith('/api/leads?')) {
|
|
const page = new URLSearchParams(url.split('?')[1]).get('page');
|
|
if (page === '1') return { rows: rowFor('Pagina 1'), total: 150, page: 1, pageSize: 50 };
|
|
if (page === '2') { await page2.promise; return { rows: rowFor('Pagina 2 (atrasada)'), total: 150, page: 2, pageSize: 50 }; }
|
|
if (page === '3') { await page3.promise; return { rows: rowFor('Pagina 3'), total: 150, page: 3, pageSize: 50 }; }
|
|
throw new Error(`pagina inesperada: ${page}`);
|
|
}
|
|
throw new Error(`GET inesperado no tratamento: ${url}`);
|
|
};
|
|
|
|
vm.runInContext(await read('public/js/tratamento.js'), app.context);
|
|
await app.context.window.Tratamento.ativar();
|
|
app.context.window.Tratamento.bindEvents();
|
|
|
|
app.element('pg-next').click(); // pagina 1 -> 2, fica presa em page2.promise
|
|
await tick();
|
|
app.element('pg-next').click(); // pagina 2 -> 3, fica presa em page3.promise
|
|
await tick();
|
|
|
|
// Resolve fora de ordem: a pagina 3 (mais recente) responde primeiro.
|
|
page3.resolve();
|
|
await tick();
|
|
await tick();
|
|
page2.resolve();
|
|
await tick();
|
|
await tick();
|
|
|
|
const t = nodeText(app.element('tabela-leads-tbody'));
|
|
assert.match(t, /Pagina 3/);
|
|
assert.doesNotMatch(t, /atrasada/);
|
|
});
|
|
|
|
test('tratamento separa validade do telefone da verificacao de WhatsApp', async () => {
|
|
const html = await read('public/index.html');
|
|
const treatment = await read('public/js/tratamento.js');
|
|
|
|
assert.match(html, /Telefone: todos/);
|
|
assert.match(html, /Com telefone/);
|
|
assert.match(html, /Sem telefone/);
|
|
assert.match(html, /<th>Telefone<\/th>/);
|
|
assert.match(html, /empresa, CNPJ ou telefone/i);
|
|
assert.doesNotMatch(treatment, /WhatsApp v[aá]lido/i);
|
|
assert.doesNotMatch(treatment, /não tem WhatsApp cadastrado/i);
|
|
assert.match(treatment, /whatsapp_status/);
|
|
assert.match(treatment, /whatsapp_numero_resolvido/);
|
|
assert.match(treatment, /whatsapp_checked_at/);
|
|
for (const label of ['Não consultado', 'Confirmado', 'Não cadastrado', 'Erro temporário']) {
|
|
assert.match(treatment, new RegExp(label));
|
|
}
|
|
});
|
|
|
|
test('configuracoes mantem polling rapido em transicao e lento em estado estavel', async () => {
|
|
const transitional = await executeApp({ whatsappState: 'RECONNECTING' });
|
|
await transitional.elements.get('btn-settings').click();
|
|
assert.equal(transitional.timers.at(-1)?.delay, 1000);
|
|
|
|
const stable = await executeApp({ whatsappState: 'READY' });
|
|
await stable.elements.get('btn-settings').click();
|
|
assert.equal(stable.timers.at(-1)?.delay, 5000);
|
|
await stable.timers.at(-1).callback();
|
|
assert.equal(stable.timers.at(-1)?.delay, 5000);
|
|
});
|
|
|
|
test('resultado de poll antigo nao sobrescreve uma acao WhatsApp mais nova', async () => {
|
|
const oldPoll = deferred();
|
|
const app = await executeApp({
|
|
statusResponses: [{ state: 'READY' }, oldPoll.promise],
|
|
post: async (url) => {
|
|
assert.equal(url, '/api/whatsapp/disconnect');
|
|
return { status: { state: 'STOPPED' } };
|
|
}
|
|
});
|
|
await app.element('btn-settings').click();
|
|
const poll = app.timers.at(-1).callback();
|
|
await Promise.resolve();
|
|
await app.element('btn-whatsapp-disconnect').click();
|
|
|
|
oldPoll.resolve({ state: 'QR_REQUIRED' });
|
|
await poll;
|
|
|
|
assert.equal(app.element('whatsapp-state').textContent, 'Parado');
|
|
assert.equal(app.element('whatsapp-qr').getAttribute('src'), null);
|
|
});
|
|
|
|
test('QR antigo nao reaparece depois de uma acao informar READY', async () => {
|
|
const oldQr = deferred();
|
|
const app = await executeApp({
|
|
statusResponses: [{ state: 'QR_REQUIRED' }],
|
|
qrResponses: [oldQr.promise],
|
|
post: async () => ({ status: { state: 'READY' } })
|
|
});
|
|
const opening = app.element('btn-settings').click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await app.element('btn-whatsapp-connect').click();
|
|
|
|
oldQr.resolve({ qr: 'data:image/png;base64,antigo', expiresAt: '2026-07-29T12:00:00Z' });
|
|
await opening;
|
|
|
|
assert.equal(app.element('whatsapp-state').textContent, 'Conectado');
|
|
assert.equal(app.element('whatsapp-qr').getAttribute('src'), null);
|
|
assert.equal(app.element('whatsapp-qr').classList.contains('hidden'), true);
|
|
});
|
|
|
|
test('resultado de acao antiga nao sobrescreve uma acao posterior', async () => {
|
|
const oldAction = deferred();
|
|
const app = await executeApp({
|
|
post: async (url) => url === '/api/whatsapp/connect'
|
|
? oldAction.promise
|
|
: { status: { state: 'STOPPED' } }
|
|
});
|
|
await app.element('btn-settings').click();
|
|
const connecting = app.element('btn-whatsapp-connect').click();
|
|
await Promise.resolve();
|
|
await app.element('btn-whatsapp-disconnect').click();
|
|
|
|
const getCount = app.gets.length;
|
|
oldAction.resolve({});
|
|
await connecting;
|
|
|
|
assert.equal(app.element('whatsapp-state').textContent, 'Parado');
|
|
assert.equal(app.element('whatsapp-qr').classList.contains('hidden'), true);
|
|
assert.equal(app.gets.length, getCount);
|
|
});
|
|
|
|
test('fechar configuracoes invalida consulta de status em andamento', async () => {
|
|
const oldStatus = deferred();
|
|
const app = await executeApp({ statusResponses: [oldStatus.promise] });
|
|
const opening = app.element('btn-settings').click();
|
|
await Promise.resolve();
|
|
app.element('modal-settings').close();
|
|
|
|
oldStatus.resolve({ state: 'QR_REQUIRED' });
|
|
await opening;
|
|
|
|
assert.equal(app.element('modal-settings').open, false);
|
|
assert.equal(app.element('whatsapp-state').textContent, '');
|
|
assert.equal(app.timers.length, 0);
|
|
});
|
|
|
|
test('limpar contexto preserva destino e mensagem digitados', async () => {
|
|
const { context, element } = await executeApp();
|
|
const numero = element('manual-numero');
|
|
const mensagem = element('manual-mensagem');
|
|
numero.value = '5575999999999';
|
|
mensagem.value = 'Mensagem revisada';
|
|
context.window.MaquinaSelecionarLeadManual({ id: 8, empresa: 'Empresa teste' });
|
|
|
|
assert.equal(typeof context.window.MaquinaLimparLeadManual, 'function');
|
|
context.window.MaquinaLimparLeadManual();
|
|
|
|
assert.equal(numero.value, '5575999999999');
|
|
assert.equal(mensagem.value, 'Mensagem revisada');
|
|
assert.equal(element('manual-lead-context').textContent, 'Envio avulso, sem lead selecionado.');
|
|
});
|
|
|
|
test('envio manual usa edicoes visiveis e mantem o lead somente como contexto', async () => {
|
|
const { context, element, posts } = await executeApp();
|
|
context.window.MaquinaSelecionarLeadManual({ id: 8, empresa: 'Empresa teste', whatsapp: '5575111111111' });
|
|
element('manual-numero').value = '5575222222222';
|
|
element('manual-mensagem').value = 'Texto final do operador';
|
|
|
|
await element('btn-enviar-manual').click();
|
|
|
|
assert.deepEqual(JSON.parse(JSON.stringify(posts[0])), {
|
|
url: '/api/outreach/enviar-manual',
|
|
body: {
|
|
numero: '5575222222222',
|
|
mensagem: 'Texto final do operador',
|
|
leadId: 8,
|
|
lead: { id: 8, empresa: 'Empresa teste', whatsapp: '5575111111111' }
|
|
}
|
|
});
|
|
assert.equal(posts.some(({ url }) => url.includes('check-number')), false);
|
|
});
|
|
|
|
test('envio manual mostra aviso quando o resultado da entrega e desconhecido', async () => {
|
|
const app = await executeApp({
|
|
post: async (url) => {
|
|
assert.equal(url, '/api/outreach/enviar-manual');
|
|
return {
|
|
ok: false,
|
|
status: 'resultado_desconhecido',
|
|
aviso: 'O envio pode ter sido aceito. Nao reenvie sem confirmar no celular.'
|
|
};
|
|
}
|
|
});
|
|
|
|
app.element('manual-numero').value = '5575222222222';
|
|
app.element('manual-mensagem').value = 'Texto final';
|
|
await app.element('btn-enviar-manual').click();
|
|
|
|
assert.match(app.element('manual-status').textContent, /nao reenvie/i);
|
|
assert.equal(app.element('manual-status').classList.contains('status-error'), true);
|
|
assert.equal(app.element('manual-status').classList.contains('status-ok'), false);
|
|
});
|
|
|
|
test('envio manual aceito mostra aviso de cache com destaque de alerta', async () => {
|
|
const app = await executeApp({
|
|
post: async (url) => {
|
|
assert.equal(url, '/api/outreach/enviar-manual');
|
|
return {
|
|
ok: true,
|
|
status: 'aceito',
|
|
messageId: 'aceita-1',
|
|
aviso: 'Nao foi possivel atualizar a verificacao local. Nao reenvie a mensagem por esse motivo.'
|
|
};
|
|
}
|
|
});
|
|
|
|
app.element('manual-numero').value = '5575222222222';
|
|
app.element('manual-mensagem').value = 'Texto final';
|
|
await app.element('btn-enviar-manual').click();
|
|
|
|
assert.match(app.element('manual-status').textContent, /nao reenvie/i);
|
|
assert.equal(app.element('manual-status').classList.contains('status-error'), true);
|
|
assert.equal(app.element('manual-status').classList.contains('status-ok'), false);
|
|
});
|
|
|
|
test('envio manual aceito sem aviso preserva confirmacao normal', async () => {
|
|
const app = await executeApp();
|
|
app.element('manual-numero').value = '5575222222222';
|
|
app.element('manual-mensagem').value = 'Texto final';
|
|
|
|
await app.element('btn-enviar-manual').click();
|
|
|
|
assert.match(app.element('manual-status').textContent, /Aceito pelo WhatsApp/i);
|
|
assert.equal(app.element('manual-status').classList.contains('status-ok'), true);
|
|
assert.equal(app.element('manual-status').classList.contains('status-error'), false);
|
|
});
|
|
|
|
test('acao real da tabela preenche envio e preserva edicao visivel com contexto do lead', async () => {
|
|
const app = await executeApp();
|
|
const lead = {
|
|
id: 8, empresa: 'Empresa teste', legal_name: 'Empresa Teste Ltda', whatsapp: '5575111111111',
|
|
cidade: 'Salvador', uf: 'BA', segmento: 'tecnologia', segmento_label: 'Tecnologia', cnpj: '123',
|
|
whatsapp_status: 'confirmado', whatsapp_numero_resolvido: '5575111111111', whatsapp_checked_at: '2026-07-29T12:00:00Z',
|
|
prioridade: 1, score: 90, classificacao: 'excelente', produto_principal: 'Meu serviço', afinidade: 'alta', exported_at: null
|
|
};
|
|
const tbody = await renderTreatment(app, [lead]);
|
|
const action = tbody.children[0].children[0].children[0];
|
|
|
|
await action.click();
|
|
await app.timers.find(({ delay }) => delay === 250).callback();
|
|
|
|
assert.match(app.element('manual-lead-context').textContent, /Lead: Empresa teste/);
|
|
assert.equal(app.element('manual-numero').value, '5575111111111');
|
|
assert.equal(app.element('manual-mensagem').value, 'Olá! Encontrei a Empresa teste em Salvador e gostaria de apresentar meu serviço. Podemos conversar?');
|
|
|
|
app.element('manual-numero').value = '5575222222222';
|
|
await app.element('btn-enviar-manual').click();
|
|
const sent = app.posts.find(({ url }) => url === '/api/outreach/enviar-manual');
|
|
assert.equal(sent.body.numero, '5575222222222');
|
|
assert.equal(sent.body.leadId, 8);
|
|
assert.equal(sent.body.lead.empresa, 'Empresa teste');
|
|
assert.equal(app.posts.some(({ url }) => url.includes('check-number')), false);
|
|
});
|
|
|
|
test('status de verificacao mostra numero e data validos sem depender de title', async () => {
|
|
const app = await executeApp();
|
|
const base = {
|
|
empresa: 'Empresa', whatsapp: '5575111111111', cidade: 'Salvador', segmento: 'tecnologia',
|
|
prioridade: 1, score: 90, classificacao: 'excelente', produto_principal: '', afinidade: 'alta', exported_at: null,
|
|
whatsapp_status: 'confirmado', whatsapp_numero_resolvido: '5575111111111'
|
|
};
|
|
const tbody = await renderTreatment(app, [
|
|
{ ...base, id: 1, whatsapp_checked_at: '2026-07-29T12:00:00Z' },
|
|
{ ...base, id: 2, whatsapp_checked_at: 'data-invalida' }
|
|
]);
|
|
|
|
const validStatus = tbody.children[0].children[3];
|
|
const invalidStatus = tbody.children[1].children[3];
|
|
assert.match(nodeText(validStatus), /Confirmado/);
|
|
assert.match(nodeText(validStatus), /5575111111111/);
|
|
assert.match(nodeText(validStatus), /2026-07-29T12:00:00Z/);
|
|
assert.doesNotMatch(nodeText(invalidStatus), /data-invalida/);
|
|
});
|
|
|
|
test('tabela de tratamento sinaliza numero compartilhado por mais de um lead', async () => {
|
|
const app = await executeApp();
|
|
const rows = [
|
|
{
|
|
id: 1, empresa: 'Contabilidade Cliente A', whatsapp: '5575999091780', whatsapp_status: 'confirmado',
|
|
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 90, classificacao: 'bom',
|
|
produto_principal: '', afinidade: 'media',
|
|
numero_compartilhado: true, numero_compartilhado_total: 2, numero_compartilhado_liberado: false
|
|
},
|
|
{
|
|
id: 2, empresa: 'Empresa Unica', whatsapp: '5511999990001', whatsapp_status: 'confirmado',
|
|
cidade: 'Salvador', segmento_label: 'Restaurantes', prioridade: 1, score: 80, classificacao: 'bom',
|
|
produto_principal: '', afinidade: 'media',
|
|
numero_compartilhado: false, numero_compartilhado_total: 0, numero_compartilhado_liberado: false
|
|
}
|
|
];
|
|
const tbody = await renderTreatment(app, rows);
|
|
const texts = tbody.children.map(nodeText);
|
|
assert.match(texts[0], /N[uú]mero compartilhado \(2 leads\)/);
|
|
assert.doesNotMatch(texts[1], /compartilhado/i);
|
|
});
|
|
|
|
test('secao de numeros compartilhados lista grupos e libera/bloqueia via API', async () => {
|
|
const app = await executeApp();
|
|
const groups = [{
|
|
numero: '5511999990009',
|
|
leads: [{ id: 1, empresa: 'Empresa A', cnpj: '' }, { id: 2, empresa: 'Empresa B', cnpj: '' }],
|
|
liberado: false,
|
|
motivo: null
|
|
}];
|
|
app.context.API.get = async (url) => {
|
|
if (url === '/api/outreach/shared-numbers') return { groups };
|
|
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
|
if (url === '/api/outreach/templates') return { templates: [] };
|
|
if (url === '/api/whatsapp/status') return { state: 'READY' };
|
|
throw new Error(`GET inesperado: ${url}`);
|
|
};
|
|
|
|
await app.element('btn-settings').click();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const list = app.element('shared-numbers-list');
|
|
assert.match(nodeText(list), /5511999990009/);
|
|
assert.match(nodeText(list), /Empresa A/);
|
|
assert.match(nodeText(list), /Empresa B/);
|
|
|
|
const row = list.children[0];
|
|
const motivoInput = row.children.find((child) => child.tagName === 'INPUT');
|
|
const liberarButton = row.children.find((child) => child.tagName === 'BUTTON');
|
|
motivoInput.value = 'confirmado contato direto';
|
|
await liberarButton.click();
|
|
|
|
assert.deepEqual(JSON.parse(JSON.stringify(app.posts.at(-1))), {
|
|
url: '/api/outreach/shared-numbers/5511999990009/liberar',
|
|
body: { motivo: 'confirmado contato direto' }
|
|
});
|
|
});
|
|
|
|
test('controle de limpar contexto e um botao acessivel', async () => {
|
|
const html = await read('public/index.html');
|
|
assert.match(html, /<button[^>]+id="btn-limpar-lead-manual"[^>]+type="button"[^>]*>\s*Limpar contexto do lead\s*<\/button>/i);
|
|
assert.match(html, /id="whatsapp-state"[^>]+role="status"[^>]+aria-live="polite"/i);
|
|
});
|