Files
COMERCIAL-GTI/maquina-comercial-pessoal/tests/whatsapp-manager.test.mjs
T
GustavoandClaude Sonnet 5 417da56bd6 chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
2026-09-28 15:50:17 -03:00

1024 lines
40 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createWhatsAppManager, WHATSAPP_STATES } from '../src/whatsapp/manager.mjs';
async function fixture(options = {}) {
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'whatsapp-manager-'));
const sockets = [];
let loads = 0;
const module = {
DisconnectReason: { restartRequired: 515, loggedOut: 401, badSession: 500, forbidden: 403, multideviceMismatch: 411, connectionReplaced: 440 },
useMultiFileAuthState: async () => ({ state: { creds: {} }, saveCreds: async () => {} }),
default: (config) => {
const ev = new EventEmitter();
const socket = {
ev,
config,
sent: [],
onWhatsApp: async (jid) => [{ jid: `${String(jid).replace(/@s\.whatsapp\.net$/, '')}@s.whatsapp.net`, exists: true }],
sendMessage: async (...args) => {
socket.sent.push(args);
return { key: { id: `message-${socket.sent.length}` } };
},
end: () => {},
logout: async () => {}
};
sockets.push(socket);
return socket;
}
};
const manager = createWhatsAppManager({
authDir,
loadBaileys: async () => { loads += 1; return module; },
qrTtlMs: 20,
reconnectDelayMs: 0,
...options
});
return { authDir, manager, module, sockets, get loads() { return loads; } };
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
async function waitFor(predicate, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!await predicate()) {
if (Date.now() >= deadline) throw new Error('condicao nao atendida dentro do prazo');
await tick();
}
}
test('manager carrega Baileys somente ao conectar e evita conexoes duplicadas', async () => {
const f = await fixture();
assert.equal(f.loads, 0);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STOPPED);
const first = f.manager.connect();
const second = f.manager.connect();
assert.strictEqual(first, second);
await first;
assert.equal(f.loads, 1);
assert.equal(f.sockets.length, 1);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STARTING);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('manager bloqueia dump criptografico do libsignal sem silenciar outros logs', async () => {
const originalInfo = console.info;
const forwarded = [];
console.info = (...args) => forwarded.push(args);
try {
const f = await fixture();
console.info('Closing session:', { currentRatchet: { rootKey: Buffer.from('segredo') } });
console.info('log seguro', 1);
assert.deepEqual(forwarded, [['log seguro', 1]]);
await f.manager.close();
assert.notStrictEqual(console.info, originalInfo);
await fs.rm(f.authDir, { recursive: true, force: true });
} finally {
console.info = originalInfo;
}
});
test('QR fica somente em memoria, altera estado e expira', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { qr: 'segredo-qr' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.QR_REQUIRED);
assert.equal(f.manager.getQr(), 'segredo-qr');
assert.equal(JSON.stringify(f.manager.getStatus()).includes(f.authDir), false);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(f.manager.getQr(), null);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('connection.update abre sessao e sendText serializa envios para JID de usuario', async () => {
let active = 0;
let maxActive = 0;
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.sendMessage = async (...args) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
socket.sent.push(args);
return { key: { id: `id-${socket.sent.length}` } };
};
socket.ev.emit('connection.update', { connection: 'open' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.READY);
const results = await Promise.all([
f.manager.sendText({ number: '+55 (11) 99999-0001', message: 'um', externalKey: 'ext-1' }),
f.manager.sendText({ number: '5511999990002', message: 'dois', externalKey: 'ext-2' })
]);
assert.equal(maxActive, 1);
assert.deepEqual(results, [
{ messageId: 'id-1', resolvedNumber: '5511999990001', variant: 'exata' },
{ messageId: 'id-2', resolvedNumber: '5511999990002', variant: 'exata' }
]);
assert.deepEqual(socket.sent[0], ['5511999990001@s.whatsapp.net', { text: 'um' }]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText rejeita numero que nao existe no WhatsApp antes de reservar ou enviar', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [];
let reserved = false;
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
beforeSend: () => { reserved = true; }
}), /nao esta cadastrado no WhatsApp/i);
assert.equal(reserved, false);
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText consulta numero movel brasileiro com e sem nono digito', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const queries = [];
socket.onWhatsApp = async (...numbers) => {
queries.push(numbers);
return [{ jid: '551198765432@s.whatsapp.net', exists: true }];
};
await f.manager.sendText({ number: '5511998765432', message: 'oi' });
assert.deepEqual(queries[0], ['5511998765432', '551198765432']);
assert.equal(socket.sent[0][0], '551198765432@s.whatsapp.net');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText tenta adicionar nono digito apenas em movel brasileiro legado', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const queries = [];
socket.onWhatsApp = async (...numbers) => {
queries.push(numbers);
return numbers.length === 2 ? [{ jid: `${numbers[1]}@s.whatsapp.net`, exists: true }] : [];
};
await f.manager.sendText({ number: '551198765432', message: 'movel' });
assert.deepEqual(queries[0], ['551198765432', '5511998765432']);
await assert.rejects(f.manager.sendText({ number: '551134567890', message: 'fixo' }), /nao esta cadastrado/i);
assert.deepEqual(queries[1], ['551134567890']);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText prefere correspondencia exata quando as duas variantes existem', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [
{ jid: '551198765432@s.whatsapp.net', exists: true },
{ jid: '5511998765432@s.whatsapp.net', exists: true }
];
await f.manager.sendText({ number: '5511998765432', message: 'oi' });
assert.equal(socket.sent[0][0], '5511998765432@s.whatsapp.net');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber diagnostica variantes sem enviar mensagem', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async (...numbers) => [{ jid: `${numbers[1]}@s.whatsapp.net`, exists: true }];
const result = await f.manager.checkNumber('5575999091780');
assert.deepEqual(result, {
numero: '5575999091780',
status: 'confirmado',
registered: true,
resolvedNumber: '557599091780',
variant: 'sem_nono_digito',
candidatesChecked: 2
});
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber retorna metadados solicitados quando numero nao esta cadastrado', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [];
assert.deepEqual(await f.manager.checkNumber('+55 (11) 99999-0001'), {
numero: '5511999990001',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('manager ignora JID existente fora das variantes solicitadas e nao envia', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.onWhatsApp = async () => [{ jid: '5511888880000@s.whatsapp.net', exists: true }];
const checked = await f.manager.checkNumber('5511999990001');
let reserved = false;
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
beforeSend: () => { reserved = true; }
}), /nao esta cadastrado/i);
assert.deepEqual(checked, {
numero: '5511999990001',
status: 'nao_cadastrado',
registered: false,
resolvedNumber: null,
variant: null,
candidatesChecked: 2
});
assert.equal(reserved, false);
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('checkNumber limita lookup e observa rejeicao tardia do provedor', async () => {
const f = await fixture({ lookupTimeoutMs: 10 });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let rejectLookup;
socket.onWhatsApp = () => new Promise((resolve, reject) => { rejectLookup = reject; });
await assert.rejects(f.manager.checkNumber('5511999990001'), (error) => {
assert.equal(error.statusCode, 504);
assert.equal(error.code, 'WHATSAPP_LOOKUP_TIMEOUT');
assert.equal(error.numero, '5511999990001');
assert.equal(error.status, 'erro_temporario');
assert.equal(error.resolvedNumber, null);
assert.equal(error.variant, null);
assert.equal(error.candidatesChecked, 2);
return true;
});
rejectLookup(new Error('lookup rejeitado depois do timeout'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('messages.update publica ACK, entrega e leitura para persistencia', async () => {
const updates = [];
const f = await fixture({ onMessageStatus: (event) => updates.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.ev.emit('messages.update', [
{ key: { id: 'msg-1', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } },
{ key: { id: 'msg-2', remoteJid: '5511999990002@s.whatsapp.net', fromMe: true }, update: { status: 3 } },
{ key: { id: 'msg-3', remoteJid: '5511999990003@s.whatsapp.net', fromMe: true }, update: { status: 4 } }
]);
await waitFor(() => updates.length === 3);
assert.deepEqual(updates.map(({ messageId, status }) => ({ messageId, status })), [
{ messageId: 'msg-1', status: 'enviado' },
{ messageId: 'msg-2', status: 'entregue' },
{ messageId: 'msg-3', status: 'lido' }
]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('messages.upsert publica uma vez somente mensagens inbound novas de conversa individual', async () => {
const inbound = [];
const f = await fixture({ onInboundMessage: (event) => inbound.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.ev.emit('messages.upsert', {
type: 'append',
messages: [{ key: { id: 'history-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [
{ key: { id: 'inbound-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'self-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: true }, messageTimestamp: 1785326400 },
{ key: { id: 'group-1', remoteJid: '120363000000000000@g.us', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'broadcast-1', remoteJid: 'newsletter@broadcast', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { id: 'status-1', remoteJid: 'status@broadcast', fromMe: false }, messageTimestamp: 1785326400 },
{ key: { remoteJid: '5575999091781@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }
]
});
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'inbound-1', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326460 }]
});
await waitFor(() => inbound.length === 1);
assert.deepEqual(inbound, [{
messageId: 'inbound-1',
jid: '5575999091780@s.whatsapp.net',
number: '5575999091780',
receivedAt: '2026-07-29T12:00:00.000Z'
}]);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
// O Set de dedup so era esvaziado inteiro no disconnect: sem um teto,
// crescia sem limite proporcional ao volume total de mensagens recebidas
// durante a vida da conexao. Envia 1 mensagem a mais que o teto interno
// (5000) e confirma que a mais antiga foi descartada (tratada como nova de
// novo), provando que o Set nao cresce indefinidamente.
test('dedup de mensagens inbound tem teto e descarta a mais antiga', async () => {
const inbound = [];
const f = await fixture({ onInboundMessage: (event) => inbound.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const TOTAL = 5001;
for (let i = 0; i < TOTAL; i++) {
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: `msg-${i}`, remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
}
await waitFor(() => inbound.length === TOTAL, 5000);
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'msg-0', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
await waitFor(() => inbound.length === TOTAL + 1, 2000);
assert.equal(inbound.filter((e) => e.messageId === 'msg-0').length, 2);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('falha ao persistir inbound nao escapa do EventEmitter nem gera resposta automatica', async () => {
const logs = [];
const f = await fixture({
onInboundMessage: () => { throw new Error('inbound sqlite fechado'); },
log: (message) => logs.push(message)
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
assert.doesNotThrow(() => socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'inbound-error', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
}));
await waitFor(() => logs.some((message) => message.includes('inbound sqlite fechado')));
assert.equal(socket.sent.length, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('erro sincrono ao persistir ACK nao escapa do EventEmitter', async () => {
const logs = [];
const f = await fixture({
onMessageStatus: () => { throw new Error('sqlite fechado'); },
log: (message) => logs.push(message)
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
assert.doesNotThrow(() => socket.ev.emit('messages.update', [
{ key: { id: 'sync-error', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } }
]));
await waitFor(() => logs.some((message) => message.includes('sqlite fechado')));
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('ACK recebido antes da persistencia do messageId e reaplicado depois', async () => {
let persisted = false;
const calls = [];
const f = await fixture({
onMessageStatus: (event) => {
calls.push(event);
return persisted;
}
});
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
socket.sendMessage = async () => {
socket.ev.emit('messages.update', [
{ key: { id: 'early-1', remoteJid: '5511999990001@s.whatsapp.net', fromMe: true }, update: { status: 2 } }
]);
return { key: { id: 'early-1' } };
};
await f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => { persisted = true; return true; }
});
assert.equal(calls.length, 2);
assert.equal(calls[1].messageId, 'early-1');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('badSession generico preserva credenciais para recuperacao explicita', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.badSession } } }
});
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.ERROR);
assert.equal((await fs.stat(path.join(f.authDir, 'creds.json'))).isFile(), true);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('connect aguarda limpeza fatal da sessao anterior', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
let releaseRemoval;
const originalRm = fs.rm;
fs.rm = async (target, options) => {
if (path.resolve(target) !== path.resolve(f.authDir)) return originalRm(target, options);
await new Promise((resolve) => { releaseRemoval = resolve; });
return originalRm(target, options);
};
try {
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.loggedOut } } }
});
await waitFor(() => Boolean(releaseRemoval));
const reconnecting = f.manager.connect();
await tick();
assert.equal(f.sockets.length, 1);
releaseRemoval();
await reconnecting;
assert.equal(f.sockets.length, 2);
} finally {
fs.rm = originalRm;
}
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('refreshQr encerra socket preso e inicia novo pareamento', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { qr: 'qr-antigo' });
await f.manager.refreshQr();
assert.equal(f.sockets.length, 2);
f.sockets[1].ev.emit('connection.update', { qr: 'qr-novo' });
assert.equal(f.manager.getQr(), 'qr-novo');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('falha transitoria ao iniciar agenda nova tentativa', async () => {
let attempts = 0;
const f = await fixture({
loadBaileys: async () => {
attempts += 1;
if (attempts === 1) throw new Error('rede indisponivel');
return f.module;
}
});
await assert.rejects(f.manager.connect(), /rede indisponivel/);
await waitFor(() => f.sockets.length === 1);
assert.equal(attempts, 2);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('nova conexao manual reinicia o orçamento de reconexoes', async () => {
const f = await fixture({ maxReconnectAttempts: 1 });
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.sockets.length === 2);
f.sockets[1].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.ERROR);
await f.manager.connect();
f.sockets[2].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } });
await waitFor(() => f.sockets.length === 4);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('restartRequired reconecta e loggedOut remove somente o authDir', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.restartRequired } } }
});
await tick();
assert.equal(f.sockets.length, 2);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STARTING);
f.sockets[1].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.loggedOut } } }
});
await waitFor(() => f.manager.getStatus().state === WHATSAPP_STATES.DISCONNECTED);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
await f.manager.close();
});
test('disconnect com logout encerra socket, limpa auth e sendText exige READY', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await assert.rejects(
f.manager.sendText({ number: '5511999990001', message: 'oi' }),
/WhatsApp nao esta conectado/
);
await f.manager.connect();
let loggedOut = false;
f.sockets[0].logout = async () => { loggedOut = true; };
await f.manager.disconnect({ logout: true });
assert.equal(loggedOut, true);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
await f.manager.close();
});
test('falha no logout remoto ainda limpa sessao local e libera lock', async () => {
const f = await fixture();
await fs.writeFile(path.join(f.authDir, 'creds.json'), '{}');
await f.manager.connect();
f.sockets[0].logout = async () => { throw new Error('logout remoto falhou'); };
await assert.rejects(f.manager.disconnect({ logout: true }), /logout remoto falhou/);
await assert.rejects(fs.stat(f.authDir), { code: 'ENOENT' });
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.DISCONNECTED);
const second = createWhatsAppManager({ authDir: f.authDir, loadBaileys: async () => f.module });
await second.connect();
await second.close();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText executa callbacks dentro da fila e persiste afterSend antes do proximo envio', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
const events = [];
await Promise.all([
f.manager.sendText({ number: '5511999990001', message: 'um', beforeSend: () => events.push('before-1'), afterSend: () => events.push('after-1') }),
f.manager.sendText({ number: '5511999990002', message: 'dois', beforeSend: () => events.push('before-2'), afterSend: () => events.push('after-2') })
]);
assert.deepEqual(events, ['before-1', 'after-1', 'before-2', 'after-2']);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('pendingSends inclui operacoes enfileiradas e decrementa no finally', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
let release;
f.sockets[0].sendMessage = () => new Promise((resolve) => { release = () => resolve({ key: { id: 'queued-1' } }); });
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
assert.equal(f.manager.getStatus().pendingSends, 1);
await waitFor(() => Boolean(release));
release();
await sending;
assert.equal(f.manager.getStatus().pendingSends, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('disconnect aguarda envio em andamento antes de encerrar socket', async () => {
const f = await fixture();
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let releaseSend;
let ended = false;
socket.sendMessage = () => new Promise((resolve) => { releaseSend = () => resolve({ key: { id: 'drain-1' } }); });
socket.end = () => { ended = true; };
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
await waitFor(() => Boolean(releaseSend));
const disconnecting = f.manager.disconnect();
await tick();
assert.equal(ended, false);
releaseSend();
await sending;
await disconnecting;
assert.equal(ended, true);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('disconnect limita espera por sendMessage travado', async () => {
const f = await fixture({ sendDrainTimeoutMs: 15 });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
let releaseSend;
socket.sendMessage = () => new Promise((resolve) => { releaseSend = () => resolve({ key: { id: 'late-1' } }); });
const sending = f.manager.sendText({ number: '5511999990001', message: 'oi' });
await waitFor(() => Boolean(releaseSend));
await assert.rejects(f.manager.disconnect(), /fila de envios.*prazo/i);
releaseSend();
await sending;
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText chama afterSend uma vez e preserva messageId quando a persistencia falha', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
let calls = 0;
await assert.rejects(
f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => {
calls += 1;
throw new Error('banco indisponivel');
}
}),
(error) => {
assert.match(error.message, /persistir.*message-1/i);
assert.equal(error.messageId, 'message-1');
assert.equal(error.deliverySucceeded, true);
assert.match(error.cause.message, /banco indisponivel/);
return true;
}
);
assert.equal(calls, 1);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText trata retorno false do afterSend como falha de persistencia', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: () => false
}), /persistir.*message-1/i);
assert.equal(f.manager.getStatus().pendingSends, 0);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText informa falha do provedor ao afterSend exatamente uma vez', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
const providerError = new Error('provedor fora');
f.sockets[0].sendMessage = async () => { throw providerError; };
const calls = [];
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'oi',
afterSend: (...args) => calls.push(args)
}), providerError);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], null);
assert.equal(calls[0][1], providerError);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText trata resposta resolvida sem messageId como entrega desconhecida', async () => {
const f = await fixture();
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'open' });
f.sockets[0].sendMessage = async () => ({ key: {} });
const finalized = [];
await assert.rejects(f.manager.sendText({
number: '5511999990001',
message: 'pode ter sido enviada',
beforeSend: () => 'reservation-without-id',
afterSend: (...args) => finalized.push(args)
}), (error) => {
assert.equal(error.code, 'WHATSAPP_MESSAGE_ID_MISSING');
assert.equal(error.deliveryUnknown, true);
return true;
});
assert.equal(finalized.length, 1);
assert.equal(finalized[0][0], null);
assert.equal(finalized[0][1].deliveryUnknown, true);
assert.equal(finalized[0][2], 'reservation-without-id');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText nao usa socket capturado se conexao muda durante beforeSend', async () => {
const f = await fixture();
await f.manager.connect();
const staleSocket = f.sockets[0];
staleSocket.ev.emit('connection.update', { connection: 'open' });
let staleSendAttempts = 0;
staleSocket.sendMessage = async () => {
staleSendAttempts += 1;
return { key: { id: 'stale-send' } };
};
let releaseBeforeSend;
let notifyBeforeSend;
const beforeSendStarted = new Promise((resolve) => { notifyBeforeSend = resolve; });
const beforeSendGate = new Promise((resolve) => { releaseBeforeSend = resolve; });
const finalized = [];
const sending = f.manager.sendText({
number: '5511999990001',
message: 'nao enviar em socket obsoleto',
beforeSend: async () => {
notifyBeforeSend();
await beforeSendGate;
return 'reservation-stale';
},
afterSend: (...args) => finalized.push(args)
});
await beforeSendStarted;
staleSocket.ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: 408 } } }
});
await waitFor(() => f.sockets.length === 2);
const replacementSocket = f.sockets[1];
replacementSocket.ev.emit('connection.update', { connection: 'open' });
releaseBeforeSend();
await assert.rejects(sending, (error) => {
assert.equal(error.code, 'WHATSAPP_SOCKET_STALE');
assert.equal(error.numero, '5511999990001');
assert.equal(error.resolvedNumber, '5511999990001');
assert.equal(error.variant, 'exata');
return true;
});
assert.equal(staleSendAttempts, 0);
assert.equal(replacementSocket.sent.length, 0);
assert.equal(finalized.length, 1);
assert.equal(finalized[0][0], null);
assert.equal(finalized[0][1].code, 'WHATSAPP_SOCKET_STALE');
assert.equal(finalized[0][2], 'reservation-stale');
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('sendText limita envio incerto, finaliza uma vez e invalida socket antes de liberar fila', async () => {
const events = [];
const f = await fixture({ messageTimeoutMs: 10 });
await f.manager.connect();
const uncertainSocket = f.sockets[0];
uncertainSocket.ev.emit('connection.update', { connection: 'open' });
let rejectLateSend;
let ended = false;
let lookupAttempts = 0;
let sendAttempts = 0;
uncertainSocket.onWhatsApp = async (jid) => {
lookupAttempts += 1;
return [{ jid: `${jid}@s.whatsapp.net`, exists: true }];
};
uncertainSocket.sendMessage = () => {
sendAttempts += 1;
return new Promise((resolve, reject) => { rejectLateSend = reject; });
};
uncertainSocket.end = () => { ended = true; };
const first = f.manager.sendText({
number: '5511999990001',
message: 'primeira',
beforeSend: () => { events.push('before'); return 'reservation-1'; },
afterSend: (result, error, context) => events.push(['after', result, error, context])
});
const second = f.manager.sendText({ number: '5511999990002', message: 'segunda' });
const secondOutcome = second.catch((error) => error);
await assert.rejects(first, (error) => {
assert.equal(error.statusCode, 504);
assert.equal(error.code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(error.deliveryUnknown, true);
assert.equal(error.numero, '5511999990001');
assert.equal(error.resolvedNumber, '5511999990001');
assert.equal(error.variant, 'exata');
return true;
});
const secondError = await secondOutcome;
assert.match(secondError.message, /nao esta conectado/i);
assert.equal(events[0], 'before');
assert.equal(events.length, 2);
assert.equal(events[1][0], 'after');
assert.equal(events[1][1], null);
assert.equal(events[1][2].code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(events[1][3], 'reservation-1');
assert.equal(lookupAttempts, 1);
assert.equal(sendAttempts, 1);
assert.equal(ended, true);
assert.equal(f.manager.getStatus().pendingSends, 0);
await waitFor(() => f.sockets.length === 2);
assert.notStrictEqual(f.sockets[1], uncertainSocket);
rejectLateSend(new Error('envio rejeitado depois do timeout'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('timeout tardio do socket anterior nao invalida socket substituto', async () => {
const f = await fixture({ messageTimeoutMs: 100 });
await f.manager.connect();
const staleSocket = f.sockets[0];
staleSocket.ev.emit('connection.update', { connection: 'open' });
let rejectLateSend;
let notifySendStarted;
let staleSendAttempts = 0;
const sendStarted = new Promise((resolve) => { notifySendStarted = resolve; });
staleSocket.sendMessage = () => {
staleSendAttempts += 1;
notifySendStarted();
return new Promise((resolve, reject) => { rejectLateSend = reject; });
};
const uncertainSend = f.manager.sendText({ number: '5511999990001', message: 'incerta' });
await sendStarted;
staleSocket.ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: 408 } } }
});
await waitFor(() => f.sockets.length === 2);
const replacementSocket = f.sockets[1];
replacementSocket.ev.emit('connection.update', { connection: 'open' });
await assert.rejects(uncertainSend, (error) => {
assert.equal(error.code, 'WHATSAPP_SEND_TIMEOUT');
assert.equal(error.deliveryUnknown, true);
return true;
});
assert.equal(staleSendAttempts, 1);
assert.equal(f.sockets.length, 2);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.READY);
const result = await f.manager.sendText({ number: '5511999990002', message: 'somente no socket B' });
assert.equal(result.messageId, 'message-1');
assert.deepEqual(replacementSocket.sent, [
['5511999990002@s.whatsapp.net', { text: 'somente no socket B' }]
]);
assert.equal(staleSendAttempts, 1);
rejectLateSend(new Error('socket A rejeitou tarde'));
await tick();
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('dois managers nao adquirem o mesmo authDir e lock stale e recuperado', async () => {
const first = await fixture();
await first.manager.connect();
const second = createWhatsAppManager({ authDir: first.authDir, loadBaileys: async () => first.module });
await assert.rejects(second.connect(), /em uso por outro processo/);
await first.manager.close();
await second.connect();
await second.close();
const lockPath = path.join(path.dirname(first.authDir), `${path.basename(first.authDir)}.lock`);
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647 }));
const stale = createWhatsAppManager({ authDir: first.authDir, loadBaileys: async () => first.module });
await stale.connect();
await stale.close();
await fs.rm(first.authDir, { recursive: true, force: true });
});
test('duas aquisicoes concorrentes isolam lock stale sem remover lock do vencedor', async () => {
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'whatsapp-lock-race-'));
const lockPath = path.join(path.dirname(authDir), `${path.basename(authDir)}.lock`);
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647, token: 'stale' }));
const sockets = [];
const module = {
DisconnectReason: {},
useMultiFileAuthState: async () => ({ state: {}, saveCreds: async () => {} }),
default: () => {
const socket = { ev: new EventEmitter(), end() {} };
sockets.push(socket);
return socket;
}
};
const managers = [
createWhatsAppManager({ authDir, loadBaileys: async () => module }),
createWhatsAppManager({ authDir, loadBaileys: async () => module })
];
const originalRename = fs.rename;
let authRenames = 0;
fs.rename = async (from, to) => {
if (from !== lockPath) return originalRename(from, to);
authRenames += 1;
if (authRenames === 2) {
await waitFor(async () => {
try {
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
return holder.pid === process.pid;
} catch {
return false;
}
});
}
return originalRename(from, to);
};
let results;
try {
results = await Promise.allSettled(managers.map((manager) => manager.connect()));
} finally {
fs.rename = originalRename;
}
assert.equal(results.filter(({ status }) => status === 'fulfilled').length, 1);
const rejected = results.find(({ status }) => status === 'rejected');
assert.match(rejected.reason.message, /(?:em uso por outro processo|recuperacao.*(?:andamento|bloqueada))/i);
assert.equal(authRenames, 1);
assert.equal(sockets.length, 1);
const holder = JSON.parse(await fs.readFile(lockPath, 'utf8'));
assert.equal(holder.pid, process.pid);
const loser = managers[results.findIndex(({ status }) => status === 'rejected')];
await loser.close();
assert.equal((await fs.stat(lockPath)).isFile(), true);
const winner = managers[results.findIndex(({ status }) => status === 'fulfilled')];
await winner.close();
await assert.rejects(fs.stat(lockPath), { code: 'ENOENT' });
await fs.rm(authDir, { recursive: true, force: true });
});
test('recovery lock de processo morto e removido com seguranca', async () => {
const f = await fixture();
const lockPath = path.join(path.dirname(f.authDir), `${path.basename(f.authDir)}.lock`);
const recoveryPath = `${lockPath}.recovery`;
await fs.writeFile(lockPath, JSON.stringify({ pid: 2147483647, token: 'stale' }));
await fs.writeFile(recoveryPath, JSON.stringify({ pid: 2147483646, token: 'recovery-owner' }));
await f.manager.connect();
assert.equal(f.sockets.length, 1);
await assert.rejects(fs.stat(recoveryPath), { code: 'ENOENT' });
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
await fs.rm(lockPath, { force: true });
await fs.rm(recoveryPath, { force: true });
});
test('recuperacao de lock stale usa quarantine por rename atomico', async () => {
const source = await fs.readFile(new URL('../src/whatsapp/manager.mjs', import.meta.url), 'utf8');
const acquireLock = source.slice(source.indexOf('const acquireLock'), source.indexOf('const releaseLock'));
assert.match(acquireLock, /fs\.rename\(lockPath,/);
assert.doesNotMatch(acquireLock, /fs\.rm\(lockPath/);
});
test('disconnect durante await de connect nao publica socket e fatal nao reconecta', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const f = await fixture({ loadBaileys: async () => { await gate; return f.module; } });
const connecting = f.manager.connect();
await f.manager.disconnect();
release();
await connecting;
assert.equal(f.sockets.length, 0);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.STOPPED);
await f.manager.connect();
f.sockets[0].ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: f.module.DisconnectReason.connectionReplaced } } } });
await tick();
assert.equal(f.sockets.length, 1);
assert.equal(f.manager.getStatus().state, WHATSAPP_STATES.ERROR);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});