import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken, getDataSourceToken } from '@nestjs/typeorm'; import { Repository, DataSource, In } from 'typeorm'; import { NotFoundException, ConflictException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SessionService, ACK_RECONCILE_DELAY_MS, SESSION_WATCHDOG_INTERVAL_MS, SESSION_WATCHDOG_PROBE_TIMEOUT_MS, } from './session.service'; import { Session, SessionStatus } from './entities/session.entity'; import { Message, MessageDirection, MessageStatus } from '../message/entities/message.entity'; import { MessageBatch } from '../message/entities/message-batch.entity'; import { Webhook } from '../webhook/entities/webhook.entity'; import { Template } from '../template/entities/template.entity'; import { BaileysStoredMessage } from '../../engine/adapters/baileys-stored-message.entity'; import { EngineFactory } from '../../engine/engine.factory'; import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; import { EventsGateway } from '../events/events.gateway'; import { WebhookService } from '../webhook/webhook.service'; import { HookManager } from '../../core/hooks'; import { IncomingMessage, EngineEventCallbacks, EngineStatus, GroupEvent, IncomingCallEvent, } from '../../engine/interfaces/whatsapp-engine.interface'; import { BaileysSessionStore } from '../../engine/adapters/baileys-session-store'; import { getSessionReconnectAttemptsTotal, getSessionReconnectLoopAlertsTotal, } from '../../common/metrics/session-reconnect-metrics'; function createMockSession(overrides: Partial = {}): Session { return { id: 'sess-uuid-1', name: 'test-session', status: SessionStatus.CREATED, phone: null, pushName: null, config: {}, proxyUrl: null, proxyType: null, connectedAt: null, lastActiveAt: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } describe('SessionService', () => { let service: SessionService; let repository: jest.Mocked>>; let messageRepository: jest.Mocked>>; let dataSource: jest.Mocked>; let engineFactory: jest.Mocked>; let eventsGateway: jest.Mocked>; let webhookService: jest.Mocked>; let hookManager: jest.Mocked>; let configService: jest.Mocked>; let lidMappingStore: jest.Mocked>; let mockEngine: Record; beforeEach(async () => { repository = { count: jest.fn(), find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn(), remove: jest.fn(), update: jest.fn(), }; messageRepository = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), // `create()` in TypeORM just builds the entity instance; it does NOT populate @PrimaryGeneratedColumn // or @CreateDateColumn. Mirror that: return the input as-is (no id/createdAt) so tests see the same // shape the production code does before the `insert()` generated-maps merge. create: jest.fn().mockImplementation((data: Partial) => ({ ...data }) as Message), save: jest.fn().mockResolvedValue(undefined), // `insert()` returns an InsertResult; `identifiers[0]` carries the PK on both SQLite + Postgres. // `generatedMaps[0]` carries createdAt (Postgres yes; SQLite historically no — left absent here to // match the local SQLite default DB). insert: jest.fn().mockResolvedValue({ identifiers: [{ id: 'gen-uuid-1' }], generatedMaps: [], raw: undefined, }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; dataSource = { transaction: jest.fn().mockImplementation(async (cb: (manager: unknown) => Promise) => { const manager = { save: jest.fn().mockImplementation((entity: unknown) => Promise.resolve(entity)), remove: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue({ affected: 0 }), }; return cb(manager); }), }; mockEngine = { initialize: jest.fn().mockResolvedValue(undefined), destroy: jest.fn().mockResolvedValue(undefined), forceDestroy: jest.fn().mockResolvedValue(undefined), disconnect: jest.fn().mockResolvedValue(undefined), getQRCode: jest.fn().mockReturnValue(null), getGroups: jest.fn().mockResolvedValue([]), getChats: jest.fn().mockResolvedValue([]), sendSeen: jest.fn().mockResolvedValue(true), markUnread: jest.fn().mockResolvedValue(true), deleteChat: jest.fn().mockResolvedValue(true), sendChatState: jest.fn().mockResolvedValue(undefined), resolveContactPhone: jest.fn().mockResolvedValue('628111222333'), rejectCall: jest.fn().mockResolvedValue(undefined), }; engineFactory = { create: jest.fn().mockReturnValue(mockEngine), purgeSessionData: jest.fn().mockResolvedValue(undefined), }; eventsGateway = { emitSessionStatus: jest.fn(), emitSessionAuthenticated: jest.fn(), emitSessionDisconnected: jest.fn(), emitMessage: jest.fn(), emitMessageSent: jest.fn(), emitMessageAck: jest.fn(), emitMessageRevoked: jest.fn(), emitMessageReaction: jest.fn(), emitMessageEdited: jest.fn(), emitGroupJoin: jest.fn(), emitGroupLeave: jest.fn(), emitGroupUpdate: jest.fn(), emitCallReceived: jest.fn(), emitQRCode: jest.fn(), }; webhookService = { dispatch: jest.fn().mockResolvedValue(undefined), }; hookManager = { execute: jest.fn().mockResolvedValue({ continue: true, data: {} }), }; configService = { get: jest.fn().mockImplementation((_key: string, def?: T): T => def as T), }; lidMappingStore = { remember: jest.fn().mockResolvedValue(undefined), getCached: jest.fn().mockReturnValue(undefined), lidsForPhone: jest.fn().mockReturnValue([]), }; const module: TestingModule = await Test.createTestingModule({ providers: [ SessionService, { provide: getRepositoryToken(Session, 'data'), useValue: repository, }, { provide: getRepositoryToken(Message, 'data'), useValue: messageRepository, }, { provide: getDataSourceToken('data'), useValue: dataSource, }, { provide: EngineFactory, useValue: engineFactory }, { provide: EventsGateway, useValue: eventsGateway }, { provide: WebhookService, useValue: webhookService }, { provide: HookManager, useValue: hookManager }, { provide: ConfigService, useValue: configService }, { provide: LidMappingStoreService, useValue: lidMappingStore }, ], }).compile(); service = module.get(SessionService); }); // ── shutdown ────────────────────────────────────────────────────── describe('onModuleDestroy', () => { it('destroys every engine even if one destroy() throws, and clears the map', async () => { const good = { destroy: jest.fn().mockResolvedValue(undefined) }; const bad = { destroy: jest.fn().mockRejectedValue(new Error('stuck chromium')) }; const engines = (service as unknown as { engines: Map }).engines; engines.set('s-good', good); engines.set('s-bad', bad); await expect(service.onModuleDestroy()).resolves.toBeUndefined(); expect(good.destroy).toHaveBeenCalledTimes(1); expect(bad.destroy).toHaveBeenCalledTimes(1); expect(engines.size).toBe(0); }); }); // ── delete/stop teardown resilience ─────────────────────────────── describe('teardown resilience', () => { const enginesOf = () => (service as unknown as { engines: Map }).engines; const stoppingOf = () => (service as unknown as { stoppingSessions: Set }).stoppingSessions; it('delete() completes when engine.forceDestroy() rejects — map reconciled, row removed, stop-mark cleared', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); const engine = { forceDestroy: jest.fn().mockRejectedValue(new Error('stuck chromium')) }; enginesOf().set('sess-uuid-1', engine); await expect(service.delete('sess-uuid-1')).resolves.toBeUndefined(); expect(engine.forceDestroy).toHaveBeenCalledTimes(1); expect(enginesOf().has('sess-uuid-1')).toBe(false); // Map reconciled despite the failure expect(stoppingOf().has('sess-uuid-1')).toBe(false); // stop-mark cleared (no wedge) expect(hookManager.execute).toHaveBeenCalledWith('session:deleted', expect.anything(), expect.anything()); expect(dataSource.transaction).toHaveBeenCalled(); // DB removal still ran }); it('delete() purges the engine on-disk auth dir (keyed by session NAME) so a same-name recreate starts clean', async () => { (repository.findOne as jest.Mock).mockResolvedValue( createMockSession({ id: 'sess-uuid-1', name: 'test-session' }), ); enginesOf().set('sess-uuid-1', { forceDestroy: jest.fn().mockResolvedValue(undefined) }); await service.delete('sess-uuid-1'); expect(engineFactory.purgeSessionData).toHaveBeenCalledWith('test-session'); }); it('delete() purges even when no engine is loaded (a stopped session has none)', async () => { (repository.findOne as jest.Mock).mockResolvedValue( createMockSession({ id: 'sess-uuid-1', name: 'test-session' }), ); // No engine in the map — the common delete case. await service.delete('sess-uuid-1'); expect(engineFactory.purgeSessionData).toHaveBeenCalledWith('test-session'); }); it('stop() completes when engine.disconnect() rejects — map reconciled, status updated', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); const engine = { disconnect: jest.fn().mockRejectedValue(new Error('stuck socket')) }; enginesOf().set('sess-uuid-1', engine); await expect(service.stop('sess-uuid-1')).resolves.toBeDefined(); expect(engine.disconnect).toHaveBeenCalledTimes(1); expect(enginesOf().has('sess-uuid-1')).toBe(false); }); it('delete() still surfaces a real DB-removal failure (engine teardown is best-effort, DB is not)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (dataSource.transaction as jest.Mock).mockRejectedValueOnce(new Error('db down')); enginesOf().set('sess-uuid-1', { forceDestroy: jest.fn().mockResolvedValue(undefined) }); await expect(service.delete('sess-uuid-1')).rejects.toThrow('db down'); expect(stoppingOf().has('sess-uuid-1')).toBe(false); // mark still cleared on failure }); it('forceKill() force-destroys the engine, reconciles the map, and marks the session stopping', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); const engine = { forceDestroy: jest.fn().mockResolvedValue(undefined) }; enginesOf().set('sess-uuid-1', engine); const result = await service.forceKill('sess-uuid-1'); expect(engine.forceDestroy).toHaveBeenCalledTimes(1); expect(enginesOf().has('sess-uuid-1')).toBe(false); // map reconciled // Stop-mark stays set (like stop()): it blocks an in-flight reconnect from resurrecting the // session we just killed; a later start() clears it. expect(stoppingOf().has('sess-uuid-1')).toBe(true); expect(result).toBeDefined(); }); it('forceKill() completes even when forceDestroy() rejects (best-effort recovery)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); const engine = { forceDestroy: jest.fn().mockRejectedValue(new Error('still wedged')) }; enginesOf().set('sess-uuid-1', engine); await expect(service.forceKill('sess-uuid-1')).resolves.toBeDefined(); expect(enginesOf().has('sess-uuid-1')).toBe(false); // map reconciled despite the failure }); it('forceKill() throws NotFoundException for an unknown session', async () => { (repository.findOne as jest.Mock).mockResolvedValue(null); await expect(service.forceKill('nope')).rejects.toThrow(NotFoundException); }); }); // ── create ──────────────────────────────────────────────────────── describe('create', () => { it('should create a new session with CREATED status', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(null); // no duplicate (repository.create as jest.Mock).mockReturnValue(session); (repository.save as jest.Mock).mockResolvedValue(session); const result = await service.create({ name: 'test-session' }); expect(result.name).toBe('test-session'); expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ status: SessionStatus.CREATED })); expect(hookManager.execute).toHaveBeenCalledWith( 'session:created', session, expect.objectContaining({ sessionId: session.id }), ); }); it('should throw ConflictException if session name already exists', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); await expect(service.create({ name: 'test-session' })).rejects.toThrow(ConflictException); }); it('maps a name UNIQUE-violation on insert to 409 when two concurrent creates race past the pre-check', async () => { (repository.findOne as jest.Mock).mockResolvedValue(null); // pre-check passes (TOCTOU window) (repository.create as jest.Mock).mockReturnValue(createMockSession()); const uniqueErr = Object.assign(new Error('duplicate key value'), { driverError: { code: '23505' } }); (dataSource.transaction as jest.Mock).mockRejectedValueOnce(uniqueErr); await expect(service.create({ name: 'test-session' })).rejects.toThrow(ConflictException); }); }); // ── findAll / findOne / findByName ──────────────────────────────── describe('findAll', () => { it('should return all sessions ordered by createdAt DESC', async () => { const sessions = [createMockSession(), createMockSession({ id: 'sess-2' })]; (repository.find as jest.Mock).mockResolvedValue(sessions); const result = await service.findAll(); expect(result).toHaveLength(2); expect(repository.find).toHaveBeenCalledWith({ order: { createdAt: 'DESC' }, take: 1000, skip: 0 }); }); it('scopes results to a session-restricted key', async () => { (repository.find as jest.Mock).mockResolvedValue([]); await service.findAll(['sess-1', 'sess-2']); expect(repository.find).toHaveBeenCalledWith({ where: { id: In(['sess-1', 'sess-2']) }, order: { createdAt: 'DESC' }, take: 1000, skip: 0, }); }); it('returns all sessions for an unrestricted key (null/empty allowlist)', async () => { (repository.find as jest.Mock).mockResolvedValue([]); await service.findAll(null); await service.findAll([]); expect(repository.find).toHaveBeenCalledTimes(2); expect(repository.find).toHaveBeenNthCalledWith(1, { order: { createdAt: 'DESC' }, take: 1000, skip: 0 }); expect(repository.find).toHaveBeenNthCalledWith(2, { order: { createdAt: 'DESC' }, take: 1000, skip: 0 }); }); it('applies bounded pagination to the database query', async () => { (repository.find as jest.Mock).mockResolvedValue([]); await service.findAll(['sess-1'], { limit: 5000, offset: -5 }); expect(repository.find).toHaveBeenCalledWith({ where: { id: In(['sess-1']) }, order: { createdAt: 'DESC' }, take: 1000, skip: 0, }); }); }); describe('findOne', () => { it('should return session by id', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); const result = await service.findOne('sess-uuid-1'); expect(result.id).toBe('sess-uuid-1'); }); it('should throw NotFoundException if session not found', async () => { (repository.findOne as jest.Mock).mockResolvedValue(null); await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); }); }); // ── start (concurrency) ─────────────────────────────────────────── describe('start concurrency', () => { it('rejects a concurrent second start for the same id, creating only one engine (no orphan)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (engineFactory.create as jest.Mock).mockClear().mockReturnValue(mockEngine); // Two near-simultaneous start() calls for the SAME id. The has()->set() window spans an // awaited hook, so without a synchronous reservation both would create an engine and the // second set() would orphan the first's Chromium/lock dir. const results = await Promise.allSettled([service.start('sess-uuid-1'), service.start('sess-uuid-1')]); expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); const rejected = results.filter(r => r.status === 'rejected'); expect(rejected).toHaveLength(1); expect(rejected[0].reason).toBeInstanceOf(BadRequestException); // The decisive assertion: exactly ONE engine was ever created — no orphaned second engine. expect(engineFactory.create).toHaveBeenCalledTimes(1); }); it('evicts and tears down the engine when engine.initialize() fails (no orphan wedging the session)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (engineFactory.create as jest.Mock).mockClear().mockReturnValue(mockEngine); mockEngine.initialize.mockRejectedValueOnce(new Error('chromium launch failed')); await expect(service.start('sess-uuid-1')).rejects.toThrow('chromium launch failed'); const engines = (service as unknown as { engines: Map }).engines; expect(engines.has('sess-uuid-1')).toBe(false); // not left orphaned → session can be started again // forceDestroy(), not destroy(): initialize() failing usually means the browser/CDP // connection is already broken, so only a direct SIGKILL (forceDestroy) reliably reaps the // OS-level Chromium process — a graceful destroy() has nothing live to talk to and can only // time out, leaving the process orphaned (the actual bug this test now guards against). expect(mockEngine.forceDestroy).toHaveBeenCalled(); expect(mockEngine.destroy).not.toHaveBeenCalled(); }); it('maps a whatsapp-web.js auth timeout (bare string) to HTTP 504, not a bare 500 (#733)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (engineFactory.create as jest.Mock).mockClear().mockReturnValue(mockEngine); // whatsapp-web.js throws the PRIMITIVE STRING 'auth timeout' (not an Error) when its inject poll // for WA Web's login bootstrap times out — e.g. a dead/unreachable proxy (the proxy.example.com // placeholder) blocks the WebSocket so no QR is ever delivered. This must surface as a diagnostic // 504, not escape as a meaningless bare 500. mockEngine.initialize.mockRejectedValueOnce('auth timeout'); let caught: unknown; try { await service.start('sess-uuid-1'); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(HttpException); expect((caught as HttpException).getStatus()).toBe(HttpStatus.GATEWAY_TIMEOUT); }); it('allows a fresh start after the previous one completed (reservation is cleared)', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (engineFactory.create as jest.Mock).mockClear().mockReturnValue(mockEngine); await service.start('sess-uuid-1'); // Engine is now in the map, so a second start is 'already started' (not wedged at 'starting'). await expect(service.start('sess-uuid-1')).rejects.toBeInstanceOf(BadRequestException); }); it('rejects starting a new session when MAX_CONCURRENT_SESSIONS is reached', async () => { (configService.get as jest.Mock).mockImplementation((key: string, def?: T): T | number => { if (key === 'sessions.maxConcurrent') return 1; return def as T; }); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession({ id: 'sess-2' })); const engines = (service as unknown as { engines: Map }).engines; engines.set('sess-1', mockEngine); await expect(service.start('sess-2')).rejects.toThrow(/Maximum concurrent sessions reached/); expect(engineFactory.create).not.toHaveBeenCalled(); }); it('does not double-count a still-initializing session against MAX_CONCURRENT_SESSIONS', async () => { (configService.get as jest.Mock).mockImplementation((key: string, def?: T): T | number => { if (key === 'sessions.maxConcurrent') return 2; return def as T; }); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession({ id: 'sess-2' })); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (engineFactory.create as jest.Mock).mockClear().mockReturnValue(mockEngine); const internals = service as unknown as { engines: Map; initializingSessions: Set; }; // 'sess-1' is mid-initialize: present in BOTH sets (the real overlap window). Deduplicated active // count is 1, below the cap of 2 — so starting 'sess-2' must be allowed. The old summed-size // logic counted it as 2 (engines.size + initializingSessions.size) and would wrongly reject. internals.engines.set('sess-1', mockEngine); internals.initializingSessions.add('sess-1'); await expect(service.start('sess-2')).resolves.toBeDefined(); expect(engineFactory.create).toHaveBeenCalled(); internals.engines.clear(); internals.initializingSessions.clear(); }); }); describe('findByName', () => { it('should return session by name', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); const result = await service.findByName('test-session'); expect(result.name).toBe('test-session'); }); it('should throw NotFoundException if name not found', async () => { (repository.findOne as jest.Mock).mockResolvedValue(null); await expect(service.findByName('nonexistent')).rejects.toThrow(NotFoundException); }); }); // ── delete ──────────────────────────────────────────────────────── describe('delete', () => { it('should stop engine and remove session from DB', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.remove as jest.Mock).mockResolvedValue(session); await service.delete('sess-uuid-1'); expect(hookManager.execute).toHaveBeenCalledWith( 'session:deleted', expect.objectContaining({ id: 'sess-uuid-1', name: 'test-session' }), expect.any(Object), ); }); it('should destroy running engine before deleting', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.save as jest.Mock).mockImplementation(s => Promise.resolve(s)); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (repository.remove as jest.Mock).mockResolvedValue(session); // Start the session first to create an engine await service.start('sess-uuid-1'); // Now delete await service.delete('sess-uuid-1'); // delete() reaps permanently, so it force-destroys (SIGKILL) rather than a graceful destroy(). expect(mockEngine.forceDestroy).toHaveBeenCalled(); }); it('removes the session and all its child rows explicitly in one transaction (SQLite cascade is off)', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); const managerDelete = jest.fn().mockResolvedValue({ affected: 0 }); const managerRemove = jest.fn().mockResolvedValue(undefined); (dataSource.transaction as jest.Mock).mockImplementationOnce(async (cb: (m: unknown) => Promise) => cb({ save: jest.fn(), remove: managerRemove, delete: managerDelete }), ); await service.delete('sess-uuid-1'); // messages/message_batches have no FK; webhooks/templates/baileys_stored_messages declare an // ON DELETE CASCADE FK, but SQLite runs with foreign_keys OFF so it never fires — delete() must // clear ALL of them explicitly or a session delete orphans them (webhooks retain the secret). expect(managerDelete).toHaveBeenCalledWith(Message, { sessionId: 'sess-uuid-1' }); expect(managerDelete).toHaveBeenCalledWith(MessageBatch, { sessionId: 'sess-uuid-1' }); expect(managerDelete).toHaveBeenCalledWith(Webhook, { sessionId: 'sess-uuid-1' }); expect(managerDelete).toHaveBeenCalledWith(Template, { sessionId: 'sess-uuid-1' }); expect(managerDelete).toHaveBeenCalledWith(BaileysStoredMessage, { sessionId: 'sess-uuid-1' }); expect(managerRemove).toHaveBeenCalledWith(session); }); }); // ── start ───────────────────────────────────────────────────────── describe('start', () => { it('should create engine and set status to INITIALIZING', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); expect(engineFactory.create).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'test-session', dbSessionId: 'sess-uuid-1' }), ); expect(mockEngine.initialize).toHaveBeenCalled(); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.INITIALIZING, }); }); it('should throw BadRequestException if session already started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); await expect(service.start('sess-uuid-1')).rejects.toThrow(BadRequestException); }); it('should execute session:starting hook before initializing engine', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); expect(hookManager.execute).toHaveBeenCalledWith( 'session:starting', expect.objectContaining({ sessionId: 'sess-uuid-1' }), expect.any(Object), ); }); it('persists INITIALIZING before engine.initialize() runs (no post-init clobber) — #219', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); let initializingPersistedBeforeInit = false; mockEngine.initialize.mockImplementation(() => { initializingPersistedBeforeInit = (repository.update as jest.Mock).mock.calls.some( (call: unknown[]) => (call[1] as { status?: SessionStatus })?.status === SessionStatus.INITIALIZING, ); return Promise.resolve(); }); await service.start('sess-uuid-1'); // The engine drives status forward via callbacks during initialize(); writing // INITIALIZING afterwards would clobber that progress, so it must be set before. expect(initializingPersistedBeforeInit).toBe(true); const initializingWrites = (repository.update as jest.Mock).mock.calls.filter( (call: unknown[]) => (call[1] as { status?: SessionStatus })?.status === SessionStatus.INITIALIZING, ); expect(initializingWrites).toHaveLength(1); }); }); // ── engine onError / lastError surfacing (#219) ─────────────────── describe('terminal-failure engine eviction', () => { interface I { initializeEngine: (id: string, s: Session) => Promise; executeReconnect: (id: string, s: Session, st: unknown) => Promise; engines: Map; } const intern = () => service as unknown as I; const flush = () => new Promise(resolve => setImmediate(resolve)); it('onError evicts the failed engine and force-destroys it, so the slot frees and a restart is not blocked', async () => { (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await intern().initializeEngine('sess-uuid-1', createMockSession()); expect(intern().engines.get('sess-uuid-1')).toBe(mockEngine); const callbacks = (mockEngine.initialize.mock.calls[0] as [EngineEventCallbacks])[0]; callbacks.onError?.('net::ERR_INVALID_AUTH_CREDENTIALS'); await flush(); expect(intern().engines.has('sess-uuid-1')).toBe(false); expect(mockEngine.forceDestroy).toHaveBeenCalledTimes(1); }); it('executeReconnect evicts and force-destroys the half-initialized engine when re-init fails (no orphan on reconnect-exhaustion)', async () => { (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); // initializeEngine registers the engine, then engine.initialize() rejects — the half-built engine // must not be left in the map for the next start() to trip over as "already started". mockEngine.initialize.mockRejectedValueOnce(new Error('chromium launch failed')); // Suppress the real reconnect timer scheduled by the catch block. jest .spyOn(service as unknown as { scheduleReconnect: () => void }, 'scheduleReconnect') .mockImplementation(() => undefined); const state = { attempts: 1, timer: null, maxAttempts: 5, baseDelay: 5000 }; await intern().executeReconnect('sess-uuid-1', createMockSession(), state); await flush(); expect(intern().engines.has('sess-uuid-1')).toBe(false); expect(mockEngine.forceDestroy).toHaveBeenCalled(); }); }); // ── initializeEngine init-timeout race (#667 follow-up) ─────────── describe('initializeEngine init-timeout race', () => { type Intern = { engines: Map; sessionErrors: Map; }; const intern = () => service as unknown as Intern; it('a REAL engine.initialize() rejection still becomes FAILED with the reason recorded (the timeout-scoped catch must NOT downgrade it to DISCONNECTED)', async () => { // Regression guard for #600/#631 diagnosability: a real init failure (e.g. Chromium can't launch) // must stay FAILED+reason. The catch inside initializeEngine handles ONLY the timeout case and // rethrows everything else untouched, so start()'s catch still owns the FAILED+reason path. (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); mockEngine.initialize.mockRejectedValueOnce(new Error('chromium launch failed')); await expect(service.start('sess-uuid-1')).rejects.toThrow('chromium launch failed'); expect(intern().engines.has('sess-uuid-1')).toBe(false); // evicted, not left wedged expect(mockEngine.forceDestroy).toHaveBeenCalled(); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.FAILED }); expect(intern().sessionErrors.get('sess-uuid-1')).toBe('chromium launch failed'); }); it('a wedged engine.initialize() (never settles) is force-destroyed, evicted, marked DISCONNECTED, and rethrows after 60s', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); // The hang: initialize() neither resolves nor rejects. mockEngine.initialize.mockReturnValue(new Promise(() => undefined)); jest.useFakeTimers(); // A start()-path timeout must NOT auto-schedule a reconnect (reconnect is executeReconnect's // domain; a manual start that times out leaves the session DISCONNECTED for the operator). const scheduleReconnect = jest.spyOn( service as unknown as { scheduleReconnect: (...a: unknown[]) => void }, 'scheduleReconnect', ); try { const pending = service.start('sess-uuid-1'); // Attach the handler synchronously so the timeout rejection is never briefly unhandled // during the fake-timer tick (which would otherwise fail the test for the wrong reason). let caught: unknown; const settled = pending.catch((e: unknown) => { caught = e; }); // Advance past the 60s init deadline (the async variant flushes microtasks so the // teardown + status writes settle within the same advance). await jest.advanceTimersByTimeAsync(60_000); await settled; // The outer init-hang deadline now maps to a diagnostic 504 (like the auth-timeout) instead of // escaping as a bare 500 (#733 follow-up). Cleanup (force-destroy + evict + DISCONNECTED) still // runs inside initializeEngine before the mapped error is thrown — asserted below. expect(caught).toBeInstanceOf(HttpException); expect((caught as HttpException).getStatus()).toBe(HttpStatus.GATEWAY_TIMEOUT); expect((caught as HttpException).getResponse() as string).toMatch(/timed out after 60000ms/i); expect(mockEngine.forceDestroy).toHaveBeenCalled(); // wedged browser reaped expect(intern().engines.has('sess-uuid-1')).toBe(false); // slot freed for retry expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.DISCONNECTED }); expect(scheduleReconnect).not.toHaveBeenCalled(); // no auto-reconnect from a start() timeout } finally { jest.useRealTimers(); } }); it('extends the init deadline past 60s when WWEBJS_AUTH_TIMEOUT_MS is raised, so a legitimate slow auth wait is not cut short', async () => { // #353 slow-boot escape hatch: operators raise the auth wait because WA-Web's inject poll // legitimately takes longer on WSL2/low-resource containers. The init race must extend with // it, not SIGKILL the init at the 60s floor mid-auth. (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); mockEngine.initialize.mockReturnValue(new Promise(() => undefined)); process.env.WWEBJS_AUTH_TIMEOUT_MS = '120000'; // → derived deadline max(60s, 120s+30s) = 150s jest.useFakeTimers(); try { const pending = service.start('sess-uuid-1'); let caught: unknown; const settled = pending.catch((e: unknown) => { caught = e; }); // At 60s the old hardcoded deadline would have fired and killed a healthy slow init; the // derived one must still be waiting. await jest.advanceTimersByTimeAsync(60_000); expect(caught).toBeUndefined(); expect(mockEngine.forceDestroy).not.toHaveBeenCalled(); // NOT cut short mid-auth // Past the derived 150s deadline the race finally fires. await jest.advanceTimersByTimeAsync(90_000); // 60s + 90s = 150s await settled; expect(caught).toBeInstanceOf(HttpException); expect((caught as HttpException).getStatus()).toBe(HttpStatus.GATEWAY_TIMEOUT); expect((caught as HttpException).getResponse() as string).toMatch(/timed out after 150000ms/i); } finally { jest.useRealTimers(); delete process.env.WWEBJS_AUTH_TIMEOUT_MS; } }); }); describe('scheduleReconnect (max attempts)', () => { it('reports "auto-reconnect disabled" (not "failed after 0 attempts") when maxAttempts is 0', async () => { const i = service as unknown as { reconnectStates: Map; sessionErrors: Map; scheduleReconnect: (id: string, session: Session) => void; }; i.reconnectStates.set('sess-uuid-1', { attempts: 0, timer: null, maxAttempts: 0, baseDelay: 5000 }); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); i.scheduleReconnect('sess-uuid-1', createMockSession()); await new Promise(resolve => setImmediate(resolve)); // maxAttempts:0 means auto-reconnect is OFF, not that 0 attempts were tried and failed. expect(i.sessionErrors.get('sess-uuid-1')).toMatch(/auto-reconnect is disabled/i); }); }); describe('scheduleReconnect (reconnect policy)', () => { type PolicyInternals = { reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number; lastAttemptAt?: number; } >; sessionErrors: Map; scheduleReconnect: (id: string, session: Session) => void; executeReconnect: (...args: unknown[]) => Promise; }; const internals = (): PolicyInternals => service as unknown as PolicyInternals; it('keeps scheduling past the old 5-attempt budget by default (unlimited), the backoff parking at the 1h cap', () => { jest.useFakeTimers(); try { const i = internals(); // What start() seeds when session.config sets no maxReconnectAttempts. const state = { attempts: 0, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000 }; i.reconnectStates.set('sess-uuid-1', state); const exec = jest.spyOn(i, 'executeReconnect').mockResolvedValue(undefined); // Twelve consecutive disconnects — the pre-fix default (5) would have wedged FAILED at the // 6th; with the unlimited default every one schedules another attempt. for (let k = 0; k < 12; k++) { i.scheduleReconnect('sess-uuid-1', createMockSession()); } expect(state.attempts).toBe(12); expect(i.sessionErrors.has('sess-uuid-1')).toBe(false); // never terminally FAILED expect(jest.getTimerCount()).toBe(1); // still exactly one pending timer // The 12th schedule computed its delay with attempts=11: 5000*2^11 ≈ 10.24M ms, clamped to // the 1h cap — the timer fires exactly at the cap, not earlier. jest.advanceTimersByTime(3_599_999); expect(exec).not.toHaveBeenCalled(); jest.advanceTimersByTime(1); expect(exec).toHaveBeenCalledTimes(1); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('resets the attempt budget after a 5-minute stable stretch (transient drops must not accrue)', () => { jest.useFakeTimers(); try { const i = internals(); const state = { attempts: 4, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000, lastAttemptAt: Date.now(), }; i.reconnectStates.set('sess-uuid-1', state); const exec = jest.spyOn(i, 'executeReconnect').mockResolvedValue(undefined); // A drop 299s after the last attempt is still the same bad stretch: the budget keeps accruing. jest.advanceTimersByTime(299_999); i.scheduleReconnect('sess-uuid-1', createMockSession()); expect(state.attempts).toBe(5); // 4 -> 5, no reset // ≥5 min since the last attempt means the session demonstrably stayed up — the budget // restarts at 0 (the first schedule's 80s timer fires during this advance; irrelevant here). jest.advanceTimersByTime(300_000); i.scheduleReconnect('sess-uuid-1', createMockSession()); expect(state.attempts).toBe(1); // ...so the backoff restarts at the base delay (~5s), not 2^4 × base (80s). const callsBefore = exec.mock.calls.length; jest.advanceTimersByTime(4_999); expect(exec.mock.calls.length).toBe(callsBefore); jest.advanceTimersByTime(1_001); expect(exec.mock.calls.length).toBe(callsBefore + 1); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('still wedges FAILED once an EXPLICIT cap is exhausted', () => { jest.useFakeTimers(); try { const i = internals(); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); const state = { attempts: 2, timer: null, maxAttempts: 3, baseDelay: 5000 }; i.reconnectStates.set('sess-uuid-1', state); i.scheduleReconnect('sess-uuid-1', createMockSession()); // attempt 3/3 still schedules expect(state.attempts).toBe(3); expect(i.sessionErrors.has('sess-uuid-1')).toBe(false); i.scheduleReconnect('sess-uuid-1', createMockSession()); // budget exhausted → terminal FAILED expect(state.attempts).toBe(3); // no further attempt consumed expect(i.sessionErrors.get('sess-uuid-1')).toMatch(/Reconnection failed after 3 attempts/); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.FAILED }); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); describe('scheduleReconnect (reconnect-loop observability)', () => { type LoopInternals = { reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number; lastAttemptAt?: number; } >; scheduleReconnect: (id: string, session: Session) => void; }; const internals = (): LoopInternals => service as unknown as LoopInternals; const loopDispatches = (): unknown[][] => ((webhookService.dispatch as jest.Mock).mock.calls as unknown[][]).filter(c => c[1] === 'session.reconnect_loop'); it('counts every scheduled attempt but emits no loop alert on attempts 1..4', () => { jest.useFakeTimers(); try { const i = internals(); const state = { attempts: 0, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000 }; i.reconnectStates.set('sess-uuid-1', state); const attemptsBefore = getSessionReconnectAttemptsTotal(); const alertsBefore = getSessionReconnectLoopAlertsTotal(); for (let k = 0; k < 4; k++) { i.scheduleReconnect('sess-uuid-1', createMockSession()); } expect(state.attempts).toBe(4); // One counter tick per scheduled attempt. expect(getSessionReconnectAttemptsTotal()).toBe(attemptsBefore + 4); // ...but the loop alert only arms at attempt 5 — no dispatch, no alert tick before that. expect(loopDispatches()).toHaveLength(0); expect(getSessionReconnectLoopAlertsTotal()).toBe(alertsBefore); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('alerts on attempts 5 and 10 with the loop payload (one signal per 5 consecutive attempts)', () => { jest.useFakeTimers(); try { const i = internals(); const state = { attempts: 0, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000 }; i.reconnectStates.set('sess-uuid-1', state); const attemptsBefore = getSessionReconnectAttemptsTotal(); const alertsBefore = getSessionReconnectLoopAlertsTotal(); for (let k = 0; k < 10; k++) { i.scheduleReconnect('sess-uuid-1', createMockSession()); } expect(getSessionReconnectAttemptsTotal()).toBe(attemptsBefore + 10); expect(getSessionReconnectLoopAlertsTotal()).toBe(alertsBefore + 2); const calls = loopDispatches(); expect(calls).toHaveLength(2); // Attempt 5: the delay was computed with attempts=4 → 5000*2^4 = 80s (+ <1s jitter). expect(calls[0][0]).toBe('sess-uuid-1'); expect(calls[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', attempts: 5 }); expect((calls[0][2] as { nextDelayMs: number }).nextDelayMs).toBeGreaterThanOrEqual(80_000); expect((calls[0][2] as { nextDelayMs: number }).nextDelayMs).toBeLessThan(81_000); // Attempt 10: computed with attempts=9 → 5000*2^9 = 2560s (+ <1s jitter). expect(calls[1][2]).toMatchObject({ sessionId: 'sess-uuid-1', attempts: 10 }); expect((calls[1][2] as { nextDelayMs: number }).nextDelayMs).toBeGreaterThanOrEqual(2_560_000); expect((calls[1][2] as { nextDelayMs: number }).nextDelayMs).toBeLessThan(2_561_000); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('re-arms the alert after a stability reset: the next alert waits 5 fresh attempts', () => { jest.useFakeTimers(); try { const i = internals(); // Four attempts already consumed, then the session stayed up ≥5 min — the budget resets. const state = { attempts: 4, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000, lastAttemptAt: Date.now(), }; i.reconnectStates.set('sess-uuid-1', state); const alertsBefore = getSessionReconnectLoopAlertsTotal(); jest.advanceTimersByTime(300_000); // stability window elapses (no timer pending yet) for (let k = 0; k < 4; k++) { i.scheduleReconnect('sess-uuid-1', createMockSession()); } // Without the reset the very first of these would have been attempt 5 and alerted; instead the // streak restarted at 0, so 4 fresh schedules reach only attempt 4 — still no alert. expect(state.attempts).toBe(4); expect(loopDispatches()).toHaveLength(0); expect(getSessionReconnectLoopAlertsTotal()).toBe(alertsBefore); i.scheduleReconnect('sess-uuid-1', createMockSession()); // fresh attempt 5 → alert again expect(state.attempts).toBe(5); const calls = loopDispatches(); expect(calls).toHaveLength(1); expect(calls[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', attempts: 5 }); expect(getSessionReconnectLoopAlertsTotal()).toBe(alertsBefore + 1); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); describe('start() stale reconnect timer', () => { it('cancels a pending reconnect timer before recreating the engine', async () => { const i = service as unknown as { reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number } >; cancelReconnect: (id: string) => void; }; // Spy clearTimeout directly so the assertion pins that the stale HANDLE was actually cleared — // not merely that cancelReconnect was reached (which would hold even if it forgot clearTimeout). const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); const staleFired = jest.fn(); // Seed a pending reconnect timer exactly as a failed executeReconnect leaves behind. // tsc resolves setTimeout to the DOM overload (number) in the spec context; force the field type. const staleTimer = setTimeout(staleFired, 30000) as unknown as NodeJS.Timeout; i.reconnectStates.set('sess-uuid-1', { attempts: 1, timer: staleTimer, maxAttempts: 5, baseDelay: 5000 }); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); await service.start('sess-uuid-1'); // start() must cancel the stale timer so it can't later destroy/replace the engine start() just // created (or orphan a Chromium process), then install a fresh reconnect state. expect(staleFired).not.toHaveBeenCalled(); expect(clearTimeoutSpy).toHaveBeenCalledWith(staleTimer); const after = i.reconnectStates.get('sess-uuid-1'); expect(after?.timer).toBeNull(); expect(after?.attempts).toBe(0); clearTimeout(staleTimer); clearTimeoutSpy.mockRestore(); }); }); describe('reconnect/stop race', () => { interface Internals { executeReconnect: (id: string, session: Session, state: unknown) => Promise; stoppingSessions: Set; engines: Map; } const internals = (): Internals => service as unknown as Internals; const reconnectState = { attempts: 1, timer: null, maxAttempts: 5, baseDelay: 5000 }; it('does not create an engine when the session was already stopped (early guard)', async () => { const i = internals(); i.stoppingSessions.add('sess-uuid-1'); await i.executeReconnect('sess-uuid-1', createMockSession(), reconnectState); expect(i.engines.has('sess-uuid-1')).toBe(false); expect(engineFactory.create).not.toHaveBeenCalled(); }); it('tears down an engine created when a stop lands during init (post-init guard)', async () => { const i = internals(); // Simulate a concurrent stop() during engine init: initialize() flips the teardown flag. mockEngine.initialize.mockImplementation(() => { i.stoppingSessions.add('sess-uuid-1'); return Promise.resolve(); }); await i.executeReconnect('sess-uuid-1', createMockSession(), reconnectState); expect(mockEngine.destroy).toHaveBeenCalled(); expect(i.engines.has('sess-uuid-1')).toBe(false); }); it('tears down an engine created when a delete lands during init (session row gone, mark cleared)', async () => { const i = internals(); // The delete↔reconnect race: delete() clears its teardown mark in finally (ms) AND removes the // session row, both well before a slow engine.initialize() (Chromium launch) resolves. Unlike // stop(), delete() does not leave the mark set, so the mark alone can't catch it — the post-init // guard must re-check that the session still exists before keeping the engine it just created. mockEngine.initialize.mockImplementation(() => { i.stoppingSessions.delete('sess-uuid-1'); (repository.findOne as jest.Mock).mockResolvedValue(null); return Promise.resolve(); }); await i.executeReconnect('sess-uuid-1', createMockSession(), reconnectState); expect(mockEngine.destroy).toHaveBeenCalled(); expect(i.engines.has('sess-uuid-1')).toBe(false); }); it('still re-initializes when the old engine destroy() hangs (time-bounded teardown)', async () => { jest.useFakeTimers(); try { const i = internals(); // A wedged Chromium: destroy() never resolves — the exact condition that triggers a reconnect. const stuck = { destroy: jest.fn(() => new Promise(() => undefined)) }; i.engines.set('sess-uuid-1', stuck); const done = i.executeReconnect('sess-uuid-1', createMockSession(), reconnectState); await jest.advanceTimersByTimeAsync(10_000); // teardown timeout elapses // The hang no longer blocks reconnection: re-init proceeded instead of wedging forever. expect(stuck.destroy).toHaveBeenCalledTimes(1); expect(engineFactory.create).toHaveBeenCalled(); await done; } finally { jest.useRealTimers(); } }); it('keeps a freshly-reconnected healthy engine when the post-init retirement check errors (transient DB)', async () => { const i = internals(); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); // Re-init succeeds and registers a live engine; the retirement DB read then fails transiently. // It must NOT be misread as a reconnect failure that reaps the healthy engine we just recovered. jest .spyOn(service as unknown as { isSessionRetired: () => Promise }, 'isSessionRetired') .mockRejectedValue(new Error('transient db blip')); await i.executeReconnect('sess-uuid-1', createMockSession(), reconnectState); expect(mockEngine.forceDestroy).not.toHaveBeenCalled(); expect(mockEngine.destroy).not.toHaveBeenCalled(); expect(i.engines.has('sess-uuid-1')).toBe(true); }); it('does not stack reconnect timers when scheduled twice back-to-back', () => { jest.useFakeTimers(); try { const i = service as unknown as { reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number } >; scheduleReconnect: (id: string, s: Session) => void; }; i.reconnectStates.set('sess-uuid-1', { attempts: 0, timer: null, maxAttempts: 5, baseDelay: 5000 }); // Two disconnect events in a row each schedule a reconnect. The second must clear the // first timer, leaving exactly one pending — otherwise both fire and double-init the engine. i.scheduleReconnect('sess-uuid-1', createMockSession()); i.scheduleReconnect('sess-uuid-1', createMockSession()); expect(jest.getTimerCount()).toBe(1); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); describe('scheduleReconnect during shutdown', () => { type ReconnectInternals = { reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number } >; scheduleReconnect: (id: string, s: Session) => void; executeReconnect: (...args: unknown[]) => Promise; shutdownService?: { isShuttingDown: () => boolean }; }; it('does not spawn a fresh engine while the process is draining', () => { jest.useFakeTimers(); try { const i = service as unknown as ReconnectInternals; i.reconnectStates.set('sess-uuid-1', { attempts: 0, timer: null, maxAttempts: 5, baseDelay: 5000 }); // Drain in progress: a disconnect during the shutdown window must NOT schedule a reconnect that // would launch a fresh Chromium racing onModuleDestroy's teardown. i.shutdownService = { isShuttingDown: () => true }; const exec = jest.spyOn(i, 'executeReconnect').mockResolvedValue(undefined); i.scheduleReconnect('sess-uuid-1', createMockSession()); jest.advanceTimersByTime(120000); expect(exec).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); expect(i.reconnectStates.get('sess-uuid-1')!.attempts).toBe(0); // no attempt consumed } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('schedules a reconnect normally when not shutting down', () => { jest.useFakeTimers(); try { const i = service as unknown as ReconnectInternals; i.reconnectStates.set('sess-uuid-2', { attempts: 0, timer: null, maxAttempts: 5, baseDelay: 5000 }); i.shutdownService = { isShuttingDown: () => false }; const exec = jest.spyOn(i, 'executeReconnect').mockResolvedValue(undefined); i.scheduleReconnect('sess-uuid-2', createMockSession()); expect(i.reconnectStates.get('sess-uuid-2')!.attempts).toBe(1); // an attempt was scheduled jest.advanceTimersByTime(120000); expect(exec).toHaveBeenCalled(); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); describe('liveness watchdog', () => { type WatchdogInternals = { engines: Map; reconnectStates: Map< string, { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number } >; livenessFailures: Map; watchdogTimer: NodeJS.Timeout | null; scheduleReconnect: (id: string, session: Session) => void; }; const internals = (): WatchdogInternals => service as unknown as WatchdogInternals; // Auto-start is OFF in these tests — the watchdog must start regardless. const originalFlag = process.env.AUTO_START_SESSIONS; beforeEach(() => { delete process.env.AUTO_START_SESSIONS; }); afterEach(() => { if (originalFlag === undefined) delete process.env.AUTO_START_SESSIONS; else process.env.AUTO_START_SESSIONS = originalFlag; }); const seedReadySession = (engine: unknown): void => { internals().engines.set('sess-uuid-1', engine); internals().reconnectStates.set('sess-uuid-1', { attempts: 0, timer: null, maxAttempts: Number.POSITIVE_INFINITY, baseDelay: 5000, }); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); }; it('treats a READY engine failing the probe twice in a row as a disconnect (webhook + WS + DISCONNECTED + reconnect)', async () => { jest.useFakeTimers(); try { const engine = { getStatus: jest.fn().mockReturnValue(EngineStatus.READY), probeLiveness: jest.fn().mockResolvedValue(false), }; seedReadySession(engine); const scheduleSpy = jest.spyOn(internals(), 'scheduleReconnect'); await service.onApplicationBootstrap(); // Tick 1: the first failure stays below the 2-consecutive-failures threshold — nothing happens. await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS); expect(scheduleSpy).not.toHaveBeenCalled(); expect(repository.update).not.toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.DISCONNECTED, }); // Tick 2: the second CONSECUTIVE failure routes through the exact engine-disconnect path. await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS); expect(scheduleSpy).toHaveBeenCalledWith('sess-uuid-1', expect.objectContaining({ id: 'sess-uuid-1' })); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.DISCONNECTED }); expect(webhookService.dispatch).toHaveBeenCalledWith( 'sess-uuid-1', 'session.disconnected', expect.objectContaining({ reason: 'liveness probe failed (watchdog)' }), ); expect(eventsGateway.emitSessionDisconnected).toHaveBeenCalledWith('sess-uuid-1', { reason: 'liveness probe failed (watchdog)', }); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('resets the failure counter after a successful probe (no disconnect from non-consecutive failures)', async () => { jest.useFakeTimers(); try { const engine = { getStatus: jest.fn().mockReturnValue(EngineStatus.READY), // fail → succeed → fail: never two IN A ROW, so the session must survive all three ticks. probeLiveness: jest .fn() .mockResolvedValueOnce(false) .mockResolvedValueOnce(true) .mockResolvedValueOnce(false), }; seedReadySession(engine); const scheduleSpy = jest.spyOn(internals(), 'scheduleReconnect'); await service.onApplicationBootstrap(); await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS * 3); expect(engine.probeLiveness).toHaveBeenCalledTimes(3); expect(scheduleSpy).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalledWith( 'sess-uuid-1', 'session.disconnected', expect.anything(), ); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('skips engines that are not READY and engines that do not implement probeLiveness', async () => { jest.useFakeTimers(); try { const notReady = { getStatus: jest.fn().mockReturnValue(EngineStatus.INITIALIZING), probeLiveness: jest.fn().mockResolvedValue(false), }; // READY but no probe method: the watchdog must feature-detect and leave it to engine events. const noProbe = { getStatus: jest.fn().mockReturnValue(EngineStatus.READY) }; internals().engines.set('sess-not-ready', notReady); internals().engines.set('sess-no-probe', noProbe); await service.onApplicationBootstrap(); await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS * 2); expect(notReady.probeLiveness).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalledWith( expect.anything(), 'session.disconnected', expect.anything(), ); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('counts a hung probe (timeout) as a failure and clears it on the next successful probe', async () => { jest.useFakeTimers(); try { const engine = { getStatus: jest.fn().mockReturnValue(EngineStatus.READY), probeLiveness: jest .fn() .mockImplementationOnce(() => new Promise(() => undefined)) // hangs → probe timeout .mockResolvedValueOnce(true), }; seedReadySession(engine); await service.onApplicationBootstrap(); await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS); // tick 1: probe hangs expect(internals().livenessFailures.get('sess-uuid-1')).toBeUndefined(); // not counted yet await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_PROBE_TIMEOUT_MS); // 15s → timeout failure expect(internals().livenessFailures.get('sess-uuid-1')).toBe(1); await jest.advanceTimersByTimeAsync(SESSION_WATCHDOG_INTERVAL_MS); // tick 2: success expect(internals().livenessFailures.get('sess-uuid-1')).toBeUndefined(); // counter reset } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); it('clears the watchdog timer in onModuleDestroy (idempotent, no open handle)', async () => { jest.useFakeTimers(); try { await service.onApplicationBootstrap(); expect(internals().watchdogTimer).not.toBeNull(); expect(jest.getTimerCount()).toBe(1); // the interval itself await service.onModuleDestroy(); expect(internals().watchdogTimer).toBeNull(); expect(jest.getTimerCount()).toBe(0); await expect(service.onModuleDestroy()).resolves.toBeUndefined(); // safe to call twice } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); describe('engine onError', () => { type EngineCallbacks = { onError?: (reason: string) => void; onReady?: (phone: string, name: string) => void }; const startAndCapture = async (): Promise => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); let captured: EngineCallbacks = {}; mockEngine.initialize.mockImplementation((cb: EngineCallbacks) => { captured = cb; return Promise.resolve(); }); await service.start('sess-uuid-1'); return captured; }; it('marks the session FAILED and runs the session:error hook on a terminal engine error', async () => { const callbacks = await startAndCapture(); callbacks.onError?.('Failed to launch the browser process: spawn ENOENT'); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.FAILED }); expect(hookManager.execute).toHaveBeenCalledWith( 'session:error', expect.objectContaining({ reason: 'Failed to launch the browser process: spawn ENOENT' }), expect.objectContaining({ sessionId: 'sess-uuid-1' }), ); }); it('surfaces the failure reason via lastError when the session is FAILED', async () => { const callbacks = await startAndCapture(); callbacks.onError?.('chromium missing'); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession({ status: SessionStatus.FAILED })); const result = await service.findOne('sess-uuid-1'); expect(result.lastError).toBe('chromium missing'); }); it('clears the stored failure reason when the session is deleted (no in-memory leak)', async () => { const callbacks = await startAndCapture(); callbacks.onError?.('chromium missing'); const sessionErrors = (service as unknown as { sessionErrors: Map }).sessionErrors; expect(sessionErrors.has('sess-uuid-1')).toBe(true); // precondition: the FAILED reason is recorded (repository.findOne as jest.Mock).mockResolvedValue(createMockSession({ status: SessionStatus.FAILED })); await service.delete('sess-uuid-1'); // Without cleanup, the entry would linger forever keyed by a deleted UUID (unbounded growth). expect(sessionErrors.has('sess-uuid-1')).toBe(false); }); it('does not surface lastError once the session has recovered', async () => { const callbacks = await startAndCapture(); callbacks.onError?.('transient failure'); // Engine later becomes ready, which clears the stored reason. callbacks.onReady?.('628123', 'Tester'); (repository.findOne as jest.Mock).mockResolvedValue(createMockSession({ status: SessionStatus.READY })); const result = await service.findOne('sess-uuid-1'); expect(result.lastError).toBeUndefined(); }); it('cancels a pending reconnect timer when the engine then errors terminally', async () => { const callbacks = await startAndCapture(); jest.useFakeTimers(); try { const i = service as unknown as { scheduleReconnect: (id: string, s: Session) => void }; // A prior onDisconnected scheduled a reconnect… i.scheduleReconnect('sess-uuid-1', createMockSession()); expect(jest.getTimerCount()).toBe(1); // …then a terminal failure arrives. It must cancel the pending reconnect so the timer // can't resurrect a session the operator has to manually restart. callbacks.onError?.('fatal browser crash'); // onError also evicts the engine; teardownEngineSafely schedules a transient timeout that is // cleared once forceDestroy settles. Flush microtasks so only the reconnect-cancellation (the // property under test) remains — the resurrection timer must be gone. await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(jest.getTimerCount()).toBe(0); } finally { jest.clearAllTimers(); jest.useRealTimers(); } }); }); // ── engine-identity guard: stale-callback isolation ─────────────── // A callback can fire after its engine was torn down (post-stop) or after a newer engine // replaced it for the same id (post-restart / reconnect). Such a stale callback must not // mutate the session that now belongs to a different (or no) engine. describe('stale engine callback isolation', () => { const enginesOf = () => (service as unknown as { engines: Map }).engines; const startAndCapture = async (): Promise => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const calls = mockEngine.initialize.mock.calls as [EngineEventCallbacks][]; return calls[0][0]; }; it('lets the live engine drive status (guard is a no-op for the active engine)', async () => { const callbacks = await startAndCapture(); (repository.update as jest.Mock).mockClear(); callbacks.onReady?.('628123', 'Tester'); expect(repository.update).toHaveBeenCalledWith( 'sess-uuid-1', expect.objectContaining({ status: SessionStatus.READY }), ); }); it('bridges session.authenticated to the socket when the live engine becomes ready', async () => { const callbacks = await startAndCapture(); (eventsGateway.emitSessionAuthenticated as jest.Mock).mockClear(); callbacks.onReady?.('628123', 'Tester'); expect(eventsGateway.emitSessionAuthenticated).toHaveBeenCalledWith('sess-uuid-1', { phone: '628123', pushName: 'Tester', }); }); it('bridges session.disconnected (with reason) to the socket from the live engine', async () => { const callbacks = await startAndCapture(); // The live onDisconnected handler schedules a reconnect timer after emitting; neutralize // it so the test leaves no pending timer (same pattern as the reconnect specs). jest .spyOn(service as unknown as { scheduleReconnect: (id: string, s: unknown) => void }, 'scheduleReconnect') .mockImplementation(() => {}); (eventsGateway.emitSessionDisconnected as jest.Mock).mockClear(); callbacks.onDisconnected?.('socket closed'); expect(eventsGateway.emitSessionDisconnected).toHaveBeenCalledWith('sess-uuid-1', { reason: 'socket closed' }); }); it('ignores onReady from an engine that was torn down (post-stop window)', async () => { const callbacks = await startAndCapture(); enginesOf().delete('sess-uuid-1'); // stop()/forceKill() removes the engine from the live map (repository.update as jest.Mock).mockClear(); callbacks.onReady?.('628123', 'Tester'); expect(repository.update).not.toHaveBeenCalled(); }); it('ignores onDisconnected from a superseded engine after restart (stale generation)', async () => { const callbacks = await startAndCapture(); // engine A captured enginesOf().set('sess-uuid-1', { marker: 'engine-B' }); // a newer engine now owns the id (repository.update as jest.Mock).mockClear(); (webhookService.dispatch as jest.Mock).mockClear(); callbacks.onDisconnected?.('socket closed'); expect(repository.update).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalled(); }); it('ignores onMessage from a superseded engine (no persist, no webhook)', async () => { const callbacks = await startAndCapture(); enginesOf().set('sess-uuid-1', { marker: 'engine-B' }); (messageRepository.insert as jest.Mock).mockClear(); (webhookService.dispatch as jest.Mock).mockClear(); callbacks.onMessage?.({ id: 'wa-1', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'hi', type: 'text', timestamp: 1, fromMe: false, isGroup: false, }); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.insert).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalled(); }); }); // ── engine message-event webhook dispatch ───────────────────────── describe('engine message-event webhook dispatch', () => { const flush = (): Promise => new Promise(resolve => setImmediate(resolve)); async function startAndCaptureCallbacks(): Promise { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const calls = mockEngine.initialize.mock.calls as [EngineEventCallbacks][]; return calls[0][0]; } function dispatchedEvents(event: string): unknown[][] { const calls = (webhookService.dispatch as jest.Mock).mock.calls as unknown[][]; return calls.filter(call => call[1] === event); } const makeMessage = (overrides: Partial = {}): IncomingMessage => ({ id: 'wa-msg-1', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'hello', type: 'text', timestamp: 1706868000, fromMe: false, isGroup: false, ...overrides, }); // A plugin returning `continue: false` means "stop the handler chain" — the plugins after it do not // run. It must NOT also delete the message from the operator's records. Honouring it here used to // skip the insert, the webhook and the websocket emit, so an auto-reply plugin doing the ordinary // thing (keeping other bots off a message it answered) silently erased the customer's message from // history, leaving bot replies answering nothing. Both branches had no coverage at all. it('still persists and dispatches an inbound message when a plugin stops the hook chain', async () => { (hookManager.execute as jest.Mock).mockImplementation((event: string, data: unknown) => Promise.resolve({ continue: event !== 'message:received', data }), ); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage?.(makeMessage({ id: 'wa-swallowed-in' })); await flush(); expect(messageRepository.insert).toHaveBeenCalled(); expect(dispatchedEvents('message.received')).toHaveLength(1); expect(eventsGateway.emitMessage).toHaveBeenCalled(); }); it('still persists and dispatches an outgoing message when a plugin stops the hook chain', async () => { (hookManager.execute as jest.Mock).mockImplementation((event: string, data: unknown) => Promise.resolve({ continue: event !== 'message:sent', data }), ); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageCreate!(makeMessage({ id: 'wa-swallowed-out', from: 'me@c.us', fromMe: true })); await flush(); expect(messageRepository.insert).toHaveBeenCalled(); expect(dispatchedEvents('message.sent')).toHaveLength(1); }); // The transform half of the contract must survive: a plugin that rewrites the payload and stops the // chain still has its edit persisted, rather than the original being written back. it('persists the plugin-modified payload even when that plugin stops the chain', async () => { (hookManager.execute as jest.Mock).mockImplementation((event: string, data: unknown) => event === 'message:received' ? Promise.resolve({ continue: false, data: { ...(data as object), body: 'rewritten by plugin' } }) : Promise.resolve({ continue: true, data }), ); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage?.(makeMessage({ id: 'wa-rewritten', body: 'original' })); await flush(); expect(messageRepository.create).toHaveBeenCalledWith(expect.objectContaining({ body: 'rewritten by plugin' })); }); it('dispatches message.sent exactly once for an outgoing (message_create) event', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onMessageCreate).toBe('function'); callbacks.onMessageCreate!(makeMessage({ id: 'wa-out-1', from: 'me@c.us', to: 'peer@c.us', fromMe: true })); await flush(); const sent = dispatchedEvents('message.sent'); expect(sent).toHaveLength(1); expect(sent[0][0]).toBe('sess-uuid-1'); }); it('persists an outgoing (message_create) self-message so phone-composed sends reach local history', async () => { // message_create is the ONLY event a phone-composed send produces; persist it best-effort. The // UNIQUE(sessionId, waMessageId) index dedups against the REST send path, which persists // API-originated sends itself (see the unique-race test below). const callbacks = await startAndCaptureCallbacks(); // Pass the message through the hook chain untouched (the default mock replaces data with {}). (hookManager.execute as jest.Mock).mockImplementation((_e: string, data: unknown) => Promise.resolve({ continue: true, data }), ); (messageRepository.insert as jest.Mock).mockClear(); callbacks.onMessageCreate!(makeMessage({ id: 'wa-out-2', from: 'me@c.us', to: 'peer@c.us', fromMe: true })); await flush(); expect(dispatchedEvents('message.sent')).toHaveLength(1); // webhook/WS contract unchanged expect(messageRepository.insert).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const row = (messageRepository.create as jest.Mock).mock.calls[0][0] as Partial; expect(row).toMatchObject({ sessionId: 'sess-uuid-1', waMessageId: 'wa-out-2', direction: MessageDirection.OUTGOING, status: MessageStatus.SENT, }); // A winning insert also feeds plugin providers (search etc.) like any other persisted message. const persistedCalls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ); expect(persistedCalls).toHaveLength(1); }); it('still dispatches (but does not double-persist) when the REST send path won the dedup race', async () => { // API-originated sends fire message_create too; the REST path persists them. A UNIQUE violation // here is the dedup oracle working — not an error: skip the insert + message:persisted quietly, // but the webhook/WS dispatch MUST still happen (today's contract). const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockRejectedValueOnce( new Error('UNIQUE constraint failed: messages.sessionId, messages.waMessageId'), ); callbacks.onMessageCreate!(makeMessage({ id: 'wa-out-dup', from: 'me@c.us', to: 'peer@c.us', fromMe: true })); await flush(); expect(dispatchedEvents('message.sent')).toHaveLength(1); const persistedCalls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ); expect(persistedCalls).toHaveLength(0); }); it('fails open on a transient insert error: message.sent still dispatches', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')); callbacks.onMessageCreate!(makeMessage({ id: 'wa-out-busy', from: 'me@c.us', to: 'peer@c.us', fromMe: true })); await flush(); expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'message.sent', expect.anything()); }); it('gates persist (but NOT dispatch) on STORE_EPHEMERAL_MESSAGES=false for ephemeral echoes', async () => { // Unlike onMessage (which skips persist AND dispatch for ephemeral), the own-send path's dispatch // is today's contract and stays; only storage honors the opt-out. process.env.STORE_EPHEMERAL_MESSAGES = 'false'; const callbacks = await startAndCaptureCallbacks(); (hookManager.execute as jest.Mock).mockImplementation((_e: string, data: unknown) => Promise.resolve({ continue: true, data }), ); (messageRepository.insert as jest.Mock).mockClear(); callbacks.onMessageCreate!( makeMessage({ id: 'wa-out-eph', from: 'me@c.us', to: 'peer@c.us', fromMe: true, ephemeralDuration: 86400 }), ); await flush(); expect(messageRepository.insert).not.toHaveBeenCalled(); expect(dispatchedEvents('message.sent')).toHaveLength(1); delete process.env.STORE_EPHEMERAL_MESSAGES; }); it('synthesizes the omitted media marker for a media echo carrying no media (wwjs shape)', async () => { // wwjs' buildIncomingMessageBase never attaches media to the own-send echo; without the marker // the dashboard renders an empty bubble and the by-type stats filter would skip the row. const callbacks = await startAndCaptureCallbacks(); (hookManager.execute as jest.Mock).mockImplementation((_e: string, data: unknown) => Promise.resolve({ continue: true, data }), ); (messageRepository.create as jest.Mock).mockClear(); callbacks.onMessageCreate!( makeMessage({ id: 'wa-out-img', from: 'me@c.us', to: 'peer@c.us', fromMe: true, type: 'image', body: '' }), ); await flush(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const row = (messageRepository.create as jest.Mock).mock.calls[0][0] as Partial; expect(row.metadata).toEqual({ media: { mimetype: '', omitted: true } }); }); it('passes a Baileys-style omitted marker through unchanged', async () => { const callbacks = await startAndCaptureCallbacks(); (hookManager.execute as jest.Mock).mockImplementation((_e: string, data: unknown) => Promise.resolve({ continue: true, data }), ); (messageRepository.create as jest.Mock).mockClear(); const marker = { mimetype: 'image/png', omitted: true, sizeBytes: 1234 }; callbacks.onMessageCreate!( makeMessage({ id: 'wa-out-img2', from: 'me@c.us', to: 'peer@c.us', fromMe: true, type: 'image', body: '', media: marker, }), ); await flush(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const row = (messageRepository.create as jest.Mock).mock.calls[0][0] as Partial; expect(row.metadata).toEqual({ media: marker }); }); it('scopes the ack status UPDATE by sessionId, not just waMessageId', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onMessageAck).toBe('function'); callbacks.onMessageAck!('wa-msg-1', 'delivered'); await flush(); expect(messageRepository.update).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'sess-uuid-1', waMessageId: 'wa-msg-1' }), expect.objectContaining({ status: MessageStatus.DELIVERED }), ); }); it('does not dispatch message.sent for an incoming message_create event (fromMe=false)', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageCreate!(makeMessage({ fromMe: false })); await flush(); expect(dispatchedEvents('message.sent')).toHaveLength(0); }); it('does not dispatch message.sent for a status/story broadcast (isStatusBroadcast flag)', async () => { const callbacks = await startAndCaptureCallbacks(); // The adapter flags status broadcasts; session.service branches on the neutral flag, not the // engine-specific `status@broadcast` pseudo-JID. callbacks.onMessageCreate!( makeMessage({ id: 'wa-status', from: 'me@c.us', to: 'status@broadcast', fromMe: true, isStatusBroadcast: true, }), ); await flush(); expect(dispatchedEvents('message.sent')).toHaveLength(0); }); it('emits the realtime WS event for an outgoing message as message.sent, not message.received', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageCreate!(makeMessage({ id: 'wa-out-2', from: 'me@c.us', to: 'peer@c.us', fromMe: true })); await flush(); expect(eventsGateway.emitMessageSent as jest.Mock).toHaveBeenCalledWith('sess-uuid-1', expect.anything()); expect(eventsGateway.emitMessage as jest.Mock).not.toHaveBeenCalled(); }); it('dispatches message.ack but never message.sent on a message_ack event', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onMessageAck).toBe('function'); callbacks.onMessageAck!('wa-out-1', 'read'); await flush(); expect(dispatchedEvents('message.ack')).toHaveLength(1); expect(dispatchedEvents('message.sent')).toHaveLength(0); }); it('emits an identical message.ack payload over the socket and the webhook (parity)', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'read'); await flush(); const ackCalls = (eventsGateway.emitMessageAck as jest.Mock).mock.calls as unknown[][]; const socketPayload = ackCalls[0][1] as Record; const webhookPayload = dispatchedEvents('message.ack')[0][2] as Record; // A socket client coded against the webhook/doc ack shape must see the same fields. expect(socketPayload).toEqual(webhookPayload); expect(socketPayload).toMatchObject({ id: 'wa-out-1', messageId: 'wa-out-1', status: 'read' }); expect(socketPayload.ack).toBeDefined(); }); it("reflects delivery on the stored message: 'delivered' updates status to DELIVERED (#220)", async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'delivered'); await flush(); expect(messageRepository.update as jest.Mock).toHaveBeenCalledWith( expect.objectContaining({ waMessageId: 'wa-out-1' }), { status: MessageStatus.DELIVERED }, ); }); it("marks the stored message FAILED and dispatches message.failed on a 'failed' status (#220)", async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'failed'); await flush(); expect(messageRepository.update as jest.Mock).toHaveBeenCalledWith( expect.objectContaining({ waMessageId: 'wa-out-1' }), { status: MessageStatus.FAILED }, ); expect(dispatchedEvents('message.failed')).toHaveLength(1); }); it('emits the message:ack hook for every ack so plugins (e.g. a delivery logger) can react', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'delivered'); await flush(); expect(hookManager.execute).toHaveBeenCalledWith( 'message:ack', expect.objectContaining({ messageId: 'wa-out-1', status: 'delivered' }), expect.objectContaining({ source: 'Engine' }), ); }); it("surfaces delivery failures via message:ack with status 'failed' (not the send-time message:failed hook)", async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'failed'); await flush(); expect(hookManager.execute).toHaveBeenCalledWith( 'message:ack', expect.objectContaining({ messageId: 'wa-out-1', status: 'failed' }), expect.objectContaining({ source: 'Engine' }), ); // message:failed stays reserved for send-time failures (a distinct {error,input} payload). expect(hookManager.execute).not.toHaveBeenCalledWith('message:failed', expect.anything(), expect.anything()); }); it("does not upgrade the stored status (or emit message.failed) for a 'sent' status", async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageAck!('wa-out-1', 'sent'); await flush(); expect(messageRepository.update as jest.Mock).not.toHaveBeenCalled(); expect(dispatchedEvents('message.failed')).toHaveLength(0); }); it('retries the ack update once after a delay when the row is not yet matchable (ack before commit)', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.update as jest.Mock) .mockClear() .mockResolvedValueOnce({ affected: 0 }) // send's 2nd save (waMessageId) not committed yet .mockResolvedValueOnce({ affected: 1 }); // retry now matches the row jest.useFakeTimers(); try { callbacks.onMessageAck!('wa-out-1', 'delivered'); await jest.advanceTimersByTimeAsync(0); // flush the first update's microtasks expect(messageRepository.update as jest.Mock).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(ACK_RECONCILE_DELAY_MS); expect(messageRepository.update as jest.Mock).toHaveBeenCalledTimes(2); } finally { jest.useRealTimers(); } }); it('does not schedule a retry when the first ack update advances a row', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.update as jest.Mock).mockClear().mockResolvedValue({ affected: 1 }); jest.useFakeTimers(); try { callbacks.onMessageAck!('wa-out-1', 'delivered'); await jest.advanceTimersByTimeAsync(ACK_RECONCILE_DELAY_MS); expect(messageRepository.update as jest.Mock).toHaveBeenCalledTimes(1); } finally { jest.useRealTimers(); } }); it('handles a rejected ack update without an unhandled rejection', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.update as jest.Mock).mockClear().mockRejectedValue(new Error('data DB down')); // Must not throw synchronously; the .catch keeps the rejection from escaping to the global backstop // (a missing .catch here would surface as an unhandled rejection and fail the suite). callbacks.onMessageAck!('wa-out-1', 'delivered'); await flush(); await flush(); expect(messageRepository.update as jest.Mock).toHaveBeenCalled(); }); it('serializes concurrent reactions on the same message so neither sender is clobbered', async () => { const callbacks = await startAndCaptureCallbacks(); // Simulate a real DB: each findOne returns a FRESH snapshot of the persisted row, and the scoped // update writes the new metadata back. Without per-message serialization the two handlers read the // same empty snapshot and the second write clobbers the first sender's reaction. type Row = { metadata?: Record }; const clone = (r: Row): Row => JSON.parse(JSON.stringify(r)) as Row; let stored: Row = { metadata: {} }; (messageRepository.findOne as jest.Mock).mockImplementation(() => Promise.resolve(clone(stored))); (messageRepository.update as jest.Mock).mockImplementation((_c: unknown, patch: Row) => { stored = clone({ ...stored, ...patch }); return Promise.resolve({ affected: 1 }); }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍' }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'bob', reaction: '🎉' }); for (let i = 0; i < 5; i++) await flush(); expect(stored.metadata?.reactions).toEqual({ alice: '👍', bob: '🎉' }); }); it('drops a reaction with no message id instead of letting it match an arbitrary row', async () => { const callbacks = await startAndCaptureCallbacks(); // An engine that can't resolve the reacted message's id passes `''` (the no-id sentinel). It must // never reach findOne: TypeORM drops an empty/undefined condition from the where-clause, so the // lookup would match some other message and emit ITS reactions under this event. (messageRepository.findOne as jest.Mock).mockClear(); (messageRepository.update as jest.Mock).mockClear(); callbacks.onMessageReaction!({ messageId: '', chatId: 'c', senderId: 'alice', reaction: '👍' }); for (let i = 0; i < 3; i++) await flush(); expect(messageRepository.findOne).not.toHaveBeenCalled(); expect(messageRepository.update).not.toHaveBeenCalled(); }); it('persists a reaction via a scoped metadata update, never a full-row save (protects ack status)', async () => { const callbacks = await startAndCaptureCallbacks(); // The row was already advanced to DELIVERED by a concurrent ack. A full-row save(msg) would // re-persist the stale status read at findOne time and clobber it; the write must be scoped to // the metadata column only, keyed by (sessionId, waMessageId). (messageRepository.findOne as jest.Mock).mockResolvedValue({ status: 'delivered', metadata: {} }); (messageRepository.save as jest.Mock).mockClear(); (messageRepository.update as jest.Mock).mockClear().mockResolvedValue({ affected: 1 }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍' }); for (let i = 0; i < 3; i++) await flush(); expect(messageRepository.save).not.toHaveBeenCalled(); expect(messageRepository.update).toHaveBeenCalledWith( { sessionId: 'sess-uuid-1', waMessageId: 'wa-1' }, { metadata: { reactions: { alice: '👍' } } }, ); }); it('removes a sender reaction on a cleared reaction event (delete branch)', async () => { const callbacks = await startAndCaptureCallbacks(); type Row = { metadata?: Record }; const clone = (r: Row): Row => JSON.parse(JSON.stringify(r)) as Row; let stored: Row = { metadata: { reactions: { alice: '👍', bob: '🎉' } } }; (messageRepository.findOne as jest.Mock).mockImplementation(() => Promise.resolve(clone(stored))); (messageRepository.update as jest.Mock).mockImplementation((_c: unknown, patch: Row) => { stored = clone({ ...stored, ...patch }); return Promise.resolve({ affected: 1 }); }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '' }); for (let i = 0; i < 3; i++) await flush(); expect(stored.metadata?.reactions).toEqual({ bob: '🎉' }); // alice removed, bob preserved }); it('a failed reaction write does not block a later reaction on the same message', async () => { const callbacks = await startAndCaptureCallbacks(); type Row = { metadata?: Record }; const clone = (r: Row): Row => JSON.parse(JSON.stringify(r)) as Row; let stored: Row = { metadata: {} }; (messageRepository.findOne as jest.Mock).mockImplementation(() => Promise.resolve(clone(stored))); (messageRepository.update as jest.Mock) .mockRejectedValueOnce(new Error('write blip')) // alice's write fails .mockImplementation((_c: unknown, patch: Row) => { stored = clone({ ...stored, ...patch }); return Promise.resolve({ affected: 1 }); }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍' }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'bob', reaction: '🎉' }); for (let i = 0; i < 5; i++) await flush(); expect(stored.metadata?.reactions).toEqual({ bob: '🎉' }); // bob applied despite alice's failure }); it('cleans up the per-message serialization entry after the chain drains (no leak)', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.findOne as jest.Mock).mockResolvedValue({ metadata: {} }); (messageRepository.update as jest.Mock).mockResolvedValue({ affected: 1 }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍' }); for (let i = 0; i < 3; i++) await flush(); const chains = (service as unknown as { messageMutationChains: Map }).messageMutationChains; expect(chains.size).toBe(0); }); it('dispatches message.reaction to the webhook with the post-apply reactions snapshot', async () => { const callbacks = await startAndCaptureCallbacks(); type Row = { metadata?: Record }; const clone = (r: Row): Row => JSON.parse(JSON.stringify(r)) as Row; let stored: Row = { metadata: {} }; (messageRepository.findOne as jest.Mock).mockImplementation(() => Promise.resolve(clone(stored))); (messageRepository.update as jest.Mock).mockImplementation((_c: unknown, patch: Row) => { stored = clone({ ...stored, ...patch }); return Promise.resolve({ affected: 1 }); }); callbacks.onMessageReaction!({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍' }); for (let i = 0; i < 3; i++) await flush(); const dispatched = dispatchedEvents('message.reaction'); expect(dispatched).toHaveLength(1); // Webhook payload mirrors the WS payload: the event plus the post-apply reactions snapshot. expect(dispatched[0][2]).toMatchObject({ messageId: 'wa-1', chatId: 'c', senderId: 'alice', reaction: '👍', reactions: { alice: '👍' }, }); }); it('dispatches message.received (not message.sent) on an incoming message event', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ fromMe: false })); await flush(); expect(dispatchedEvents('message.received')).toHaveLength(1); expect(dispatchedEvents('message.sent')).toHaveLength(0); }); it('does not dispatch message.received for a status/story broadcast via onMessage (isStatusBroadcast)', async () => { const callbacks = await startAndCaptureCallbacks(); // Engine delivers a status@broadcast inbound — engine-neutral guard must drop it. callbacks.onMessage!( makeMessage({ from: 'status@broadcast', to: 'me@c.us', chatId: 'status@broadcast', fromMe: false, isStatusBroadcast: true, }), ); await flush(); expect(dispatchedEvents('message.received')).toHaveLength(0); }); it('skips persist and dispatch for ephemeral messages when STORE_EPHEMERAL_MESSAGES=false', async () => { process.env.STORE_EPHEMERAL_MESSAGES = 'false'; const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockClear(); callbacks.onMessage!(makeMessage({ id: 'wa-eph-1', ephemeralDuration: 86400 })); await flush(); expect(messageRepository.insert).not.toHaveBeenCalled(); expect(dispatchedEvents('message.received')).toHaveLength(0); delete process.env.STORE_EPHEMERAL_MESSAGES; }); it('still persists ephemeral messages when STORE_EPHEMERAL_MESSAGES is unset (default)', async () => { delete process.env.STORE_EPHEMERAL_MESSAGES; const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockClear(); callbacks.onMessage!(makeMessage({ id: 'wa-eph-2', ephemeralDuration: 86400 })); await flush(); expect(messageRepository.insert).toHaveBeenCalled(); expect(dispatchedEvents('message.received')).toHaveLength(1); }); it('emits message:persisted with a non-empty message.id on inbound (insert generated PK merged)', async () => { // Asymmetry guard: the inbound path uses `insert()` (the dedup oracle), which — unlike `save()` — // does NOT merge @PrimaryGeneratedColumn/@CreateDateColumn back onto the entity. Without the // identifiers/generatedMaps merge, `dbMessage.id` is undefined here, while the outbound path // (MessageService.saveOutgoingMessage) emits a real id via `save()`. A plugin subscribing to // `message:persisted` would see id=undefined on inbound but a real id on outbound. This pins the // inbound payload to carry the DB-generated id, mirroring the outbound emit test. const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ id: 'wa-in-1', fromMe: false })); await flush(); const persistedCalls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ) as unknown[][]; expect(persistedCalls).toHaveLength(1); const payload = persistedCalls[0][1] as { sessionId: string; message: { id?: string } }; expect(payload.sessionId).toBe('sess-uuid-1'); expect(payload.message.id).toBeTruthy(); // the DB-generated id, not undefined expect(payload.message.id).toBe('gen-uuid-1'); // merged from InsertResult.identifiers[0] expect(persistedCalls[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', source: 'SessionService' }); }); it('does not emit message:persisted on a duplicate re-fire (loses the dedup insert race)', async () => { // The emit lives AFTER the dedup gate. A re-fire that hits the UNIQUE(sessionId, waMessageId) // constraint must not emit message:persisted — no row was durably stored on this attempt. const callbacks = await startAndCaptureCallbacks(); // Emulate the SQLite UNIQUE-violation phrasing that `isUniqueConstraintError` matches via regex. (messageRepository.insert as jest.Mock).mockRejectedValueOnce( new Error('UNIQUE constraint failed: messages.sessionId, messages.waMessageId'), ); callbacks.onMessage!(makeMessage({ id: 'wa-dup-1', fromMe: false })); await flush(); const persistedCalls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ); expect(persistedCalls).toHaveLength(0); }); it('does not emit message:persisted when insert throws a transient (non-unique) error', async () => { // Fail-open on transient DB errors (SQLITE_BUSY, lock-timeout, connection drop) is correct for // webhook/WS dispatch — a real inbound message must never be dropped. But the row was never // stored and dbMessage.id is undefined, so the message:persisted hook must NOT fire (it would // hand plugins an id-less payload for a row that isn't in the DB). The hook is gated on a // `persisted` flag set only after the generated-maps merge succeeds. Webhook/WS still dispatch. const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')); callbacks.onMessage!(makeMessage({ id: 'wa-busy-1', fromMe: false })); await flush(); const persistedCalls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ); expect(persistedCalls).toHaveLength(0); // Fail-open: webhook still dispatched so the inbound message is not silently dropped. (The // payload is `{}` here because the hook mock returns `data: {}`; the point is that dispatch // fired at all on a transient DB error — only the message:persisted hook is gated on `persisted`.) expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'message.received', expect.anything()); }); it('does not persist (no orphan row) when the session is deleted mid hook chain', async () => { // onMessage gates on isLiveEngine synchronously at entry, then awaits the message:received hook // chain before inserting. If delete() completes during that await (the engine leaves the live // map), a late continuation must NOT insert: the messages row has no FK, so an orphan persisted // here is exactly what the session-delete cleanup is meant to prevent. const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockClear(); (webhookService.dispatch as jest.Mock).mockClear(); const engines = (service as unknown as { engines: Map }).engines; // Tear the session out of the live map while message:received is still awaiting. (hookManager.execute as jest.Mock).mockImplementationOnce((_event: string, data: unknown) => { engines.delete('sess-uuid-1'); return Promise.resolve({ continue: true, data }); }); callbacks.onMessage!(makeMessage({ id: 'wa-orphan-1', fromMe: false })); await flush(); expect(messageRepository.insert).not.toHaveBeenCalled(); expect(dispatchedEvents('message.received')).toHaveLength(0); }); it('does not process an own-send status echo (type=append) — no dispatch, no WS emit, no DB write', async () => { // Regression guard for the WhatsApp Status feature: posting a status produces an own-send echo // that Baileys delivers as `messages.upsert` with `type: 'append'` (NOT 'notify'). The adapter's // handleMessagesUpsert filters `type !== 'notify'` before processInboundMessage, so the echo never // reaches the engine callbacks. This test pins the engine-neutral last-chance guard — // `isStatusBroadcast` on both onMessageCreate and onMessage — so a future change can't silently // leak a status echo to websockets, webhooks, or the message table. Asserts the full no-side-effect // contract (webhook dispatch + WS emit + DB insert) for completeness, even though the existing // isStatusBroadcast tests above already cover the dispatch-only slice. const callbacks = await startAndCaptureCallbacks(); (webhookService.dispatch as jest.Mock).mockClear(); (eventsGateway.emitMessage as jest.Mock).mockClear(); (eventsGateway.emitMessageSent as jest.Mock).mockClear(); (messageRepository.insert as jest.Mock).mockClear(); const statusEcho = makeMessage({ id: 'wa-status-echo', from: 'me@c.us', to: 'status@broadcast', chatId: 'status@broadcast', fromMe: true, isStatusBroadcast: true, }); // An own-send echo could in principle surface via either callback path; assert neither dispatches. callbacks.onMessageCreate!(statusEcho); callbacks.onMessage!(statusEcho); await flush(); expect(webhookService.dispatch).not.toHaveBeenCalled(); expect(eventsGateway.emitMessage).not.toHaveBeenCalled(); expect(eventsGateway.emitMessageSent).not.toHaveBeenCalled(); expect(messageRepository.insert).not.toHaveBeenCalled(); }); // The default hookManager mock returns an empty `data: {}`; echo the message through so the // engine-set fields (isLidSender) survive the hook and reach the inline-resolution branch. const echoHook = () => (hookManager.execute as jest.Mock).mockImplementation((_event: string, data: unknown) => Promise.resolve({ continue: true, data }), ); it('attaches senderPhone inline for an @lid sender when RESOLVE_LID_TO_PHONE is on (#263)', async () => { process.env.RESOLVE_LID_TO_PHONE = 'true'; try { echoHook(); mockEngine.resolveContactPhone.mockResolvedValue('628111222333'); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ from: '111@lid', chatId: '111@lid', isLidSender: true })); await flush(); const received = dispatchedEvents('message.received'); expect(received).toHaveLength(1); expect((received[0][2] as IncomingMessage).senderPhone).toBe('628111222333'); expect(mockEngine.resolveContactPhone).toHaveBeenCalledWith('111@lid'); // #583 R3 Phase 2: the resolved inbound @lid -> phone is persisted so the read-path can bridge // this contact's @lid and @c.us rows even if the operator never sent to them. expect(lidMappingStore.remember).toHaveBeenCalledWith('111', '628111222333', expect.any(String)); } finally { delete process.env.RESOLVE_LID_TO_PHONE; } }); it('resolves senderPhone from a canonicalized @c.us author for a resolved-lid sender (#263)', async () => { // After JID canonicalization a resolved lid reaches the service as @c.us while isLidSender // stays true. Wire resolveContactPhone to the real store so the @c.us branch is genuinely exercised: // if resolvePhone regressed to null for @c.us, senderPhone would be null here. process.env.RESOLVE_LID_TO_PHONE = 'true'; try { echoHook(); const store = new BaileysSessionStore(); store.addLidMappings([{ lid: '111@lid', pn: '628111222333@s.whatsapp.net' }]); mockEngine.resolveContactPhone.mockImplementation((id: string) => Promise.resolve(store.resolvePhone(id))); const callbacks = await startAndCaptureCallbacks(); // Group lid author resolved to @c.us by the engine boundary. callbacks.onMessage!( makeMessage({ from: 'g@g.us', chatId: 'g@g.us', author: '628111222333@c.us', isLidSender: true }), ); await flush(); const received = dispatchedEvents('message.received'); expect(received).toHaveLength(1); expect((received[0][2] as IncomingMessage).senderPhone).toBe('628111222333'); expect(mockEngine.resolveContactPhone).toHaveBeenCalledWith('628111222333@c.us'); } finally { delete process.env.RESOLVE_LID_TO_PHONE; } }); it('does not resolve senderPhone when RESOLVE_LID_TO_PHONE is unset (default off)', async () => { delete process.env.RESOLVE_LID_TO_PHONE; echoHook(); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ from: '111@lid', chatId: '111@lid', isLidSender: true })); await flush(); const received = dispatchedEvents('message.received'); expect(received).toHaveLength(1); expect((received[0][2] as IncomingMessage).senderPhone).toBeUndefined(); expect(mockEngine.resolveContactPhone).not.toHaveBeenCalled(); }); it('does not resolve for a normal (non-lid) sender even when the flag is on', async () => { process.env.RESOLVE_LID_TO_PHONE = 'true'; try { echoHook(); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ from: 'peer@c.us', chatId: 'peer@c.us' })); // no isLidSender await flush(); expect(mockEngine.resolveContactPhone).not.toHaveBeenCalled(); } finally { delete process.env.RESOLVE_LID_TO_PHONE; } }); it('caches @lid resolution so the same sender is queried only once (#263)', async () => { process.env.RESOLVE_LID_TO_PHONE = 'true'; try { echoHook(); mockEngine.resolveContactPhone.mockResolvedValue('628111222333'); const callbacks = await startAndCaptureCallbacks(); callbacks.onMessage!(makeMessage({ id: 'm1', from: '111@lid', chatId: '111@lid', isLidSender: true })); await flush(); callbacks.onMessage!(makeMessage({ id: 'm2', from: '111@lid', chatId: '111@lid', isLidSender: true })); await flush(); expect(mockEngine.resolveContactPhone).toHaveBeenCalledTimes(1); } finally { delete process.env.RESOLVE_LID_TO_PHONE; } }); it('dispatches the message.revoked webhook and WS event on a revoke (#152)', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onMessageRevoked).toBe('function'); callbacks.onMessageRevoked!({ id: 'wa-rev-1', chatId: 'peer@c.us', from: 'peer@c.us', to: 'me@c.us', type: 'revoked', body: '', timestamp: 1706868000, }); await flush(); expect(dispatchedEvents('message.revoked')).toHaveLength(1); expect(eventsGateway.emitMessageRevoked as jest.Mock).toHaveBeenCalledWith('sess-uuid-1', expect.anything()); }); it('flags the DB row by revokedId (the original), not the revocation notification id', async () => { const callbacks = await startAndCaptureCallbacks(); // wwebjs shape: `id` is the revocation notification, `revokedId` the original message. callbacks.onMessageRevoked!({ id: 'REVOKE_NOTIF', revokedId: 'ORIGINAL_MSG', chatId: 'peer@c.us', from: 'peer@c.us', to: 'me@c.us', type: 'revoked', body: '', timestamp: 1706868000, }); await flush(); expect(messageRepository.update as jest.Mock).toHaveBeenCalledWith( { sessionId: 'sess-uuid-1', waMessageId: 'ORIGINAL_MSG' }, { body: '', type: 'revoked' }, ); // The DB flag is an internal side effect; the delivered payload is the public contract // this fix exists for. Webhook and WS consumers must receive `revokedId` (the original), // not just the revocation-notification `id`, so they can reconcile the deleted message. expect(dispatchedEvents('message.revoked')[0][2]).toEqual( expect.objectContaining({ id: 'REVOKE_NOTIF', revokedId: 'ORIGINAL_MSG' }), ); expect(eventsGateway.emitMessageRevoked as jest.Mock).toHaveBeenCalledWith( 'sess-uuid-1', expect.objectContaining({ id: 'REVOKE_NOTIF', revokedId: 'ORIGINAL_MSG' }), ); }); it('falls back to `id` for the DB flag when revokedId is absent (Baileys shape)', async () => { const callbacks = await startAndCaptureCallbacks(); callbacks.onMessageRevoked!({ id: 'ORIGINAL_MSG', chatId: 'peer@c.us', from: 'peer@c.us', to: 'me@c.us', type: 'revoked', body: '', timestamp: 1706868000, }); await flush(); expect(messageRepository.update as jest.Mock).toHaveBeenCalledWith( { sessionId: 'sess-uuid-1', waMessageId: 'ORIGINAL_MSG' }, { body: '', type: 'revoked' }, ); }); // ── session lifecycle events ────────────────────────────────────── it('dispatches session.qr with the QR payload when the engine emits a QR code', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onQRCode).toBe('function'); callbacks.onQRCode!('qr-data-abc'); await flush(); const qr = dispatchedEvents('session.qr'); expect(qr).toHaveLength(1); expect(qr[0][0]).toBe('sess-uuid-1'); expect(qr[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', qr: 'qr-data-abc' }); }); it('dispatches session.authenticated with phone/pushName when the engine reports ready', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onReady).toBe('function'); callbacks.onReady!('628123', 'Alice'); await flush(); const auth = dispatchedEvents('session.authenticated'); expect(auth).toHaveLength(1); expect(auth[0][0]).toBe('sess-uuid-1'); expect(auth[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', phone: '628123', pushName: 'Alice' }); }); it('dispatches session.disconnected with the reason when the engine disconnects', async () => { const callbacks = await startAndCaptureCallbacks(); expect(typeof callbacks.onDisconnected).toBe('function'); // Isolate the dispatch from the reconnect scheduler, which would otherwise leave a live timer. jest .spyOn(service as unknown as { scheduleReconnect: (id: string, s: unknown) => void }, 'scheduleReconnect') .mockImplementation(() => undefined); callbacks.onDisconnected!('logged out'); await flush(); const disc = dispatchedEvents('session.disconnected'); expect(disc).toHaveLength(1); expect(disc[0][0]).toBe('sess-uuid-1'); expect(disc[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', reason: 'logged out' }); }); it('dispatches session.status on a session status transition', async () => { await startAndCaptureCallbacks(); await flush(); // start() transitions the session to INITIALIZING via updateStatus(). const status = dispatchedEvents('session.status'); expect(status.length).toBeGreaterThanOrEqual(1); expect(status[0][0]).toBe('sess-uuid-1'); expect(status[0][2]).toMatchObject({ sessionId: 'sess-uuid-1', status: SessionStatus.INITIALIZING }); }); it('does not double-dispatch session.status when onStateChanged and a dedicated callback report the same status', async () => { const callbacks = await startAndCaptureCallbacks(); // wwebjs signals a QR transition via BOTH onStateChanged(QR_READY) and onQRCode → updateStatus(QR_READY) twice. callbacks.onStateChanged!(EngineStatus.QR_READY); callbacks.onQRCode!('qr-data-abc'); await flush(); const qrStatus = dispatchedEvents('session.status').filter( c => (c[2] as { status?: string }).status === SessionStatus.QR_READY, ); expect(qrStatus).toHaveLength(1); }); it('does not double-EMIT session.status over WS when the same status is reported twice', async () => { const callbacks = await startAndCaptureCallbacks(); (eventsGateway.emitSessionStatus as jest.Mock).mockClear(); callbacks.onStateChanged!(EngineStatus.QR_READY); callbacks.onQRCode!('qr-data-abc'); // same QR_READY transition, second signal await flush(); const qrEmits = ((eventsGateway.emitSessionStatus as jest.Mock).mock.calls as unknown[][]).filter( c => c[1] === SessionStatus.QR_READY, ); expect(qrEmits).toHaveLength(1); }); it('persists and dispatches message.received only once when the engine re-fires the same message', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockReset(); (webhookService.dispatch as jest.Mock).mockClear(); (messageRepository.insert as jest.Mock) .mockResolvedValueOnce(undefined) // first delivery: new row .mockRejectedValueOnce({ driverError: { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, }); // re-fire const msg: IncomingMessage = { id: 'wa-1', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'hi', type: 'text', timestamp: 1, fromMe: false, isGroup: false, }; callbacks.onMessage?.(msg); await flush(); callbacks.onMessage?.(msg); // re-fired engine event await flush(); expect(messageRepository.insert).toHaveBeenCalledTimes(2); expect( ((webhookService.dispatch as jest.Mock).mock.calls as unknown[][]).filter(c => c[1] === 'message.received'), ).toHaveLength(1); }); it('still dispatches message.received when the insert fails with a non-constraint error (fail-open)', async () => { const callbacks = await startAndCaptureCallbacks(); (messageRepository.insert as jest.Mock).mockReset(); (webhookService.dispatch as jest.Mock).mockClear(); (messageRepository.insert as jest.Mock).mockRejectedValueOnce(new Error('db down')); callbacks.onMessage?.({ id: 'wa-2', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'hi', type: 'text', timestamp: 1, fromMe: false, isGroup: false, }); await flush(); expect( ((webhookService.dispatch as jest.Mock).mock.calls as unknown[][]).filter(c => c[1] === 'message.received'), ).toHaveLength(1); }); // ── persistHistoryMessages collision tolerance ─────────────────── describe('persistHistoryMessages collision tolerance', () => { it('uses an insert-or-ignore bulk insert so a colliding history row cannot abort the batch', async () => { const callbacks = await startAndCaptureCallbacks(); const execute = jest.fn().mockResolvedValue({ identifiers: [] }); const qb = { insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), orIgnore: jest.fn().mockReturnThis(), execute, }; (messageRepository.createQueryBuilder as jest.Mock) = jest.fn().mockReturnValue(qb); (messageRepository.find as jest.Mock).mockResolvedValue([]); // nothing pre-seen (messageRepository.create as jest.Mock).mockImplementation((data: Record) => ({ ...data })); (messageRepository.save as jest.Mock).mockClear(); callbacks.onHistoryMessages?.([ { id: 'h1', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'old', type: 'text', timestamp: 1, fromMe: false, isGroup: false, }, ]); await flush(); expect(qb.orIgnore).toHaveBeenCalled(); expect(execute).toHaveBeenCalled(); expect(messageRepository.save).not.toHaveBeenCalled(); // no longer the throwing path }); it('synthesizes the omitted media marker for media-free history rows (no empty bubbles)', async () => { // History sync maps messages media-free (footprint). A media row persisted WITHOUT the marker // renders as an empty bubble in the dashboard (the DB copy wins the merge over the engine // placeholder) and is skipped by the by-type stats filter. const callbacks = await startAndCaptureCallbacks(); const execute = jest.fn().mockResolvedValue({ identifiers: [] }); const qb = { insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), orIgnore: jest.fn().mockReturnThis(), execute, }; (messageRepository.createQueryBuilder as jest.Mock) = jest.fn().mockReturnValue(qb); (messageRepository.find as jest.Mock).mockResolvedValue([]); // nothing pre-seen (messageRepository.create as jest.Mock).mockImplementation((data: Record) => ({ ...data })); callbacks.onHistoryMessages?.([ { id: 'h-img', from: 'me@c.us', to: 'peer@c.us', chatId: 'peer@c.us', body: '', type: 'image', timestamp: 1, fromMe: true, isGroup: false, }, ]); await flush(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const rows = qb.values.mock.calls[0][0] as Array>; expect(rows[0].metadata).toEqual({ media: { mimetype: '', omitted: true } }); }); }); // ── persistHistoryMessages STORE_EPHEMERAL_MESSAGES guard ──────── describe('persistHistoryMessages ephemeral guard', () => { const setupBulkQb = () => { const execute = jest.fn().mockResolvedValue({ identifiers: [] }); const qb = { insert: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), orIgnore: jest.fn().mockReturnThis(), execute, }; (messageRepository.createQueryBuilder as jest.Mock) = jest.fn().mockReturnValue(qb); (messageRepository.find as jest.Mock).mockResolvedValue([]); (messageRepository.create as jest.Mock).mockImplementation((data: Record) => ({ ...data })); return { qb, execute }; }; it('skips a disappearing history message when STORE_EPHEMERAL_MESSAGES=false', async () => { process.env.STORE_EPHEMERAL_MESSAGES = 'false'; const callbacks = await startAndCaptureCallbacks(); const { qb, execute } = setupBulkQb(); callbacks.onHistoryMessages?.([ { id: 'h-eph', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'vanishing', type: 'text', timestamp: 1, fromMe: false, isGroup: false, ephemeralDuration: 86400, }, ]); await flush(); // The guard dropped the only message before de-dup, so the bulk insert was never reached. expect(qb.values).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); delete process.env.STORE_EPHEMERAL_MESSAGES; }); it('still persists a disappearing history message when STORE_EPHEMERAL_MESSAGES is unset (default)', async () => { delete process.env.STORE_EPHEMERAL_MESSAGES; const callbacks = await startAndCaptureCallbacks(); const { qb } = setupBulkQb(); callbacks.onHistoryMessages?.([ { id: 'h-eph-default', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'vanishing', type: 'text', timestamp: 1, fromMe: false, isGroup: false, ephemeralDuration: 86400, }, ]); await flush(); expect(qb.values).toHaveBeenCalledTimes(1); const calls = qb.values.mock.calls as unknown[][]; const insertedRows = calls[0][0] as { waMessageId: string }[]; expect(insertedRows).toHaveLength(1); expect(insertedRows[0].waMessageId).toBe('h-eph-default'); }); it('persists a non-disappearing history message even with STORE_EPHEMERAL_MESSAGES=false', async () => { process.env.STORE_EPHEMERAL_MESSAGES = 'false'; const callbacks = await startAndCaptureCallbacks(); const { qb } = setupBulkQb(); callbacks.onHistoryMessages?.([ { id: 'h-normal', from: 'peer@c.us', to: 'me@c.us', chatId: 'peer@c.us', body: 'stays', type: 'text', timestamp: 1, fromMe: false, isGroup: false, // no ephemeralDuration — a regular chat message must never be dropped. }, ]); await flush(); expect(qb.values).toHaveBeenCalledTimes(1); const calls = qb.values.mock.calls as unknown[][]; const insertedRows = calls[0][0] as { waMessageId: string }[]; expect(insertedRows).toHaveLength(1); expect(insertedRows[0].waMessageId).toBe('h-normal'); delete process.env.STORE_EPHEMERAL_MESSAGES; }); }); }); // ── stop ────────────────────────────────────────────────────────── describe('stop', () => { it('should disconnect engine and set status to DISCONNECTED', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); // Start first await service.start('sess-uuid-1'); // Stop await service.stop('sess-uuid-1'); expect(mockEngine.disconnect).toHaveBeenCalled(); expect(repository.update).toHaveBeenCalledWith('sess-uuid-1', { status: SessionStatus.DISCONNECTED, }); }); }); // ── getQRCode ───────────────────────────────────────────────────── describe('getQRCode', () => { it('should throw BadRequestException if engine not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.getQRCode('sess-uuid-1')).rejects.toThrow(BadRequestException); }); it('should return QR code from engine', async () => { const session = createMockSession({ status: SessionStatus.QR_READY }); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); mockEngine.getQRCode.mockReturnValue('data:image/png;base64,iVBOR...'); const result = await service.getQRCode('sess-uuid-1'); expect(result.qrCode).toBe('data:image/png;base64,iVBOR...'); }); it('should throw if session is READY (already authenticated)', async () => { const session = createMockSession({ status: SessionStatus.READY }); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); mockEngine.getQRCode.mockReturnValue(null); await expect(service.getQRCode('sess-uuid-1')).rejects.toThrow('already authenticated'); }); }); // ── getStats ────────────────────────────────────────────────────── describe('getStats', () => { const makeStatsQb = (rows: Array<{ status: string; count: string }>) => ({ select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue(rows), }); it('should return correct session statistics', async () => { (repository.createQueryBuilder as jest.Mock) = jest.fn().mockReturnValue( makeStatsQb([ { status: SessionStatus.READY, count: '2' }, { status: SessionStatus.DISCONNECTED, count: '1' }, ]), ); const stats = await service.getStats(); expect(stats.total).toBe(3); expect(stats.ready).toBe(2); expect(stats.disconnected).toBe(1); expect(stats.byStatus[SessionStatus.READY]).toBe(2); expect(stats.memoryUsage).toBeDefined(); }); it('counts every session via a grouped COUNT, not the bounded findAll (no undercount past the cap)', async () => { const findSpy = repository.find as jest.Mock; findSpy.mockClear(); (repository.createQueryBuilder as jest.Mock) = jest .fn() .mockReturnValue(makeStatsQb([{ status: SessionStatus.READY, count: '1500' }])); const stats = await service.getStats(); // 1500 > DEFAULT_LIST_LIMIT (1000): the old findAll-based path would have capped total at 1000. expect(stats.total).toBe(1500); expect(stats.ready).toBe(1500); expect(findSpy).not.toHaveBeenCalled(); }); it('scopes the stats to a restricted key (active counts only in-scope engines)', async () => { const qb = makeStatsQb([{ status: SessionStatus.READY, count: '1' }]); (repository.createQueryBuilder as jest.Mock) = jest.fn().mockReturnValue(qb); const engines = (service as unknown as { engines: Map }).engines; engines.set('sess-A', {}); engines.set('sess-B', {}); // global engine the scoped key must NOT see counted const stats = await service.getStats(['sess-A']); expect(qb.where).toHaveBeenCalledWith('session.id IN (:...scope)', { scope: ['sess-A'] }); expect(stats.total).toBe(1); expect(stats.active).toBe(1); // not 2 (global engines.size) engines.clear(); }); }); // ── getChats ────────────────────────────────────────────────────── describe('getChats', () => { it('should delegate to engine.getChats for a started session', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const chats = [{ id: '123@c.us', name: 'Alice', isGroup: false, unreadCount: 2, timestamp: 1700000000 }]; mockEngine.getChats.mockResolvedValue(chats); const result = await service.getChats('sess-uuid-1'); expect(mockEngine.getChats).toHaveBeenCalled(); expect(result).toEqual(chats); }); it('caps an unbounded chat list at the default limit (1000), most-recent first', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const chats = Array.from({ length: 1500 }, (_, i) => ({ id: `${i}@c.us`, name: `c${i}`, isGroup: false, unreadCount: 0, timestamp: i, })); mockEngine.getChats.mockResolvedValue(chats); const result = await service.getChats('sess-uuid-1'); expect(result).toHaveLength(1000); expect(result[0].timestamp).toBe(1499); // sorted timestamp DESC before capping expect(result[999].timestamp).toBe(500); }); it('applies limit/offset to the chat list', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const chats = Array.from({ length: 50 }, (_, i) => ({ id: `${i}@c.us`, name: `c${i}`, isGroup: false, unreadCount: 0, timestamp: i, })); mockEngine.getChats.mockResolvedValue(chats); const result = await service.getChats('sess-uuid-1', { limit: 5, offset: 0 }); expect(result).toHaveLength(5); expect(result[0].timestamp).toBe(49); // most-recent first }); it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.getChats('sess-uuid-1')).rejects.toThrow(BadRequestException); }); }); describe('getGroups pagination', () => { it('caps an unbounded group list at the default limit (1000)', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const groups = Array.from({ length: 1500 }, (_, i) => ({ id: `g${i}`, name: `G${i}` })); mockEngine.getGroups.mockResolvedValue(groups); const result = await service.getGroups('sess-uuid-1'); expect(result).toHaveLength(1000); }); }); describe('start() concurrent stop/delete guard', () => { it('tears down the just-initialized engine if a stop/delete lands during start() (no resurrection to READY)', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); // Simulate a concurrent stop()/delete() landing WHILE engine.initialize() is in flight. mockEngine.initialize.mockImplementationOnce(() => { (service as unknown as { stoppingSessions: Set }).stoppingSessions.add('sess-uuid-1'); return Promise.resolve(); }); await service.start('sess-uuid-1'); // The engine registered during init must be torn down + removed, not left READY. expect(mockEngine.destroy).toHaveBeenCalled(); expect(service.getEngine('sess-uuid-1')).toBeUndefined(); }); it('tears down the just-initialized engine if the session is deleted during start() (row gone, mark cleared)', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); // Unlike a stop(), a concurrent delete() clears its teardown mark in finally AND removes the // session row before this init resolves — the mark alone can't catch it, so the post-init guard // must re-check existence. start() then surfaces the now-missing session as NotFound. mockEngine.initialize.mockImplementationOnce(() => { (service as unknown as { stoppingSessions: Set }).stoppingSessions.delete('sess-uuid-1'); (repository.findOne as jest.Mock).mockResolvedValue(null); return Promise.resolve(); }); await expect(service.start('sess-uuid-1')).rejects.toThrow(NotFoundException); expect(mockEngine.destroy).toHaveBeenCalled(); expect(service.getEngine('sess-uuid-1')).toBeUndefined(); }); }); // ── sendSeen (markChatRead) ─────────────────────────────────────── describe('sendSeen', () => { it('should delegate to engine.sendSeen with the chatId', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); mockEngine.sendSeen.mockResolvedValue(true); const result = await service.sendSeen('sess-uuid-1', '123@c.us'); expect(mockEngine.sendSeen).toHaveBeenCalledWith('123@c.us'); expect(result).toBe(true); }); it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.sendSeen('sess-uuid-1', '123@c.us')).rejects.toThrow(BadRequestException); }); }); // ── markUnread (markChatUnread) ─────────────────────────────────── describe('markUnread', () => { it('should delegate to engine.markUnread with the chatId', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); mockEngine.markUnread.mockResolvedValue(true); const result = await service.markUnread('sess-uuid-1', '123@c.us'); expect(mockEngine.markUnread).toHaveBeenCalledWith('123@c.us'); expect(result).toBe(true); }); it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.markUnread('sess-uuid-1', '123@c.us')).rejects.toThrow(BadRequestException); }); }); // ── onQRCode WebSocket emit ─────────────────────────────────────── describe('onQRCode', () => { it('emits the QR over the WebSocket so subscribed clients get it without polling', async () => { (repository.findOne as jest.Mock).mockResolvedValue(createMockSession()); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const callbacks = (mockEngine.initialize.mock.calls as [EngineEventCallbacks][])[0][0]; callbacks.onQRCode?.('qr-data-123'); expect(eventsGateway.emitQRCode).toHaveBeenCalledWith('sess-uuid-1', 'qr-data-123'); }); }); // ── deleteChat ──────────────────────────────────────────────────── describe('deleteChat', () => { it('should delegate to engine.deleteChat with the chatId', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); mockEngine.deleteChat.mockResolvedValue(true); const result = await service.deleteChat('sess-uuid-1', '1234567890-123@g.us'); expect(mockEngine.deleteChat).toHaveBeenCalledWith('1234567890-123@g.us'); expect(result).toBe(true); }); it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.deleteChat('sess-uuid-1', '1234567890-123@g.us')).rejects.toThrow(BadRequestException); }); }); // ── sendChatState (typing/recording/paused) ─────────────────────── describe('sendChatState', () => { it('should delegate to engine.sendChatState with the chatId and state', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); await service.sendChatState('sess-uuid-1', '123@c.us', 'typing'); expect(mockEngine.sendChatState).toHaveBeenCalledWith('123@c.us', 'typing'); }); it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); await expect(service.sendChatState('sess-uuid-1', '123@c.us', 'typing')).rejects.toThrow(BadRequestException); }); }); // ── onMessageRevoked (no localized string) ──────────────────────── describe('onMessageRevoked callback', () => { it('persists an empty body with type "revoked" and emits no localized string', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); (messageRepository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); // Grab the callbacks object passed to engine.initialize. const initializeCall = mockEngine.initialize.mock.calls[0] as unknown[]; const callbacks = initializeCall[0] as { onMessageRevoked: (m: { id: string; type: string; body: string }) => void; }; const revoked = { id: 'WA_MSG_1', chatId: '123@c.us', from: '123@c.us', to: 'me@c.us', type: 'revoked' as const, body: '' as const, timestamp: 1700000000, }; callbacks.onMessageRevoked(revoked); // Allow the queued microtask (repository.update().then()) to resolve. await Promise.resolve(); await Promise.resolve(); // The stored update must carry an EMPTY body and the 'revoked' type — no display string. expect(messageRepository.update).toHaveBeenCalledWith( { sessionId: 'sess-uuid-1', waMessageId: 'WA_MSG_1' }, { body: '', type: 'revoked' }, ); // The structured payload emitted to clients must not contain any localized text. expect(eventsGateway.emitMessageRevoked).toHaveBeenCalledWith( 'sess-uuid-1', expect.objectContaining({ id: 'WA_MSG_1', type: 'revoked', body: '', }), ); const revokedCall = (eventsGateway.emitMessageRevoked as jest.Mock).mock.calls[0] as unknown[]; const emittedPayload = revokedCall[1] as { body: string }; expect(emittedPayload.body).toBe(''); }); }); // ── onMessageEdited ─────────────────────────────────────────────── describe('onMessageEdited callback', () => { const startAndCaptureEditCallback = async (): Promise> => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const initializeCall = mockEngine.initialize.mock.calls[0] as unknown[]; const callbacks = initializeCall[0] as EngineEventCallbacks; return callbacks.onMessageEdited!; }; const edited = (body = 'New edited text') => ({ messageId: 'WA_MSG_EDIT_1', chatId: '123@c.us', body, senderId: '123@c.us', from: '123@c.us', to: '456@c.us', fromMe: false, isGroup: false, type: 'text' as const, hasMedia: false, timestamp: 1700000005, }); it('persists the body before emitting the WebSocket and webhook event', async () => { let releaseUpdate!: () => void; (messageRepository.update as jest.Mock).mockImplementationOnce( () => new Promise(resolve => { releaseUpdate = () => resolve({ affected: 1 }); }), ); const onMessageEdited = await startAndCaptureEditCallback(); onMessageEdited(edited()); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.update).toHaveBeenCalledWith( { sessionId: 'sess-uuid-1', waMessageId: 'WA_MSG_EDIT_1' }, { body: 'New edited text' }, ); expect(eventsGateway.emitMessageEdited).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalledWith('sess-uuid-1', 'message.edited', expect.anything()); releaseUpdate(); await new Promise(resolve => setImmediate(resolve)); expect(webhookService.dispatch).toHaveBeenCalledWith( 'sess-uuid-1', 'message.edited', expect.objectContaining({ messageId: 'WA_MSG_EDIT_1', body: 'New edited text', }), ); expect(eventsGateway.emitMessageEdited).toHaveBeenCalledWith( 'sess-uuid-1', expect.objectContaining({ messageId: 'WA_MSG_EDIT_1', body: 'New edited text', }), ); }); it('serializes rapid edits so the latest body cannot be overwritten by an older slow update', async () => { const releases: Array<() => void> = []; (messageRepository.update as jest.Mock).mockImplementation( () => new Promise(resolve => { releases.push(() => resolve({ affected: 1 })); }), ); const onMessageEdited = await startAndCaptureEditCallback(); onMessageEdited(edited('first edit')); onMessageEdited(edited('second edit')); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.update).toHaveBeenCalledTimes(1); expect(eventsGateway.emitMessageEdited).not.toHaveBeenCalled(); releases[0](); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.update).toHaveBeenCalledTimes(2); expect(eventsGateway.emitMessageEdited).toHaveBeenCalledTimes(1); releases[1](); await new Promise(resolve => setImmediate(resolve)); const payloads = (eventsGateway.emitMessageEdited as jest.Mock).mock.calls.map( call => (call as [string, { body: string }])[1].body, ); expect(payloads).toEqual(['first edit', 'second edit']); }); it('drops an edit with no target id before any persistence or notification', async () => { const onMessageEdited = await startAndCaptureEditCallback(); onMessageEdited({ ...edited(), messageId: '' }); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.update).not.toHaveBeenCalled(); expect(eventsGateway.emitMessageEdited).not.toHaveBeenCalled(); expect(webhookService.dispatch).not.toHaveBeenCalledWith('sess-uuid-1', 'message.edited', expect.anything()); }); it('still reports a real edit occurrence when the best-effort database update fails', async () => { (messageRepository.update as jest.Mock).mockRejectedValueOnce(new Error('database unavailable')); const onMessageEdited = await startAndCaptureEditCallback(); onMessageEdited(edited()); await new Promise(resolve => setImmediate(resolve)); expect(eventsGateway.emitMessageEdited).toHaveBeenCalledTimes(1); expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'message.edited', edited()); }); }); // ── recordOutboundMessageEdit (REST outbound edit -> the same mutation queue) ── describe('recordOutboundMessageEdit', () => { const startAndCaptureEditCallback = async (): Promise> => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const initializeCall = mockEngine.initialize.mock.calls[0] as unknown[]; const callbacks = initializeCall[0] as EngineEventCallbacks; return callbacks.onMessageEdited!; }; const inboundEdit = { messageId: 'WA_MSG_EDIT_1', chatId: '123@c.us', body: 'inbound edit', senderId: '123@c.us', from: '123@c.us', to: '456@c.us', fromMe: false, isGroup: false, type: 'text' as const, hasMedia: false, timestamp: 1700000005, }; it('serializes the outbound write with a queued inbound edit on the same message', async () => { const releases: Array<() => void> = []; (messageRepository.update as jest.Mock).mockImplementation( () => new Promise(resolve => { releases.push(() => resolve({ affected: 1 })); }), ); const onMessageEdited = await startAndCaptureEditCallback(); // The inbound edit lands first; the REST outbound edit for the same message must queue BEHIND // it instead of racing the row directly (latest-write-wins across both directions). onMessageEdited(inboundEdit); const outbound = service.recordOutboundMessageEdit('sess-uuid-1', 'WA_MSG_EDIT_1', 'outbound edit'); await new Promise(resolve => setImmediate(resolve)); expect(messageRepository.update).toHaveBeenCalledTimes(1); // only the inbound write started releases[0](); await new Promise(resolve => setImmediate(resolve)); // chain advances to the queued outbound write expect(messageRepository.update).toHaveBeenCalledTimes(2); releases[1](); await outbound; // resolves once its own queued write has run const bodies = (messageRepository.update as jest.Mock).mock.calls.map( call => (call as [unknown, { body: string }])[1].body, ); expect(bodies).toEqual(['inbound edit', 'outbound edit']); }); it('is best-effort: a missing row / failed write does not reject the request', async () => { (messageRepository.update as jest.Mock).mockRejectedValueOnce(new Error('db down')); await expect(service.recordOutboundMessageEdit('sess-uuid-1', 'GONE', 'x')).resolves.toBeUndefined(); }); }); // ── onGroupEvent ────────────────────────────────────────────────── describe('onGroupEvent callback', () => { const startAndCaptureGroupCallback = async (): Promise> => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const initializeCall = mockEngine.initialize.mock.calls[0] as unknown[]; const callbacks = initializeCall[0] as EngineEventCallbacks; return callbacks.onGroupEvent!; }; const groupEvent = (over: Partial = {}): GroupEvent => ({ kind: 'join', groupId: '120363@g.us', actorId: '628444@c.us', participantIds: ['628111@c.us'], timestamp: 1700000900, ...over, }); it('dispatches a join as group.join to BOTH the webhook stream and the socket room', async () => { const onGroupEvent = await startAndCaptureGroupCallback(); onGroupEvent(groupEvent()); const payload = { groupId: '120363@g.us', participantIds: ['628111@c.us'], timestamp: 1700000900, actorId: '628444@c.us', }; expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'group.join', payload); expect(eventsGateway.emitGroupJoin).toHaveBeenCalledWith('sess-uuid-1', payload); expect(eventsGateway.emitGroupLeave).not.toHaveBeenCalled(); expect(eventsGateway.emitGroupUpdate).not.toHaveBeenCalled(); }); it('dispatches a leave as group.leave', async () => { const onGroupEvent = await startAndCaptureGroupCallback(); onGroupEvent(groupEvent({ kind: 'leave' })); expect(webhookService.dispatch).toHaveBeenCalledWith( 'sess-uuid-1', 'group.leave', expect.objectContaining({ groupId: '120363@g.us', participantIds: ['628111@c.us'] }), ); expect(eventsGateway.emitGroupLeave).toHaveBeenCalledTimes(1); expect(eventsGateway.emitGroupJoin).not.toHaveBeenCalled(); }); it('dispatches an update as group.update, carrying the changes delta', async () => { const onGroupEvent = await startAndCaptureGroupCallback(); onGroupEvent( groupEvent({ kind: 'update', participantIds: [], changes: { subject: 'New name', announce: true } }), ); expect(webhookService.dispatch).toHaveBeenCalledWith( 'sess-uuid-1', 'group.update', expect.objectContaining({ groupId: '120363@g.us', participantIds: [], changes: { subject: 'New name', announce: true }, timestamp: 1700000900, }), ); expect(eventsGateway.emitGroupUpdate).toHaveBeenCalledWith( 'sess-uuid-1', expect.objectContaining({ changes: { subject: 'New name', announce: true } }), ); expect(eventsGateway.emitGroupJoin).not.toHaveBeenCalled(); }); it('omits the actorId/changes keys entirely when the engine did not report them', async () => { const onGroupEvent = await startAndCaptureGroupCallback(); onGroupEvent({ kind: 'join', groupId: '120363@g.us', participantIds: [], timestamp: 1700000901 }); const dispatchCalls = (webhookService.dispatch as jest.Mock).mock.calls as Array< [string, string, Record] >; const dispatched = dispatchCalls.find(call => call[1] === 'group.join')?.[2]; expect(dispatched).toEqual({ groupId: '120363@g.us', participantIds: [], timestamp: 1700000901 }); // Absent, not explicit-undefined: consumers diffing on key presence see no actor/delta at all. expect(Object.keys(dispatched ?? {})).toEqual(['groupId', 'participantIds', 'timestamp']); }); it('drops the event when it arrives from a stale (superseded) engine', async () => { const onGroupEvent = await startAndCaptureGroupCallback(); // A newer engine now owns the id (restart/reconnect window): the captured callback is stale. const engines = (service as unknown as { engines: Map }).engines; engines.set('sess-uuid-1', { marker: 'engine-B' }); (webhookService.dispatch as jest.Mock).mockClear(); onGroupEvent(groupEvent()); expect(webhookService.dispatch).not.toHaveBeenCalled(); expect(eventsGateway.emitGroupJoin).not.toHaveBeenCalled(); expect(eventsGateway.emitGroupLeave).not.toHaveBeenCalled(); expect(eventsGateway.emitGroupUpdate).not.toHaveBeenCalled(); }); }); // ── onCall ──────────────────────────────────────────────────────── describe('onCall callback', () => { const flush = (): Promise => new Promise(resolve => setImmediate(resolve)); const startAndCaptureCallCallback = async ( config: Record = {}, ): Promise> => { const session = createMockSession({ config }); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); const initializeCall = mockEngine.initialize.mock.calls[0] as unknown[]; const callbacks = initializeCall[0] as EngineEventCallbacks; return callbacks.onCall!; }; const callEvent = (over: Partial = {}): IncomingCallEvent => ({ callId: 'CALL1', from: '628111@c.us', isVideo: false, isGroup: false, timestamp: 1700000900, ...over, }); it('dispatches call.received to BOTH the webhook stream and the socket room', async () => { const onCall = await startAndCaptureCallCallback(); onCall(callEvent()); const payload = { callId: 'CALL1', from: '628111@c.us', isVideo: false, isGroup: false, timestamp: 1700000900, }; expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'call.received', payload); expect(eventsGateway.emitCallReceived).toHaveBeenCalledWith('sess-uuid-1', payload); }); it('auto-rejects via the engine when config.autoRejectCalls is strictly true', async () => { const onCall = await startAndCaptureCallCallback({ autoRejectCalls: true }); onCall(callEvent()); await flush(); expect(mockEngine.rejectCall).toHaveBeenCalledWith('CALL1'); // The event is still emitted — auto-reject never suppresses dispatch. expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'call.received', expect.anything()); }); it.each([{ autoRejectCalls: 'yes' }, { autoRejectCalls: 1 }, {}])( 'does NOT auto-reject for a truthy non-boolean or absent flag: %o', async config => { const onCall = await startAndCaptureCallCallback(config); onCall(callEvent()); await flush(); expect(mockEngine.rejectCall).not.toHaveBeenCalled(); // Dispatch is unaffected by the flag either way. expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'call.received', expect.anything()); }, ); it('still dispatches the event when the auto-reject itself fails', async () => { const onCall = await startAndCaptureCallCallback({ autoRejectCalls: true }); mockEngine.rejectCall.mockRejectedValue(new Error('call already ended')); onCall(callEvent()); await flush(); // must not produce an unhandled rejection expect(webhookService.dispatch).toHaveBeenCalledWith('sess-uuid-1', 'call.received', expect.anything()); expect(eventsGateway.emitCallReceived).toHaveBeenCalledTimes(1); }); it('drops the event when it arrives from a stale (superseded) engine', async () => { const onCall = await startAndCaptureCallCallback({ autoRejectCalls: true }); // A newer engine now owns the id (restart/reconnect window): the captured callback is stale. const engines = (service as unknown as { engines: Map }).engines; engines.set('sess-uuid-1', { marker: 'engine-B' }); (webhookService.dispatch as jest.Mock).mockClear(); onCall(callEvent()); await flush(); expect(webhookService.dispatch).not.toHaveBeenCalled(); expect(eventsGateway.emitCallReceived).not.toHaveBeenCalled(); expect(mockEngine.rejectCall).not.toHaveBeenCalled(); }); }); // ── getActiveCount / isActive ───────────────────────────────────── describe('getActiveCount', () => { it('should return 0 when no engines are running', () => { expect(service.getActiveCount()).toBe(0); }); it('should return correct count after starting sessions', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); expect(service.getActiveCount()).toBe(1); }); }); describe('isActive', () => { it('should return false for inactive session', () => { expect(service.isActive('nonexistent')).toBe(false); }); it('should return true for active session', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); expect(service.isActive('sess-uuid-1')).toBe(true); }); }); // ── onModuleInit ────────────────────────────────────────────────── describe('onModuleInit', () => { it('should reset active sessions to DISCONNECTED on startup', async () => { (repository.update as jest.Mock).mockResolvedValue({ affected: 3 }); await service.onModuleInit(); expect(repository.update).toHaveBeenCalledWith(expect.objectContaining({ status: expect.anything() as string }), { status: SessionStatus.DISCONNECTED, }); }); }); // ── onModuleDestroy ─────────────────────────────────────────────── describe('onModuleDestroy', () => { it('should destroy all running engines on shutdown', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); await service.start('sess-uuid-1'); await service.onModuleDestroy(); expect(mockEngine.destroy).toHaveBeenCalled(); expect(service.getActiveCount()).toBe(0); }); }); // ── onApplicationBootstrap (auto-start) ─────────────────────────── describe('onApplicationBootstrap', () => { const originalFlag = process.env.AUTO_START_SESSIONS; afterEach(async () => { if (originalFlag === undefined) delete process.env.AUTO_START_SESSIONS; else process.env.AUTO_START_SESSIONS = originalFlag; // Bootstrap always starts the (unref'd) liveness watchdog interval now — clear it so no // timer outlives the test. await service.onModuleDestroy(); }); it('does nothing when AUTO_START_SESSIONS is not enabled', async () => { delete process.env.AUTO_START_SESSIONS; const startSpy = jest.spyOn(service, 'start').mockResolvedValue(undefined as never); await service.onApplicationBootstrap(); expect(repository.find).not.toHaveBeenCalled(); expect(startSpy).not.toHaveBeenCalled(); }); it('starts no engine when there are no previously-authenticated sessions', async () => { process.env.AUTO_START_SESSIONS = 'true'; (repository.find as jest.Mock).mockResolvedValue([]); const startSpy = jest.spyOn(service, 'start').mockResolvedValue(undefined as never); await service.onApplicationBootstrap(); expect(startSpy).not.toHaveBeenCalled(); }); it('auto-starts every previously-authenticated session', async () => { process.env.AUTO_START_SESSIONS = 'true'; (repository.find as jest.Mock).mockResolvedValue([ { id: 'a', name: 'A' }, { id: 'b', name: 'B' }, ]); jest.spyOn(service as unknown as { delay: () => Promise }, 'delay').mockResolvedValue(undefined); const startSpy = jest.spyOn(service, 'start').mockResolvedValue(undefined as never); await service.onApplicationBootstrap(); expect(startSpy).toHaveBeenCalledTimes(2); expect(startSpy).toHaveBeenCalledWith('a'); expect(startSpy).toHaveBeenCalledWith('b'); }); it('keeps starting the remaining sessions when one fails', async () => { process.env.AUTO_START_SESSIONS = 'true'; (repository.find as jest.Mock).mockResolvedValue([ { id: 'a', name: 'A' }, { id: 'b', name: 'B' }, ]); jest.spyOn(service as unknown as { delay: () => Promise }, 'delay').mockResolvedValue(undefined); const startSpy = jest .spyOn(service, 'start') .mockRejectedValueOnce(new Error('boom')) .mockResolvedValueOnce(undefined as never); await service.onApplicationBootstrap(); expect(startSpy).toHaveBeenCalledTimes(2); }); }); });