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.
992 lines
39 KiB
JavaScript
992 lines
39 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 });
|
|
});
|
|
|
|
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 });
|
|
});
|