Spaces:
Runtime error
Runtime error
| 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> = {}): 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<Partial<Repository<Session>>>; | |
| let messageRepository: jest.Mocked<Partial<Repository<Message>>>; | |
| let dataSource: jest.Mocked<Partial<DataSource>>; | |
| let engineFactory: jest.Mocked<Partial<EngineFactory>>; | |
| let eventsGateway: jest.Mocked<Partial<EventsGateway>>; | |
| let webhookService: jest.Mocked<Partial<WebhookService>>; | |
| let hookManager: jest.Mocked<Partial<HookManager>>; | |
| let configService: jest.Mocked<Partial<ConfigService>>; | |
| let lidMappingStore: jest.Mocked<Partial<LidMappingStoreService>>; | |
| let mockEngine: Record<string, jest.Mock>; | |
| 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<Message>) => ({ ...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<unknown>) => { | |
| 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(<T>(_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>(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<string, unknown> }).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<string, unknown> }).engines; | |
| const stoppingOf = () => (service as unknown as { stoppingSessions: Set<string> }).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<string, unknown> }).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(<T>(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<string, unknown> }).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(<T>(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<string, unknown>; | |
| initializingSessions: Set<string>; | |
| }; | |
| // '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<unknown>) => | |
| 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<void>; | |
| executeReconnect: (id: string, s: Session, st: unknown) => Promise<void>; | |
| engines: Map<string, unknown>; | |
| } | |
| 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<string, unknown>; | |
| sessionErrors: Map<string, string>; | |
| }; | |
| 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<void>(() => 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<void>(() => 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<string, { attempts: number; timer: null; maxAttempts: number; baseDelay: number }>; | |
| sessionErrors: Map<string, string>; | |
| 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<string, string>; | |
| scheduleReconnect: (id: string, session: Session) => void; | |
| executeReconnect: (...args: unknown[]) => Promise<void>; | |
| }; | |
| 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<void>; | |
| stoppingSessions: Set<string>; | |
| engines: Map<string, unknown>; | |
| } | |
| 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<void>(() => 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<boolean> }, '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<void>; | |
| 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<string, unknown>; | |
| reconnectStates: Map< | |
| string, | |
| { attempts: number; timer: NodeJS.Timeout | null; maxAttempts: number; baseDelay: number } | |
| >; | |
| livenessFailures: Map<string, number>; | |
| 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<boolean>(() => 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<EngineCallbacks> => { | |
| 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<string, string> }).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<string, unknown> }).engines; | |
| const startAndCapture = async (): Promise<EngineEventCallbacks> => { | |
| (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<void> => new Promise(resolve => setImmediate(resolve)); | |
| async function startAndCaptureCallbacks(): Promise<EngineEventCallbacks> { | |
| (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> = {}): 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<Message>; | |
| 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<Message>; | |
| 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<Message>; | |
| 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<string, unknown>; | |
| const webhookPayload = dispatchedEvents('message.ack')[0][2] as Record<string, unknown>; | |
| // 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<string, unknown> }; | |
| 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<string, unknown> }; | |
| 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<string, unknown> }; | |
| 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<string, unknown> }).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<string, unknown> }; | |
| 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<string, unknown> }).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 <phone>@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 <phone>@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<string, unknown>) => ({ ...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<string, unknown>) => ({ ...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<Record<string, unknown>>; | |
| 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<string, unknown>) => ({ ...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<string, unknown> }).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<string> }).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<string> }).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<NonNullable<EngineEventCallbacks['onMessageEdited']>> => { | |
| 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<NonNullable<EngineEventCallbacks['onMessageEdited']>> => { | |
| 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<NonNullable<EngineEventCallbacks['onGroupEvent']>> => { | |
| 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> = {}): 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<string, unknown>] | |
| >; | |
| 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<string, unknown> }).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<void> => new Promise(resolve => setImmediate(resolve)); | |
| const startAndCaptureCallCallback = async ( | |
| config: Record<string, unknown> = {}, | |
| ): Promise<NonNullable<EngineEventCallbacks['onCall']>> => { | |
| 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> = {}): 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<string, unknown> }).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<void> }, '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<void> }, 'delay').mockResolvedValue(undefined); | |
| const startSpy = jest | |
| .spyOn(service, 'start') | |
| .mockRejectedValueOnce(new Error('boom')) | |
| .mockResolvedValueOnce(undefined as never); | |
| await service.onApplicationBootstrap(); | |
| expect(startSpy).toHaveBeenCalledTimes(2); | |
| }); | |
| }); | |
| }); | |