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 }); }); 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 }); });