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.
303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createLeadInserter, openDb } from '../src/db.mjs';
|
|
import { createRouter } from '../src/server/router.mjs';
|
|
import { registerWhatsAppRoutes } from '../src/server/routes/whatsapp.mjs';
|
|
|
|
function setup({ qr = 'raw-qr', checkNumber } = {}) {
|
|
const db = openDb(':memory:');
|
|
const calls = [];
|
|
const whatsapp = {
|
|
getStatus: () => ({ state: 'QR_REQUIRED', connected: false, qrExpiresAt: '2026-07-28T12:00:00.000Z' }),
|
|
getQr: () => qr,
|
|
connect: async () => { calls.push('connect'); },
|
|
refreshQr: async () => { calls.push('refreshQr'); },
|
|
checkNumber: checkNumber || (async (numero) => ({
|
|
numero,
|
|
status: 'confirmado',
|
|
registered: true,
|
|
resolvedNumber: '557599091780',
|
|
variant: 'sem_nono_digito',
|
|
candidatesChecked: 2
|
|
})),
|
|
disconnect: async (options) => { calls.push(options); }
|
|
};
|
|
const router = createRouter();
|
|
registerWhatsAppRoutes(router, {
|
|
db,
|
|
whatsapp,
|
|
toDataURL: async (value) => `data:image/png;base64,${value === 'raw-qr' ? 'encoded' : 'bad'}`
|
|
});
|
|
return { db, router, calls };
|
|
}
|
|
|
|
async function invoke(router, method, pathname, context = {}) {
|
|
const responses = [];
|
|
const matched = router.match(method, pathname);
|
|
assert.ok(matched, `rota ausente: ${method} ${pathname}`);
|
|
await matched.handler(null, null, {
|
|
params: matched.params,
|
|
body: null,
|
|
json: (status, data) => responses.push({ status, data }),
|
|
...context
|
|
});
|
|
return responses[0];
|
|
}
|
|
|
|
test('status pode ser consultado por qualquer usuario autenticado sem segredos', async () => {
|
|
const { router } = setup();
|
|
const response = await invoke(router, 'GET', '/api/whatsapp/status', { user: { role: 'comercial' } });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.data.state, 'QR_REQUIRED');
|
|
assert.equal(JSON.stringify(response.data).includes('raw-qr'), false);
|
|
assert.equal(JSON.stringify(response.data).includes('auth'), false);
|
|
});
|
|
|
|
test('acoes WhatsApp rejeitam usuario nao-admin', async () => {
|
|
const { router, calls } = setup();
|
|
for (const [method, pathname] of [
|
|
['POST', '/api/whatsapp/connect'],
|
|
['POST', '/api/whatsapp/refresh-qr'],
|
|
['POST', '/api/whatsapp/check-number'],
|
|
['GET', '/api/whatsapp/qr'],
|
|
['POST', '/api/whatsapp/disconnect'],
|
|
['POST', '/api/whatsapp/logout']
|
|
]) {
|
|
const response = await invoke(router, method, pathname, { user: { role: 'comercial' } });
|
|
assert.equal(response.status, 403);
|
|
}
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test('admin diagnostica numero sem enviar mensagem', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async () => ({
|
|
numero: '5511888880000',
|
|
status: 'erro_temporario',
|
|
registered: true,
|
|
resolvedNumber: '557599091780',
|
|
variant: 'sem_nono_digito',
|
|
candidatesChecked: 2
|
|
})
|
|
});
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '5575999091780' }
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.data.numero, '5575999091780');
|
|
assert.equal(response.data.status, 'confirmado');
|
|
assert.equal(response.data.registered, true);
|
|
assert.equal(response.data.resolvedNumber, '557599091780');
|
|
assert.equal(response.data.variant, 'sem_nono_digito');
|
|
assert.equal(response.data.candidatesChecked, 2);
|
|
assert.match(response.data.checked_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT numero, status, numero_resolvido, variante FROM whatsapp_number_verifications').get() },
|
|
{
|
|
numero: '5575999091780',
|
|
status: 'confirmado',
|
|
numero_resolvido: '557599091780',
|
|
variante: 'sem_nono_digito'
|
|
}
|
|
);
|
|
db.close();
|
|
});
|
|
|
|
test('diagnostico definitivo nao cadastrado retorna 422 e persiste o resultado', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async (numero) => ({
|
|
numero,
|
|
status: 'nao_cadastrado',
|
|
registered: false,
|
|
resolvedNumber: null,
|
|
variant: null,
|
|
candidatesChecked: 2
|
|
})
|
|
});
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '5511999990001' }
|
|
});
|
|
assert.equal(response.status, 422);
|
|
assert.equal(response.data.status, 'nao_cadastrado');
|
|
assert.match(response.data.checked_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
assert.equal(db.prepare('SELECT status FROM whatsapp_number_verifications').get().status, 'nao_cadastrado');
|
|
db.close();
|
|
});
|
|
|
|
test('timeout de diagnostico retorna 504 e persiste erro temporario solicitado', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async () => {
|
|
throw Object.assign(new Error('consulta excedeu o prazo'), {
|
|
statusCode: 504,
|
|
code: 'WHATSAPP_LOOKUP_TIMEOUT',
|
|
status: 'erro_temporario',
|
|
registered: false,
|
|
resolvedNumber: null,
|
|
variant: null,
|
|
candidatesChecked: 2
|
|
});
|
|
}
|
|
});
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '5511999990001' }
|
|
});
|
|
assert.equal(response.status, 504);
|
|
assert.match(response.data.erro, /prazo/);
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT numero, status, erro FROM whatsapp_number_verifications').get() },
|
|
{ numero: '5511999990001', status: 'erro_temporario', erro: 'consulta excedeu o prazo' }
|
|
);
|
|
db.close();
|
|
});
|
|
|
|
test('erro definitivo sem statusCode retorna 422 e ignora numero conflitante do provedor', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async () => {
|
|
throw Object.assign(new Error('numero nao cadastrado'), {
|
|
numero: '5511888880000',
|
|
status: 'nao_cadastrado',
|
|
registered: false,
|
|
resolvedNumber: null,
|
|
variant: null
|
|
});
|
|
}
|
|
});
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '+55 (11) 99999-0001' }
|
|
});
|
|
assert.equal(response.status, 422);
|
|
assert.deepEqual(
|
|
{ ...db.prepare('SELECT numero, status FROM whatsapp_number_verifications').get() },
|
|
{ numero: '5511999990001', status: 'nao_cadastrado' }
|
|
);
|
|
db.close();
|
|
});
|
|
|
|
test('check-number valida leadId opcional antes de consultar provedor ou cache', async () => {
|
|
let providerCalls = 0;
|
|
const { db, router } = setup({
|
|
checkNumber: async () => {
|
|
providerCalls += 1;
|
|
return { registered: true };
|
|
}
|
|
});
|
|
createLeadInserter(db)({
|
|
dedup_key: 'lead-check-1', empresa: 'Empresa', whatsapp: '5511999990001', cidade: 'Santos',
|
|
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
db.exec(`
|
|
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
|
|
BEGIN SELECT RAISE(FAIL, 'cache nao deveria ser tocado'); END;
|
|
`);
|
|
|
|
for (const leadId of [true, '1', '01', '1e0', 1.5, 0, -1, NaN, 999]) {
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { leadId, numero: '5511999990001' }
|
|
});
|
|
assert.deepEqual(response, { status: 404, data: { erro: 'lead nao encontrado' } });
|
|
}
|
|
assert.equal(providerCalls, 0);
|
|
assert.equal(db.prepare('SELECT COUNT(*) total FROM whatsapp_number_verifications').get().total, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('check-number usa numero do corpo mesmo com contexto de lead valido', async () => {
|
|
let checkedNumber;
|
|
const { db, router } = setup({
|
|
checkNumber: async (numero) => {
|
|
checkedNumber = numero;
|
|
return { registered: false, resolvedNumber: null, variant: null, candidatesChecked: 2 };
|
|
}
|
|
});
|
|
createLeadInserter(db)({
|
|
dedup_key: 'lead-check-2', empresa: 'Empresa', whatsapp: '5511888880000', cidade: 'Santos',
|
|
produto_principal: 'ERP', produtos_json: '[]', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 0, classificacao: 'ruim'
|
|
});
|
|
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { leadId: 1, numero: '5511999990001' }
|
|
});
|
|
|
|
assert.equal(response.status, 422);
|
|
assert.equal(checkedNumber, '5511999990001');
|
|
assert.equal(response.data.numero, '5511999990001');
|
|
assert.equal(db.prepare('SELECT numero FROM whatsapp_number_verifications').get().numero, '5511999990001');
|
|
db.close();
|
|
});
|
|
|
|
test('falha de cache no nao cadastrado preserva 422, JSON e timestamp nulo', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async () => ({ registered: false, resolvedNumber: null, variant: null, candidatesChecked: 2 })
|
|
});
|
|
db.exec(`
|
|
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
|
|
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
|
|
`);
|
|
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '5511999990001' }
|
|
});
|
|
|
|
assert.equal(response.status, 422);
|
|
assert.equal(response.data.status, 'nao_cadastrado');
|
|
assert.equal(response.data.checked_at, null);
|
|
assert.match(response.data.aviso, /verificacao local/i);
|
|
assert.doesNotMatch(response.data.aviso, /tente.*enviar|reenvie/i);
|
|
db.close();
|
|
});
|
|
|
|
test('falha de cache no timeout de lookup preserva 504, JSON e timestamp nulo', async () => {
|
|
const { db, router } = setup({
|
|
checkNumber: async () => {
|
|
throw Object.assign(new Error('consulta excedeu o prazo'), {
|
|
statusCode: 504,
|
|
status: 'erro_temporario',
|
|
registered: false,
|
|
resolvedNumber: null,
|
|
variant: null,
|
|
candidatesChecked: 2
|
|
});
|
|
}
|
|
});
|
|
db.exec(`
|
|
CREATE TRIGGER fail_verification_cache BEFORE INSERT ON whatsapp_number_verifications
|
|
BEGIN SELECT RAISE(FAIL, 'cache indisponivel'); END;
|
|
`);
|
|
|
|
const response = await invoke(router, 'POST', '/api/whatsapp/check-number', {
|
|
user: { role: 'admin' }, body: { numero: '5511999990001' }
|
|
});
|
|
|
|
assert.equal(response.status, 504);
|
|
assert.equal(response.data.status, 'erro_temporario');
|
|
assert.equal(response.data.checked_at, null);
|
|
assert.match(response.data.erro, /prazo/);
|
|
assert.match(response.data.aviso, /verificacao local/i);
|
|
assert.doesNotMatch(response.data.aviso, /tente.*enviar|reenvie/i);
|
|
db.close();
|
|
});
|
|
|
|
test('admin conecta, obtem QR vigente como data URL e encerra ou remove sessao', async () => {
|
|
const { router, calls } = setup();
|
|
const user = { role: 'admin' };
|
|
assert.equal((await invoke(router, 'POST', '/api/whatsapp/connect', { user })).status, 202);
|
|
assert.equal((await invoke(router, 'POST', '/api/whatsapp/refresh-qr', { user })).status, 202);
|
|
const qr = await invoke(router, 'GET', '/api/whatsapp/qr', { user });
|
|
assert.deepEqual(qr, {
|
|
status: 200,
|
|
data: { qr: 'data:image/png;base64,encoded', expiresAt: '2026-07-28T12:00:00.000Z' }
|
|
});
|
|
assert.equal((await invoke(router, 'POST', '/api/whatsapp/disconnect', { user })).status, 200);
|
|
assert.equal((await invoke(router, 'POST', '/api/whatsapp/logout', { user })).status, 200);
|
|
assert.deepEqual(calls, ['connect', 'refreshQr', { logout: false }, { logout: true }]);
|
|
});
|
|
|
|
test('QR expirado retorna conflito sem chamar gerador', async () => {
|
|
const { router } = setup({ qr: null });
|
|
const response = await invoke(router, 'GET', '/api/whatsapp/qr', { user: { role: 'admin' } });
|
|
assert.equal(response.status, 409);
|
|
assert.match(response.data.erro, /QR/);
|
|
});
|