import { Types } from 'mongoose'; import { ReactionType } from '../../common/enums/reaction-type.enum'; import { SortOrder } from '../../common/enums/sort-order.enum'; import { ChatService } from './chat.service'; describe('ChatService realtime message broadcasting', () => { const senderId = new Types.ObjectId().toString(); const recipientId = new Types.ObjectId().toString(); const conversationId = new Types.ObjectId().toString(); let chatRepository: Record; let usersRepository: Record; let notificationsService: Record; let storageService: Record; let chatRealtimeService: Record; let service: ChatService; const conversation = { id: conversationId, participantIds: [new Types.ObjectId(senderId), new Types.ObjectId(recipientId)], }; beforeEach(() => { chatRepository = { findConversationById: jest.fn().mockResolvedValue(conversation), findAnyBlockBetween: jest.fn().mockResolvedValue(null), createMessage: jest.fn(), updateConversationAfterNewMessage: jest.fn().mockResolvedValue(conversation), findMessageById: jest.fn(), markMessageSeen: jest.fn().mockResolvedValue(undefined), clearConversationUnreadForUser: jest.fn().mockResolvedValue(undefined), markMessageDelivered: jest.fn().mockResolvedValue(undefined), hideConversationForUser: jest.fn().mockResolvedValue(conversation), findDirectConversation: jest.fn().mockResolvedValue(null), createConversation: jest.fn(), findConversationsForUser: jest.fn().mockResolvedValue([]), countConversationsForUser: jest.fn().mockResolvedValue(0), findMessages: jest.fn().mockResolvedValue([]), countMessages: jest.fn().mockResolvedValue(0), unsendMessage: jest.fn(), setMessageReaction: jest.fn(), deleteMessageForUser: jest.fn().mockResolvedValue(undefined), createBlock: jest.fn().mockResolvedValue(undefined), removeBlock: jest.fn().mockResolvedValue(undefined), findBlock: jest.fn().mockResolvedValue(null), findBlocksByBlocker: jest.fn().mockResolvedValue([]), }; usersRepository = { findById: jest.fn().mockResolvedValue({ isDisabled: false }), }; notificationsService = { createMessageNotification: jest.fn().mockResolvedValue(null), }; storageService = { saveFile: jest.fn().mockResolvedValue('/uploads/chat/media/message.jpg'), deleteFile: jest.fn().mockResolvedValue(undefined), }; chatRealtimeService = { emitNewMessage: jest.fn(), }; service = new ChatService( chatRepository as any, usersRepository as any, notificationsService as any, storageService as any, chatRealtimeService as any, ); }); it('emits new_message after a text message is created through the service', async () => { const message = { id: new Types.ObjectId().toString(), conversationId: new Types.ObjectId(conversationId), senderId: new Types.ObjectId(senderId), content: 'hello', messageType: 'text', }; chatRepository.createMessage.mockResolvedValue(message); const result = await service.sendMessage(senderId, { conversationId, content: 'hello', messageType: 'text', }); expect(result).toBe(message); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message); expect(notificationsService.createMessageNotification).toHaveBeenCalledWith( senderId, recipientId, conversationId, 'hello', message.id, ); }); it('emits new_message after a media upload message is created through the service', async () => { const message = { id: new Types.ObjectId().toString(), conversationId: new Types.ObjectId(conversationId), senderId: new Types.ObjectId(senderId), content: '', mediaUrl: '/uploads/chat/media/message.jpg', messageType: 'image', }; chatRepository.createMessage.mockResolvedValue(message); const result = await service.sendMessageWithUpload( senderId, { conversationId, }, { mimetype: 'image/jpeg', size: 1024, buffer: Buffer.from('image'), originalname: 'message.jpg', }, ); expect(result).toBe(message); expect(chatRepository.createMessage).toHaveBeenCalledWith( expect.objectContaining({ conversationId, senderId, messageType: 'image', mediaUrl: '/uploads/chat/media/message.jpg', }), ); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1); expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message); }); it('marks a message delivered for a participant that is not the sender', async () => { const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValue({ id: messageId, conversationId: new Types.ObjectId(conversationId), senderId: new Types.ObjectId(senderId), }); const result = await service.markMessageDelivered(recipientId, conversationId, messageId); expect(chatRepository.markMessageDelivered).toHaveBeenCalledWith( messageId, recipientId, expect.any(Date), ); expect(result).toMatchObject({ conversationId, messageId, userId: recipientId, }); expect(result.deliveredAt).toEqual(expect.any(String)); }); it('does not let the sender mark their own message delivered', async () => { const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValue({ id: messageId, conversationId: new Types.ObjectId(conversationId), senderId: new Types.ObjectId(senderId), }); await expect(service.markMessageDelivered(senderId, conversationId, messageId)).rejects.toThrow( 'Sender cannot mark own message delivered', ); expect(chatRepository.markMessageDelivered).not.toHaveBeenCalled(); }); it('marks a message seen and returns conversationId and seenAt for realtime payloads', async () => { const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValue({ id: messageId, conversationId: new Types.ObjectId(conversationId), senderId: new Types.ObjectId(senderId), }); const result = await service.markMessageSeen(recipientId, messageId, conversationId); expect(chatRepository.markMessageSeen).toHaveBeenCalledWith(messageId, recipientId); expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith(conversationId, recipientId); expect(result).toMatchObject({ success: true, conversationId, messageId, userId: recipientId, }); expect(result.seenAt).toEqual(expect.any(String)); }); it('hides a conversation for the current user only', async () => { const result = await service.hideConversationForMe(senderId, conversationId); expect(chatRepository.findConversationById).toHaveBeenCalledWith(conversationId); expect(chatRepository.hideConversationForUser).toHaveBeenCalledWith( conversationId, senderId, expect.any(Date), ); expect(chatRepository.createMessage).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, conversationId, deletedForMe: true, }); }); it('does not hide a conversation for a non-participant', async () => { const outsiderId = new Types.ObjectId().toString(); await expect(service.hideConversationForMe(outsiderId, conversationId)).rejects.toThrow( 'You are not a member of this conversation', ); expect(chatRepository.hideConversationForUser).not.toHaveBeenCalled(); }); describe('conversation lifecycle', () => { it('requires another participant and validates every participant id', async () => { await expect( service.createConversation(senderId, { participantIds: [senderId] }), ).rejects.toThrow('Conversation must include at least 2 participants'); await expect( service.createConversation(senderId, { participantIds: ['not-an-object-id'] }), ).rejects.toThrow('Invalid participant id'); expect(usersRepository.findById).not.toHaveBeenCalled(); }); it('rejects disabled participants and an invalid direct-conversation shape', async () => { usersRepository.findById.mockResolvedValueOnce({ isDisabled: false }).mockResolvedValueOnce({ isDisabled: true, }); await expect( service.createConversation(senderId, { participantIds: [recipientId] }), ).rejects.toThrow('One or more participants are invalid or disabled'); const thirdId = new Types.ObjectId().toString(); await expect( service.createConversation(senderId, { participantIds: [recipientId, thirdId], isGroup: false, }), ).rejects.toThrow('Direct conversation must contain exactly 2 participants'); }); it('returns an existing direct conversation and enforces chat blocks', async () => { const existing = { id: conversationId }; chatRepository.findDirectConversation.mockResolvedValue(existing); await expect( service.createConversation(senderId, { participantIds: [recipientId] }), ).resolves.toBe(existing); expect(chatRepository.createConversation).not.toHaveBeenCalled(); chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' }); await expect( service.createConversation(senderId, { participantIds: [recipientId] }), ).rejects.toThrow('You cannot start chat with this user'); }); it('creates a deduplicated group conversation', async () => { const thirdId = new Types.ObjectId().toString(); const created = { id: conversationId, isGroup: true }; chatRepository.createConversation.mockResolvedValue(created); await expect( service.createConversation(senderId, { participantIds: [recipientId, recipientId, thirdId], title: 'Band', }), ).resolves.toBe(created); expect(chatRepository.createConversation).toHaveBeenCalledWith({ participantIds: [senderId, recipientId, thirdId], isGroup: true, title: 'Band', createdBy: senderId, }); }); it('maps unread counters and cursor metadata in the conversation list', async () => { const conversations = [ { lastMessageAt: undefined, toObject: () => ({ id: 'c1', unreadCountByUser: new Map([[senderId, '2']]) }), }, { lastMessageAt: new Date('2025-01-01T00:00:00.000Z'), toObject: () => ({ id: 'c2', unreadCountByUser: { [senderId]: 3, $__internal: 9, $isMongooseMap: true }, }), }, { lastMessageAt: undefined, toObject: () => ({ id: 'c3', unreadCountByUser: 'invalid' }), }, ]; chatRepository.findConversationsForUser.mockResolvedValue(conversations); chatRepository.countConversationsForUser.mockResolvedValue(5); const result = await service.getMyConversations(senderId, { page: 1, limit: 3, sortOrder: SortOrder.ASC, }); expect(chatRepository.findConversationsForUser).toHaveBeenCalledWith(senderId, 0, 3, { lastMessageAt: 1, updatedAt: 1, }); expect(result.items[0]).toMatchObject({ unreadCount: 2, lastMessageAt: null }); expect(result.items[1].unreadCountByUser).toEqual({ [senderId]: 3 }); expect(result.items[2].unreadCountByUser).toEqual({}); expect(result.nextCursor).toEqual(expect.any(String)); }); it('lists messages using a cursor and clears unread state', async () => { const messages = [{ id: 'm1' }]; chatRepository.findMessages.mockResolvedValue(messages); chatRepository.countMessages.mockResolvedValue(4); const cursor = Buffer.from('2', 'utf8').toString('base64url'); const result = await service.getMessages(senderId, conversationId, { page: 9, limit: 1, cursor, sortOrder: SortOrder.ASC, }); expect(chatRepository.findMessages).toHaveBeenCalledWith( conversationId, senderId, 2, 1, { createdAt: 1 }, ); expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith( conversationId, senderId, ); expect(result.items).toEqual(messages); expect(result.nextCursor).toEqual(expect.any(String)); }); it('requires a conversation id and reports a failed hide update', async () => { await expect(service.getMessages(senderId, ' ', {})).rejects.toThrow( 'conversationId is required', ); chatRepository.hideConversationForUser.mockResolvedValue(null); await expect(service.hideConversationForMe(senderId, conversationId)).rejects.toThrow( 'Conversation not found', ); }); }); describe('message validation and mutations', () => { it('requires text content and a URL for non-text messages', async () => { await expect( service.sendMessage(senderId, { conversationId, content: ' ', messageType: 'text' }), ).rejects.toThrow('Text message content is required'); await expect( service.sendMessage(senderId, { conversationId, messageType: 'video' }), ).rejects.toThrow('mediaUrl is required for non-text messages'); }); it('prevents messaging any blocked participant', async () => { chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' }); await expect( service.sendMessage(senderId, { conversationId, content: 'hello' }), ).rejects.toThrow('Cannot send message because one of participants is blocked'); expect(chatRepository.createMessage).not.toHaveBeenCalled(); }); it('validates reply ids and accepts replies from the same conversation', async () => { await expect( service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: 'invalid', }), ).rejects.toThrow('Invalid replyToMessageId'); const replyId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValueOnce({ id: replyId, conversationId: new Types.ObjectId(), }); await expect( service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }), ).rejects.toThrow('Reply message must belong to the same conversation'); const message = { id: new Types.ObjectId().toString(), conversationId: new Types.ObjectId(conversationId), }; chatRepository.findMessageById.mockResolvedValueOnce({ id: replyId, conversationId: new Types.ObjectId(conversationId), }); chatRepository.createMessage.mockResolvedValue(message); notificationsService.createMessageNotification.mockRejectedValue(new Error('push unavailable')); await expect( service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }), ).resolves.toBe(message); expect(chatRepository.createMessage).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: replyId }), ); }); it('requires an upload and cleans stored media when message creation fails', async () => { await expect(service.sendMessageWithUpload(senderId, { conversationId })).rejects.toThrow( 'mediaFile is required', ); chatRepository.createMessage.mockRejectedValue(new Error('database failed')); await expect( service.sendMessageWithUpload( senderId, { conversationId }, { mimetype: 'image/png', size: 10, buffer: Buffer.from('png'), originalname: 'photo.png', }, ), ).rejects.toThrow('database failed'); expect(storageService.deleteFile).toHaveBeenCalledWith('/uploads/chat/media/message.jpg'); }); it('detects uploaded audio and rejects unsupported media', async () => { const message = { id: new Types.ObjectId().toString(), conversationId: new Types.ObjectId(conversationId), }; storageService.saveFile.mockResolvedValue('/uploads/chat/media/message.mp3'); chatRepository.createMessage.mockResolvedValue(message); await service.sendMessageWithUpload( senderId, { conversationId }, { mimetype: 'audio/mpeg', size: 10, buffer: Buffer.from('audio'), originalname: 'voice.mp3', }, ); expect(storageService.saveFile).toHaveBeenCalledWith( expect.objectContaining({ extension: '.mp3', contentType: 'audio/mpeg' }), ); expect(chatRepository.createMessage).toHaveBeenCalledWith( expect.objectContaining({ messageType: 'audio' }), ); await expect( service.sendMessageWithUpload( senderId, { conversationId }, { mimetype: 'application/pdf', size: 10, buffer: Buffer.from('pdf'), originalname: 'file.pdf', }, ), ).rejects.toThrow('mediaFile must be image, video, or audio'); }); it('validates seen-message identifiers and ownership', async () => { await expect(service.markMessageSeen(senderId, 'invalid')).rejects.toThrow( 'Invalid message id', ); const messageId = new Types.ObjectId().toString(); await expect(service.markMessageSeen(senderId, messageId, 'invalid')).rejects.toThrow( 'Invalid conversation id', ); chatRepository.findMessageById.mockResolvedValueOnce(null); await expect(service.markMessageSeen(senderId, messageId)).rejects.toThrow( 'Message not found', ); chatRepository.findMessageById.mockResolvedValueOnce({ id: messageId, conversationId: new Types.ObjectId(), }); await expect(service.markMessageSeen(senderId, messageId, conversationId)).rejects.toThrow( 'Message must belong to the conversation', ); }); it('validates delivered-message identifiers and conversation ownership', async () => { await expect( service.markMessageDelivered(recipientId, conversationId, 'invalid'), ).rejects.toThrow('Invalid message id'); const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValue(null); await expect( service.markMessageDelivered(recipientId, conversationId, messageId), ).rejects.toThrow('Message must belong to the conversation'); }); it('unsends only an existing message owned by the current user', async () => { const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValueOnce(null); await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found'); chatRepository.findMessageById.mockResolvedValueOnce({ id: messageId, senderId: new Types.ObjectId(recipientId), }); await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow( 'You can only unsend your own messages', ); chatRepository.findMessageById.mockResolvedValue({ id: messageId, senderId: new Types.ObjectId(senderId), }); chatRepository.unsendMessage.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: messageId }); await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found'); await expect(service.unsendMessage(senderId, messageId)).resolves.toEqual({ id: messageId }); }); it('reacts to and locally deletes messages only after membership checks', async () => { const messageId = new Types.ObjectId().toString(); chatRepository.findMessageById.mockResolvedValueOnce(null); await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow( 'Message not found', ); const message = { id: messageId, conversationId: new Types.ObjectId(conversationId), }; chatRepository.findMessageById.mockResolvedValue(message); chatRepository.setMessageReaction.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: messageId, reactions: [{ type: ReactionType.LOVE }], }); await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow( 'Message not found', ); await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).resolves.toMatchObject({ id: messageId, }); await expect(service.deleteMessageForMe(senderId, messageId)).resolves.toEqual({ success: true }); expect(chatRepository.deleteMessageForUser).toHaveBeenCalledWith(messageId, senderId); chatRepository.findMessageById.mockResolvedValue(null); await expect(service.deleteMessageForMe(senderId, messageId)).rejects.toThrow( 'Message not found', ); }); }); describe('chat block and membership rules', () => { it('validates conversation ids, existence, and membership', async () => { await expect(service.assertConversationMember(senderId, 'invalid')).rejects.toThrow( 'Invalid conversation id', ); chatRepository.findConversationById.mockResolvedValueOnce(null); await expect(service.assertConversationMember(senderId, conversationId)).rejects.toThrow( 'Conversation not found', ); const outsiderId = new Types.ObjectId().toString(); await expect(service.assertConversationMember(outsiderId, conversationId)).rejects.toThrow( 'You are not a member of this conversation', ); }); it('blocks and unblocks a valid existing target', async () => { await expect(service.blockUser(senderId, recipientId)).resolves.toEqual({ blocked: true, targetUserId: recipientId, }); expect(chatRepository.createBlock).toHaveBeenCalledWith(senderId, recipientId); await expect(service.unblockUser(senderId, recipientId)).resolves.toEqual({ blocked: false, targetUserId: recipientId, }); expect(chatRepository.removeBlock).toHaveBeenCalledWith(senderId, recipientId); }); it('rejects invalid, self, and missing block targets', async () => { await expect(service.blockUser(senderId, 'invalid')).rejects.toThrow('Invalid target user id'); await expect(service.blockUser(senderId, senderId)).rejects.toThrow('You cannot block yourself'); usersRepository.findById.mockResolvedValue(null); await expect(service.blockUser(senderId, recipientId)).rejects.toThrow('Target user not found'); await expect(service.unblockUser(senderId, 'invalid')).rejects.toThrow( 'Invalid target user id', ); }); it('returns both block directions and the current block list', async () => { chatRepository.findBlock.mockResolvedValueOnce({ id: 'mine' }).mockResolvedValueOnce(null); await expect(service.getBlockStatus(senderId, recipientId)).resolves.toEqual({ targetUserId: recipientId, iBlocked: true, blockedMe: false, }); await expect(service.getBlockStatus(senderId, 'invalid')).rejects.toThrow( 'Invalid target user id', ); const items = [{ id: 'block-1' }]; chatRepository.findBlocksByBlocker.mockResolvedValue(items); await expect(service.getMyBlockedUsers(senderId)).resolves.toEqual({ items }); }); }); });