import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BadRequestException, NotFoundException, PayloadTooLargeException } from '@nestjs/common'; import { MessageService } from './message.service'; import { Message, MessageDirection, MessageStatus } from './entities/message.entity'; import { SessionService } from '../session/session.service'; import { HookManager } from '../../core/hooks'; import { TemplateService } from '../template/template.service'; import { Template } from '../template/entities/template.entity'; import { SsrfBlockedError } from '../../common/security/ssrf-guard'; import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; const mockEngineResult = { id: 'wa-msg-1', timestamp: 1706868000 }; function createMockEngine() { return { sendTextMessage: jest.fn().mockResolvedValue(mockEngineResult), sendImageMessage: jest.fn().mockResolvedValue(mockEngineResult), sendVideoMessage: jest.fn().mockResolvedValue(mockEngineResult), sendAudioMessage: jest.fn().mockResolvedValue(mockEngineResult), sendDocumentMessage: jest.fn().mockResolvedValue(mockEngineResult), sendStickerMessage: jest.fn().mockResolvedValue(mockEngineResult), sendLocationMessage: jest.fn().mockResolvedValue(mockEngineResult), sendContactMessage: jest.fn().mockResolvedValue(mockEngineResult), sendPollMessage: jest.fn().mockResolvedValue(mockEngineResult), replyToMessage: jest.fn().mockResolvedValue(mockEngineResult), forwardMessage: jest.fn().mockResolvedValue(mockEngineResult), reactToMessage: jest.fn().mockResolvedValue(undefined), getMessageReactions: jest.fn().mockResolvedValue([]), deleteMessage: jest.fn().mockResolvedValue(undefined), editMessage: jest.fn().mockResolvedValue(mockEngineResult), getChatHistory: jest.fn().mockResolvedValue([]), sendChatState: jest.fn().mockResolvedValue(undefined), }; } describe('MessageService', () => { let service: MessageService; let repository: jest.Mocked>>; let sessionService: jest.Mocked>; let hookManager: jest.Mocked>; let templateService: jest.Mocked>; let lidMappingStore: { lidsForPhone: jest.Mock }; let mockEngine: ReturnType; // Auto-typing is on by default; disable it for the unrelated send tests so they don't incur the // real setTimeout delay and don't add an extra sendChatState call. The auto-typing suite opts in. beforeEach(() => { process.env.SIMULATE_TYPING = 'false'; }); afterEach(() => { delete process.env.SIMULATE_TYPING; delete process.env.SIMULATE_TYPING_MAX_MS; }); beforeEach(async () => { repository = { create: jest.fn().mockImplementation((data: Partial) => ({ id: 'msg-uuid-1', ...data }) as Message), save: jest.fn().mockImplementation(msg => Promise.resolve(msg)), findOne: jest.fn().mockResolvedValue(null), update: jest.fn().mockResolvedValue({ affected: 1 }), delete: jest.fn().mockResolvedValue({ affected: 1 }), createQueryBuilder: jest.fn(), }; mockEngine = createMockEngine(); sessionService = { getEngine: jest.fn().mockReturnValue(mockEngine), findOne: jest.fn().mockResolvedValue({ id: 'sess-1', phone: '628123456789' }), recordOutboundMessageEdit: jest.fn().mockResolvedValue(undefined), }; hookManager = { // Echo the input straight back so the message:sending gate is a pass-through by default; specific // tests override with continue:false (block) or a modified input. execute: jest .fn() .mockImplementation((_event: string, data: unknown) => Promise.resolve({ continue: true, data })), }; templateService = { resolve: jest.fn(), }; lidMappingStore = { lidsForPhone: jest.fn().mockReturnValue([]) }; const module: TestingModule = await Test.createTestingModule({ providers: [ MessageService, { provide: getRepositoryToken(Message, 'data'), useValue: repository }, { provide: SessionService, useValue: sessionService }, { provide: HookManager, useValue: hookManager }, { provide: TemplateService, useValue: templateService }, { provide: LidMappingStoreService, useValue: lidMappingStore }, ], }).compile(); service = module.get(MessageService); }); // ── sendText ────────────────────────────────────────────────────── describe('auto-typing before send (SIMULATE_TYPING, on by default)', () => { it('sends a typing presence before the message by default', async () => { delete process.env.SIMULATE_TYPING; // default = on process.env.SIMULATE_TYPING_MAX_MS = '1'; // keep the humanising delay ~instant in tests await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello' }); expect(mockEngine.sendChatState).toHaveBeenCalledWith('628123456789@c.us', 'typing'); expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('628123456789@c.us', 'Hello'); }); it('does not send typing presence when SIMULATE_TYPING=false', async () => { process.env.SIMULATE_TYPING = 'false'; await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello' }); expect(mockEngine.sendChatState).not.toHaveBeenCalled(); }); }); describe('sendText', () => { it('should send text message and return messageId + timestamp', async () => { const result = await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello', }); expect(result.messageId).toBe('wa-msg-1'); expect(result.timestamp).toBe(1706868000); expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('628123456789@c.us', 'Hello'); }); it('threads mentions through to the engine (#530)', async () => { const input = { chatId: '120@g.us', text: 'hi @62811', mentions: ['62811@c.us'] }; (hookManager.execute as jest.Mock).mockResolvedValueOnce({ continue: true, data: { sessionId: 'sess-1', input, type: 'text' }, }); await service.sendText('sess-1', input); expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('120@g.us', 'hi @62811', ['62811@c.us']); }); it('should save outgoing message as pending before sending, then update to sent', async () => { await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello', }); // First save: pending message before engine send expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'sess-1', direction: MessageDirection.OUTGOING, type: 'text', body: 'Hello', status: MessageStatus.PENDING, }), ); // save called twice: once for initial pending, once for status update to sent expect(repository.save).toHaveBeenCalledTimes(2); }); it('returns success (not FAILED) when persisting the SENT state fails after a successful send', async () => { // 1st save (PENDING) ok; 2nd save (SENT-state, after WhatsApp already accepted the message) throws. (repository.save as jest.Mock) .mockImplementationOnce((msg: unknown) => Promise.resolve(msg)) .mockRejectedValueOnce(new Error('transient db fault')); const result = await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello' }); // The send succeeded, so it is reported as success — not rethrown, not marked FAILED. expect(result.messageId).toBe('wa-msg-1'); expect(result.timestamp).toBe(1706868000); expect(hookManager.execute).not.toHaveBeenCalledWith('message:failed', expect.anything(), expect.anything()); }); it('executes the message:sending hook (message:sent now fires once from the engine message_create path)', async () => { await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'Hello', }); expect(hookManager.execute).toHaveBeenCalledWith( 'message:sending', expect.objectContaining({ type: 'text' }), expect.any(Object), ); // message:sent is no longer fired here — it is emitted solely by SessionService.onMessageCreate // with a consistent IncomingMessage payload for ALL sends (avoids the prior double dispatch). expect(hookManager.execute).not.toHaveBeenCalledWith('message:sent', expect.anything(), expect.anything()); }); it('emits message:persisted after saving an outbound message', async () => { await service.sendText('sess-1', { chatId: '628123456789@c.us', text: 'hello' }); const calls = (hookManager.execute as jest.Mock).mock.calls.filter( ([ev]: unknown[]) => ev === 'message:persisted', ) as unknown[][]; expect(calls).toHaveLength(1); expect(calls[0][1]).toMatchObject({ sessionId: 'sess-1', message: { chatId: '628123456789@c.us' } }); expect(calls[0][2]).toMatchObject({ sessionId: 'sess-1', source: 'MessageService' }); }); it('should throw BadRequestException when plugin blocks sending', async () => { (hookManager.execute as jest.Mock).mockResolvedValueOnce({ continue: false, data: {} }); await expect(service.sendText('sess-1', { chatId: 'test@c.us', text: 'blocked' })).rejects.toThrow( 'Message sending blocked by plugin', ); }); it('should throw BadRequestException if session is not active', async () => { (sessionService.getEngine as jest.Mock).mockReturnValue(undefined); await expect(service.sendText('inactive', { chatId: 'test@c.us', text: 'hello' })).rejects.toThrow( BadRequestException, ); }); }); // ── sendTemplate ────────────────────────────────────────────────── describe('sendTemplate', () => { function mockTemplate(overrides: Partial