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

568 lines
24 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\("#"\)/);
});
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);
});