Files
COMERCIAL-GTI/tests/personal-branding.test.mjs
T
Gustavo d330bb5ff0 Baseline commit of the existing Maquina Comercial Pessoal project
Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
2026-07-31 10:54:07 -03:00

507 lines
21 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/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('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);
});