Spaces:
Runtime error
Runtime error
| 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<Partial<Repository<Message>>>; | |
| let sessionService: jest.Mocked<Partial<SessionService>>; | |
| let hookManager: jest.Mocked<Partial<HookManager>>; | |
| let templateService: jest.Mocked<Partial<TemplateService>>; | |
| let lidMappingStore: { lidsForPhone: jest.Mock }; | |
| let mockEngine: ReturnType<typeof createMockEngine>; | |
| // 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<Message>) => ({ 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>(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<Template> = {}): Template { | |
| return { | |
| id: 'tpl-1', | |
| sessionId: 'sess-1', | |
| name: 'order-confirmation', | |
| body: 'Hi {{customer}}, your order {{orderId}} shipped.', | |
| header: null, | |
| footer: null, | |
| createdAt: new Date(), | |
| updatedAt: new Date(), | |
| session: undefined as unknown as Template['session'], | |
| ...overrides, | |
| }; | |
| } | |
| beforeEach(() => { | |
| // Echo the supplied input back through the hook so the rendered text | |
| // reaches the engine via the delegated sendText path. | |
| (hookManager.execute as jest.Mock).mockImplementation((event: string, data: unknown) => | |
| Promise.resolve({ continue: true, data }), | |
| ); | |
| }); | |
| it('should resolve the template, render variables, and delegate to sendText', async () => { | |
| (templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate()); | |
| const result = await service.sendTemplate('sess-1', { | |
| chatId: '628123456789@c.us', | |
| templateName: 'order-confirmation', | |
| vars: { customer: 'Alice', orderId: '1234' }, | |
| }); | |
| expect(templateService.resolve).toHaveBeenCalledWith('sess-1', { | |
| templateId: undefined, | |
| templateName: 'order-confirmation', | |
| }); | |
| expect(mockEngine.sendTextMessage).toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| 'Hi Alice, your order 1234 shipped.', | |
| ); | |
| expect(result.messageId).toBe('wa-msg-1'); | |
| }); | |
| it('should flatten header and footer around the body with blank lines', async () => { | |
| (templateService.resolve as jest.Mock).mockResolvedValue( | |
| mockTemplate({ header: 'OpenWA Store', body: 'Hello {{customer}}', footer: 'Reply STOP to opt out' }), | |
| ); | |
| await service.sendTemplate('sess-1', { | |
| chatId: 'test@c.us', | |
| templateId: 'tpl-1', | |
| vars: { customer: 'Bob' }, | |
| }); | |
| expect(mockEngine.sendTextMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| 'OpenWA Store\n\nHello Bob\n\nReply STOP to opt out', | |
| ); | |
| }); | |
| it('should leave unmatched placeholders literal', async () => { | |
| (templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hi {{customer}} {{unknown}}' })); | |
| await service.sendTemplate('sess-1', { | |
| chatId: 'test@c.us', | |
| templateId: 'tpl-1', | |
| vars: { customer: 'Alice' }, | |
| }); | |
| expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('test@c.us', 'Hi Alice {{unknown}}'); | |
| }); | |
| it('should propagate NotFoundException when the template cannot be resolved', async () => { | |
| (templateService.resolve as jest.Mock).mockRejectedValue(new NotFoundException('Template not found')); | |
| await expect(service.sendTemplate('sess-1', { chatId: 'test@c.us', templateName: 'missing' })).rejects.toThrow( | |
| NotFoundException, | |
| ); | |
| expect(mockEngine.sendTextMessage).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ── send-hook chokepoint ────────────────────────────────────────── | |
| describe('send-hook chokepoint (message:sending gate + message:failed across all senders)', () => { | |
| it('runs the message:sending gate for a media send (sendImage) tagged with the media type', async () => { | |
| await service.sendImage('sess-1', { chatId: '628@c.us', url: 'https://e.com/i.jpg', caption: 'hi' }); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:sending', | |
| expect.objectContaining({ type: 'image' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('runs the message:sending gate for an extended send (sendPoll)', async () => { | |
| await service.sendPoll('sess-1', { chatId: '628@c.us', name: 'Q?', options: ['a', 'b'] }); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:sending', | |
| expect.objectContaining({ type: 'poll' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('lets a plugin block a media send (continue:false) before the engine is called', async () => { | |
| (hookManager.execute as jest.Mock).mockResolvedValueOnce({ continue: false, data: {} }); | |
| await expect(service.sendImage('sess-1', { chatId: '628@c.us', url: 'https://e.com/i.jpg' })).rejects.toThrow( | |
| 'Message sending blocked by plugin', | |
| ); | |
| expect(mockEngine.sendImageMessage).not.toHaveBeenCalled(); | |
| }); | |
| it('threads a plugin-modified media input through to the engine', async () => { | |
| (hookManager.execute as jest.Mock).mockResolvedValueOnce({ | |
| continue: true, | |
| data: { | |
| sessionId: 'sess-1', | |
| type: 'image', | |
| input: { chatId: '999@c.us', url: 'https://e.com/mod.jpg', caption: 'edited' }, | |
| }, | |
| }); | |
| await service.sendImage('sess-1', { chatId: '628@c.us', url: 'https://e.com/i.jpg', caption: 'orig' }); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '999@c.us', | |
| expect.objectContaining({ data: 'https://e.com/mod.jpg', caption: 'edited' }), | |
| ); | |
| }); | |
| it('fires message:failed when a media send fails (previously only sendText did)', async () => { | |
| mockEngine.sendImageMessage.mockRejectedValueOnce(new Error('engine down')); | |
| await expect(service.sendImage('sess-1', { chatId: '628@c.us', url: 'https://e.com/i.jpg' })).rejects.toThrow( | |
| 'engine down', | |
| ); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:failed', | |
| expect.objectContaining({ type: 'image', error: 'engine down' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| }); | |
| // ── sendImage ───────────────────────────────────────────────────── | |
| describe('sendImage', () => { | |
| it('should send image via URL', async () => { | |
| const result = await service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| url: 'https://example.com/img.jpg', | |
| caption: 'My image', | |
| }); | |
| expect(result.messageId).toBe('wa-msg-1'); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| expect.objectContaining({ data: 'https://example.com/img.jpg', caption: 'My image' }), | |
| ); | |
| }); | |
| it('should send image via base64 with mimetype', async () => { | |
| await service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| base64: 'iVBORw0KGgoAAAAN...', | |
| mimetype: 'image/png', | |
| }); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| expect.objectContaining({ data: 'iVBORw0KGgoAAAAN...', mimetype: 'image/png' }), | |
| ); | |
| }); | |
| it('threads media mentions into the MediaInput (#530)', async () => { | |
| await service.sendImage('sess-1', { | |
| chatId: '120@g.us', | |
| base64: 'AAAA', | |
| mimetype: 'image/png', | |
| caption: 'look @62811', | |
| mentions: ['62811@c.us'], | |
| }); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '120@g.us', | |
| expect.objectContaining({ mentions: ['62811@c.us'] }), | |
| ); | |
| }); | |
| it('maps a blocked-media-URL SSRF error to HTTP 400 with a generic message (no internal IP leak)', async () => { | |
| mockEngine.sendImageMessage.mockRejectedValueOnce( | |
| new SsrfBlockedError('Host x resolves to a blocked internal address: 169.254.169.254'), | |
| ); | |
| // Generic client message — the resolved internal IP must NOT reach the caller (recon oracle). | |
| await expect( | |
| service.sendImage('sess-1', { chatId: '628123456789@c.us', url: 'http://127.0.0.1/x.png' }), | |
| ).rejects.toMatchObject({ response: { message: 'Destination address is not allowed' } }); | |
| }); | |
| it('does not leak the SSRF internal address into the message:failed hook payload (media sends now route there)', async () => { | |
| mockEngine.sendImageMessage.mockRejectedValueOnce( | |
| new SsrfBlockedError('Host x resolves to a blocked internal address: 169.254.169.254'), | |
| ); | |
| await expect( | |
| service.sendImage('sess-1', { chatId: '628123456789@c.us', url: 'http://127.0.0.1/x.png' }), | |
| ).rejects.toThrow(); | |
| const calls = (hookManager.execute as jest.Mock).mock.calls as [string, { error?: string }, unknown][]; | |
| const failedCall = calls.find(c => c[0] === 'message:failed'); | |
| expect(failedCall).toBeDefined(); | |
| // The hook payload (now delivered to plugins for media sends) carries the generic message, NOT | |
| // the resolved internal IP that the raw SsrfBlockedError.message contains. | |
| expect(failedCall![1].error).toBe('Destination address is not allowed'); | |
| expect(failedCall![1].error).not.toContain('169.254.169.254'); | |
| }); | |
| it('rejects a base64 image over the media cap before sending or persisting', async () => { | |
| process.env.MEDIA_DOWNLOAD_MAX_BYTES = '1024'; | |
| try { | |
| await expect( | |
| service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| base64: Buffer.alloc(1025).toString('base64'), | |
| mimetype: 'image/png', | |
| }), | |
| ).rejects.toBeInstanceOf(PayloadTooLargeException); | |
| expect(mockEngine.sendImageMessage).not.toHaveBeenCalled(); | |
| } finally { | |
| delete process.env.MEDIA_DOWNLOAD_MAX_BYTES; | |
| } | |
| }); | |
| it('strips the base64 payload from a FAILED media row but keeps mimetype/filename', async () => { | |
| mockEngine.sendImageMessage.mockRejectedValueOnce(new Error('engine down')); | |
| await expect( | |
| service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| base64: 'QUJDREVGISBhIGJpZyBwYXlsb2Fk', | |
| mimetype: 'image/png', | |
| filename: 'pic.png', | |
| }), | |
| ).rejects.toThrow(); | |
| // The persisted FAILED row must not retain the (often multi-MB) base64 — it's never displayed | |
| // or retried — but should keep the descriptive mimetype/filename. | |
| const calls = (repository.save as jest.Mock).mock.calls as [Message][]; | |
| const saved = calls.at(-1)![0]; | |
| expect(saved.status).toBe(MessageStatus.FAILED); | |
| const media = (saved.metadata as { media?: { data?: unknown; mimetype?: string; filename?: string } }).media; | |
| expect(media?.data).toBeUndefined(); | |
| expect(media?.mimetype).toBe('image/png'); | |
| expect(media?.filename).toBe('pic.png'); | |
| }); | |
| }); | |
| // ── getMessages pagination guard ────────────────────────────────── | |
| describe('getMessages pagination guard', () => { | |
| interface QbMock { | |
| where: jest.Mock; | |
| orderBy: jest.Mock; | |
| skip: jest.Mock; | |
| take: jest.Mock; | |
| andWhere: jest.Mock; | |
| getManyAndCount: jest.Mock; | |
| } | |
| const makeQb = (): QbMock => { | |
| const qb: QbMock = { | |
| where: jest.fn(), | |
| orderBy: jest.fn(), | |
| skip: jest.fn(), | |
| take: jest.fn(), | |
| andWhere: jest.fn(), | |
| getManyAndCount: jest.fn().mockResolvedValue([[], 0]), | |
| }; | |
| qb.where.mockReturnValue(qb); | |
| qb.orderBy.mockReturnValue(qb); | |
| qb.skip.mockReturnValue(qb); | |
| qb.take.mockReturnValue(qb); | |
| qb.andWhere.mockReturnValue(qb); | |
| return qb; | |
| }; | |
| it('falls back to defaults on NaN limit/offset (never take(NaN))', async () => { | |
| const qb = makeQb(); | |
| (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); | |
| await service.getMessages('sess-1', { limit: NaN, offset: NaN }); | |
| expect(qb.take).toHaveBeenCalledWith(50); | |
| expect(qb.skip).toHaveBeenCalledWith(0); | |
| }); | |
| it('clamps an oversized limit to 100 and a negative offset to 0', async () => { | |
| const qb = makeQb(); | |
| (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); | |
| await service.getMessages('sess-1', { limit: 999, offset: -5 }); | |
| expect(qb.take).toHaveBeenCalledWith(100); | |
| expect(qb.skip).toHaveBeenCalledWith(0); | |
| }); | |
| }); | |
| // ── getMessages from-filter (lid resolution becomes a hit) ───────── | |
| describe('getMessages from-filter resolves a lid to a phone', () => { | |
| // A group message whose stored author is an unresolved lid, plus a plain DM from the same person. | |
| const lidRow = { id: 'm-lid', from: '111@lid', chatId: 'grp@g.us' } as Message; | |
| const dmRow = { id: 'm-dm', from: '628999@c.us', chatId: '628999@c.us' } as Message; | |
| const rows = [lidRow, dmRow]; | |
| // A query-builder fake that actually filters by the `from IN (:...froms)` clause it receives, so the | |
| // test exercises the resolution-driven expansion end to end (filter -> rows returned). | |
| const makeFilteringQb = () => { | |
| let froms: string[] | null = null; | |
| const qb = { | |
| where: jest.fn().mockReturnThis(), | |
| orderBy: jest.fn().mockReturnThis(), | |
| skip: jest.fn().mockReturnThis(), | |
| take: jest.fn().mockReturnThis(), | |
| andWhere: jest.fn().mockImplementation((_clause: string, params?: { froms?: string[] }) => { | |
| if (params?.froms) froms = params.froms; | |
| return qb; | |
| }), | |
| getManyAndCount: jest.fn().mockImplementation(() => { | |
| const matched = froms ? rows.filter(r => froms!.includes(r.from)) : rows; | |
| return Promise.resolve([matched, matched.length]); | |
| }), | |
| }; | |
| return qb; | |
| }; | |
| it('returns the lid-authored message once the table maps the lid to that phone (the hit)', async () => { | |
| lidMappingStore.lidsForPhone.mockReturnValue(['111']); // table: lid 111 -> phone 628999 | |
| const qb = makeFilteringQb(); | |
| (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); | |
| const { messages } = await service.getMessages('sess-1', { from: '628999' }); | |
| expect(lidMappingStore.lidsForPhone).toHaveBeenCalledWith('628999'); | |
| expect(messages.map(m => m.id).sort()).toEqual(['m-dm', 'm-lid']); | |
| }); | |
| it('misses the lid-authored message when the table has no mapping (the prior silent miss)', async () => { | |
| lidMappingStore.lidsForPhone.mockReturnValue([]); // unresolved: no lid -> phone row yet | |
| const qb = makeFilteringQb(); | |
| (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); | |
| const { messages } = await service.getMessages('sess-1', { from: '628999' }); | |
| expect(messages.map(m => m.id)).toEqual(['m-dm']); // only the @c.us DM matches | |
| }); | |
| }); | |
| // ── getMessages chatId filter is dialect-agnostic ───────────────── | |
| describe('getMessages chatId filter matches across dialects', () => { | |
| // A message stored with the raw @s.whatsapp.net chatId (e.g. an outbound send addressed by a raw id). | |
| const stored = { id: 'm1', from: '628113@c.us', chatId: '6281316434311@s.whatsapp.net' } as Message; | |
| const makeChatQb = () => { | |
| let chatIds: string[] | null = null; | |
| const qb = { | |
| where: jest.fn().mockReturnThis(), | |
| orderBy: jest.fn().mockReturnThis(), | |
| skip: jest.fn().mockReturnThis(), | |
| take: jest.fn().mockReturnThis(), | |
| andWhere: jest.fn().mockImplementation((_clause: string, params?: { chatIds?: string[] }) => { | |
| if (params?.chatIds) chatIds = params.chatIds; | |
| return qb; | |
| }), | |
| getManyAndCount: jest.fn().mockImplementation(() => { | |
| const matched = chatIds && chatIds.includes(stored.chatId) ? [stored] : []; | |
| return Promise.resolve([matched, matched.length]); | |
| }), | |
| }; | |
| return qb; | |
| }; | |
| it('returns a @s.whatsapp.net-stored message when filtering by the neutral @c.us chat id', async () => { | |
| lidMappingStore.lidsForPhone.mockReturnValue([]); | |
| const qb = makeChatQb(); | |
| (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); | |
| const { messages } = await service.getMessages('sess-1', { chatId: '6281316434311@c.us' }); | |
| expect(messages.map(m => m.id)).toEqual(['m1']); | |
| }); | |
| }); | |
| // ── sendVideo / sendAudio / sendDocument / sendSticker ──────────── | |
| describe('sendVideo', () => { | |
| it('should call engine.sendVideoMessage', async () => { | |
| await service.sendVideo('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/video.mp4', | |
| }); | |
| expect(mockEngine.sendVideoMessage).toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe('sendAudio', () => { | |
| it('should call engine.sendAudioMessage', async () => { | |
| await service.sendAudio('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/audio.ogg', | |
| }); | |
| expect(mockEngine.sendAudioMessage).toHaveBeenCalled(); | |
| }); | |
| it('sends a voice note (ptt) and defaults the mimetype to ogg/opus when omitted', async () => { | |
| await service.sendAudio('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/voice', | |
| ptt: true, | |
| }); | |
| expect(mockEngine.sendAudioMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| expect.objectContaining({ ptt: true, mimetype: 'audio/ogg; codecs=opus' }), | |
| ); | |
| }); | |
| it('respects a caller-supplied mimetype for a voice note', async () => { | |
| await service.sendAudio('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/voice.ogg', | |
| mimetype: 'audio/ogg', | |
| ptt: true, | |
| }); | |
| expect(mockEngine.sendAudioMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| expect.objectContaining({ ptt: true, mimetype: 'audio/ogg' }), | |
| ); | |
| }); | |
| it('persists a voice note as type "voice"', async () => { | |
| await service.sendAudio('sess-1', { chatId: 'test@c.us', url: 'https://example.com/voice', ptt: true }); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'voice' })); | |
| }); | |
| it('labels the message:sending gate "voice" for a voice note (matches the persisted/failed type)', async () => { | |
| await service.sendAudio('sess-1', { chatId: 'test@c.us', url: 'https://example.com/voice', ptt: true }); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:sending', | |
| expect.objectContaining({ type: 'voice' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('labels the message:sending gate "audio" for a plain (non-ptt) audio send', async () => { | |
| await service.sendAudio('sess-1', { chatId: 'test@c.us', url: 'https://example.com/audio.ogg' }); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:sending', | |
| expect.objectContaining({ type: 'audio' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('persists a plain audio send (no ptt) as type "audio"', async () => { | |
| await service.sendAudio('sess-1', { chatId: 'test@c.us', url: 'https://example.com/audio.ogg' }); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'audio' })); | |
| }); | |
| }); | |
| describe('sendDocument', () => { | |
| it('should call engine.sendDocumentMessage with filename', async () => { | |
| await service.sendDocument('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/doc.pdf', | |
| filename: 'report.pdf', | |
| }); | |
| expect(mockEngine.sendDocumentMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| expect.objectContaining({ filename: 'report.pdf' }), | |
| ); | |
| }); | |
| }); | |
| describe('sendSticker', () => { | |
| it('should call engine.sendStickerMessage', async () => { | |
| await service.sendSticker('sess-1', { | |
| chatId: 'test@c.us', | |
| url: 'https://example.com/sticker.webp', | |
| }); | |
| expect(mockEngine.sendStickerMessage).toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ── sendLocation ────────────────────────────────────────────────── | |
| describe('sendLocation', () => { | |
| it('should send location with lat/lng', async () => { | |
| const result = await service.sendLocation('sess-1', { | |
| chatId: 'test@c.us', | |
| latitude: -6.2088, | |
| longitude: 106.8456, | |
| description: 'Jakarta', | |
| }); | |
| expect(result.messageId).toBe('wa-msg-1'); | |
| expect(mockEngine.sendLocationMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| expect.objectContaining({ latitude: -6.2088, longitude: 106.8456 }), | |
| ); | |
| }); | |
| }); | |
| // ── sendContact ─────────────────────────────────────────────────── | |
| describe('sendContact', () => { | |
| it('should send contact with name and number', async () => { | |
| const result = await service.sendContact('sess-1', { | |
| chatId: 'test@c.us', | |
| contactName: 'John Doe', | |
| contactNumber: '+628123456789', | |
| }); | |
| expect(result.messageId).toBe('wa-msg-1'); | |
| expect(mockEngine.sendContactMessage).toHaveBeenCalledWith( | |
| 'test@c.us', | |
| expect.objectContaining({ name: 'John Doe', number: '+628123456789' }), | |
| ); | |
| }); | |
| }); | |
| // ── sendPoll ────────────────────────────────────────────────────── | |
| describe('sendPoll', () => { | |
| it('should send a poll and default to single choice', async () => { | |
| const result = await service.sendPoll('sess-1', { | |
| chatId: '120363000@g.us', | |
| name: 'Where should we meet?', | |
| options: ['Park', 'Beach'], | |
| }); | |
| expect(result.messageId).toBe('wa-msg-1'); | |
| expect(mockEngine.sendPollMessage).toHaveBeenCalledWith('120363000@g.us', { | |
| name: 'Where should we meet?', | |
| options: ['Park', 'Beach'], | |
| allowMultipleAnswers: false, | |
| }); | |
| // A poll has no plain-text body, so it is persisted as type 'poll' with the question as the body. | |
| expect(repository.create).toHaveBeenCalledWith( | |
| expect.objectContaining({ type: 'poll', body: '📊 Where should we meet?' }), | |
| ); | |
| }); | |
| it('should pass allowMultipleAnswers through to the engine', async () => { | |
| await service.sendPoll('sess-1', { | |
| chatId: '120363000@g.us', | |
| name: 'Pick toppings', | |
| options: ['Cheese', 'Ham', 'Olives'], | |
| allowMultipleAnswers: true, | |
| }); | |
| expect(mockEngine.sendPollMessage).toHaveBeenCalledWith( | |
| '120363000@g.us', | |
| expect.objectContaining({ allowMultipleAnswers: true }), | |
| ); | |
| }); | |
| }); | |
| // ── reply / forward ─────────────────────────────────────────────── | |
| describe('reply', () => { | |
| it('should call engine.replyToMessage with quotedMessageId', async () => { | |
| await service.reply('sess-1', { | |
| chatId: 'test@c.us', | |
| quotedMessageId: 'wa-quoted-1', | |
| text: 'This is a reply', | |
| }); | |
| expect(mockEngine.replyToMessage).toHaveBeenCalledWith('test@c.us', 'wa-quoted-1', 'This is a reply'); | |
| }); | |
| }); | |
| describe('forward', () => { | |
| it('should call engine.forwardMessage with from/to chats', async () => { | |
| await service.forward('sess-1', { | |
| fromChatId: 'from@c.us', | |
| toChatId: 'to@c.us', | |
| messageId: 'wa-msg-to-fwd', | |
| }); | |
| expect(mockEngine.forwardMessage).toHaveBeenCalledWith('from@c.us', 'to@c.us', 'wa-msg-to-fwd'); | |
| }); | |
| it('should save forwarded message with toChatId', async () => { | |
| await service.forward('sess-1', { | |
| fromChatId: 'from@c.us', | |
| toChatId: 'to@c.us', | |
| messageId: 'wa-msg-to-fwd', | |
| }); | |
| expect(repository.create).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| chatId: 'to@c.us', | |
| body: '[Forwarded]', | |
| type: 'forward', | |
| }), | |
| ); | |
| }); | |
| }); | |
| // ── saveIncomingMessage ─────────────────────────────────────────── | |
| describe('saveIncomingMessage', () => { | |
| it('should save with INCOMING direction', async () => { | |
| await service.saveIncomingMessage('sess-1', { | |
| waMessageId: 'wa-in-1', | |
| chatId: 'sender@c.us', | |
| body: 'Hi there', | |
| type: 'text', | |
| }); | |
| expect(repository.create).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| sessionId: 'sess-1', | |
| direction: MessageDirection.INCOMING, | |
| }), | |
| ); | |
| }); | |
| }); | |
| // ── buildMediaInput (via sendImage) ─────────────────────────────── | |
| describe('buildMediaInput validation', () => { | |
| it('should throw when neither url nor base64 is provided', async () => { | |
| await expect(service.sendImage('sess-1', { chatId: 'test@c.us' })).rejects.toThrow( | |
| 'Either url or base64 must be provided', | |
| ); | |
| }); | |
| it('should throw when base64 is provided without mimetype', async () => { | |
| await expect( | |
| service.sendImage('sess-1', { | |
| chatId: 'test@c.us', | |
| base64: 'data...', | |
| }), | |
| ).rejects.toThrow('mimetype is required when using base64 data'); | |
| }); | |
| it('prefers base64 over url when both are provided (#670)', async () => { | |
| // When both are sent, base64 is the explicit local payload and must win over `url` — otherwise | |
| // a stale `url` is fetched and silently shadows the image. This aligns the send selection with | |
| // the base64-first persisted metadata and the `@ValidateIf((o) => !o.base64)` intent on `url`. | |
| await service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| url: 'https://example.com/img.jpg', | |
| base64: 'iVBORw0KGgoAAAAN...', | |
| mimetype: 'image/png', | |
| }); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| expect.objectContaining({ data: 'iVBORw0KGgoAAAAN...' }), | |
| ); | |
| expect(mockEngine.sendImageMessage).not.toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| expect.objectContaining({ data: 'https://example.com/img.jpg' }), | |
| ); | |
| }); | |
| it('strips a data-URI prefix before passing base64 bytes to the engine', async () => { | |
| await service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| base64: 'data:image/png;base64,QUJD', | |
| mimetype: 'image/png', | |
| }); | |
| expect(mockEngine.sendImageMessage).toHaveBeenCalledWith( | |
| '628123456789@c.us', | |
| expect.objectContaining({ data: 'QUJD' }), | |
| ); | |
| }); | |
| it('rejects a data URI with no encoded payload', async () => { | |
| await expect( | |
| service.sendImage('sess-1', { | |
| chatId: '628123456789@c.us', | |
| base64: 'data:image/png;base64,', | |
| mimetype: 'image/png', | |
| }), | |
| ).rejects.toThrow('Either url or base64 must be provided'); | |
| expect(mockEngine.sendImageMessage).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ── reactToMessage / deleteMessage ──────────────────────────────── | |
| describe('reactToMessage', () => { | |
| it('should call engine.reactToMessage', async () => { | |
| await service.reactToMessage('sess-1', { | |
| chatId: 'test@c.us', | |
| messageId: 'wa-msg-1', | |
| emoji: '👍', | |
| }); | |
| expect(mockEngine.reactToMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', '👍'); | |
| }); | |
| }); | |
| describe('getChatHistory', () => { | |
| it('should call engine.getChatHistory with default limit and includeMedia=false', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us'); | |
| expect(mockEngine.getChatHistory).toHaveBeenCalledWith('test@c.us', 50, false); | |
| }); | |
| it('should pass through custom limit', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 10); | |
| expect(mockEngine.getChatHistory).toHaveBeenCalledWith('test@c.us', 10, false); | |
| }); | |
| it('should pass through includeMedia flag', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 5, true); | |
| expect(mockEngine.getChatHistory).toHaveBeenCalledWith('test@c.us', 5, true); | |
| }); | |
| it('should clamp the limit to [1, 100] and default non-finite values to 50', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 500); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 100, false); | |
| await service.getChatHistory('sess-1', 'test@c.us', 0); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 1, false); | |
| await service.getChatHistory('sess-1', 'test@c.us', Number.NaN); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 50, false); | |
| }); | |
| it('should return engine result', async () => { | |
| const fake = [{ id: 'm1', body: 'hi', from: 'a', to: 'b', chatId: 'test@c.us' }]; | |
| mockEngine.getChatHistory.mockResolvedValueOnce(fake); | |
| const result = await service.getChatHistory('sess-1', 'test@c.us'); | |
| expect(result).toBe(fake); | |
| }); | |
| describe('deep mode (#347)', () => { | |
| it('allows a limit above the standard 100 cap when deep=true', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 500, false, true); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 500, false); | |
| }); | |
| it('clamps a deep limit to the 2000 ceiling', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 5000, false, true); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 2000, false); | |
| }); | |
| it('forces includeMedia off in deep mode (metadata-only)', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 300, true, true); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 300, false); | |
| }); | |
| it('still clamps to 100 when deep is not set (regression guard)', async () => { | |
| await service.getChatHistory('sess-1', 'test@c.us', 500, false, false); | |
| expect(mockEngine.getChatHistory).toHaveBeenLastCalledWith('test@c.us', 100, false); | |
| }); | |
| }); | |
| }); | |
| describe('deleteMessage', () => { | |
| it('should call engine.deleteMessage with forEveryone default true', async () => { | |
| await service.deleteMessage('sess-1', { | |
| chatId: 'test@c.us', | |
| messageId: 'wa-msg-1', | |
| }); | |
| expect(mockEngine.deleteMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', true); | |
| }); | |
| it('should pass forEveryone=false when specified', async () => { | |
| await service.deleteMessage('sess-1', { | |
| chatId: 'test@c.us', | |
| messageId: 'wa-msg-1', | |
| forEveryone: false, | |
| }); | |
| expect(mockEngine.deleteMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', false); | |
| }); | |
| }); | |
| describe('editMessage', () => { | |
| it('edits via the engine, delegates the stored-row update, and returns the engine result', async () => { | |
| const res = await service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }); | |
| expect(mockEngine.editMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', 'edited'); | |
| // Persistence is delegated to the session's per-message mutation queue (serialized with the | |
| // inbound edit path) — the service no longer writes the row directly. | |
| expect(sessionService.recordOutboundMessageEdit).toHaveBeenCalledWith('sess-1', 'wa-msg-1', 'edited'); | |
| expect(repository.update).not.toHaveBeenCalled(); | |
| expect(res).toEqual({ messageId: 'wa-msg-1', timestamp: 1706868000 }); | |
| }); | |
| it('still succeeds when the delegated stored-row update is a no-op (the engine edit already happened)', async () => { | |
| // recordOutboundMessageEdit is best-effort by contract (never rejects); a missing row or a | |
| // failed write is logged inside the session service, not surfaced here. | |
| const res = await service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }); | |
| expect(res).toEqual({ messageId: 'wa-msg-1', timestamp: 1706868000 }); | |
| }); | |
| it('propagates the engine not-found error as-is (MessageNotFoundError → 404)', async () => { | |
| mockEngine.editMessage.mockRejectedValueOnce(new NotFoundException('Message wa-msg-1 not found')); | |
| await expect( | |
| service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }), | |
| ).rejects.toBeInstanceOf(NotFoundException); | |
| expect(sessionService.recordOutboundMessageEdit).not.toHaveBeenCalled(); | |
| }); | |
| it('throws BadRequestException when the session is not started', async () => { | |
| (sessionService.getEngine as jest.Mock).mockReturnValue(undefined); | |
| await expect( | |
| service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }), | |
| ).rejects.toBeInstanceOf(BadRequestException); | |
| expect(mockEngine.editMessage).not.toHaveBeenCalled(); | |
| }); | |
| // An edit replaces the text the recipient sees, so it belongs to the same moderation | |
| // chokepoint as every other sender rather than going out unseen by plugins. | |
| it('runs the message:sending gate tagged as an edit', async () => { | |
| await service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }); | |
| expect(hookManager.execute).toHaveBeenCalledWith( | |
| 'message:sending', | |
| expect.objectContaining({ type: 'edit' }), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('lets a plugin block an edit before the engine is called', async () => { | |
| (hookManager.execute as jest.Mock).mockResolvedValueOnce({ continue: false, data: {} }); | |
| await expect( | |
| service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'edited' }), | |
| ).rejects.toThrow('Message sending blocked by plugin'); | |
| expect(mockEngine.editMessage).not.toHaveBeenCalled(); | |
| expect(sessionService.recordOutboundMessageEdit).not.toHaveBeenCalled(); | |
| }); | |
| it('threads a plugin-rewritten edit body through to the engine and the stored row', async () => { | |
| (hookManager.execute as jest.Mock).mockResolvedValueOnce({ | |
| continue: true, | |
| data: { input: { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'redacted' } }, | |
| }); | |
| await service.editMessage('sess-1', { chatId: 'test@c.us', messageId: 'wa-msg-1', body: 'secret' }); | |
| expect(mockEngine.editMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', 'redacted'); | |
| expect(sessionService.recordOutboundMessageEdit).toHaveBeenCalledWith('sess-1', 'wa-msg-1', 'redacted'); | |
| }); | |
| }); | |
| /** | |
| * The empty id is the engine's "sent, but I couldn't read the id back" signal (#757). It has to reach | |
| * the DB as NULL: UQ_messages_sessionId_waMessageId is NOT partial, so '' collides with the next | |
| * id-less send in the same session, while NULLs stay exempt. In the bulk path that violation is | |
| * swallowed into a warning, so the row would vanish with nothing surfacing. | |
| */ | |
| describe('saveOutgoingMessage id normalization (#757)', () => { | |
| it('stores an empty engine id as NULL rather than an empty string', async () => { | |
| await service.saveOutgoingMessage('sess-1', { waMessageId: '', chatId: '621@c.us', type: 'text' }); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ waMessageId: undefined })); | |
| }); | |
| it('leaves a real id untouched', async () => { | |
| await service.saveOutgoingMessage('sess-1', { | |
| waMessageId: 'true_621@c.us_ABC', | |
| chatId: '621@c.us', | |
| type: 'text', | |
| }); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ waMessageId: 'true_621@c.us_ABC' })); | |
| }); | |
| }); | |
| describe('persistSentState vs the own-send echo (dedup race)', () => { | |
| it('merges state onto the echo row, then drops the redundant PENDING row', async () => { | |
| // The engine's message_create echo (onMessageCreate) won the insert race, so the SENT-state save | |
| // collides on UNIQUE(sessionId, waMessageId). The echo row carries only a media-less marker — | |
| // the merge must land status/timestamp/metadata on it BEFORE the placeholder is deleted, or the | |
| // payload is lost. The send still succeeds. | |
| (repository.save as jest.Mock) | |
| .mockImplementationOnce(msg => Promise.resolve(msg)) // saveOutgoingMessage (PENDING) | |
| .mockRejectedValueOnce(new Error('UNIQUE constraint failed: messages.sessionId, messages.waMessageId')); | |
| const result = await service.sendText('sess-1', { chatId: '621@c.us', text: 'hi' }); | |
| expect(result.messageId).toBe('wa-msg-1'); // send reported success | |
| expect(repository.update).toHaveBeenCalledWith( | |
| { sessionId: 'sess-1', waMessageId: 'wa-msg-1' }, | |
| expect.objectContaining({ status: MessageStatus.SENT, timestamp: 1706868000 }), | |
| ); | |
| expect(repository.delete).toHaveBeenCalledWith({ id: 'msg-uuid-1' }); | |
| }); | |
| it('merges the media payload onto the echo row for a media send (no data loss after reload)', async () => { | |
| (repository.save as jest.Mock) | |
| .mockImplementationOnce(msg => Promise.resolve(msg)) | |
| .mockRejectedValueOnce(new Error('UNIQUE constraint failed: messages.sessionId, messages.waMessageId')); | |
| await service.sendImage('sess-1', { chatId: '621@c.us', base64: 'QUJD', mimetype: 'image/png' }); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access | |
| const patch = (repository.update as jest.Mock).mock.calls[0]?.[1] as Record<string, unknown> | undefined; | |
| expect((patch?.metadata as { media?: { data?: string } } | undefined)?.media?.data).toBe('QUJD'); | |
| expect(repository.delete).toHaveBeenCalledWith({ id: 'msg-uuid-1' }); | |
| }); | |
| it('does NOT delete anything on a transient (non-unique) persist error', async () => { | |
| (repository.save as jest.Mock) | |
| .mockImplementationOnce(msg => Promise.resolve(msg)) | |
| .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')); | |
| const result = await service.sendText('sess-1', { chatId: '621@c.us', text: 'hi' }); | |
| expect(result.messageId).toBe('wa-msg-1'); // transient persist faults never fail the send | |
| expect(repository.delete).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| }); | |