Integrate Oudelaa backend features, security, tests, and deployment updates
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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', () => {
|
||||
@@ -7,6 +9,7 @@ describe('ChatService realtime message broadcasting', () => {
|
||||
const conversationId = new Types.ObjectId().toString();
|
||||
|
||||
let chatRepository: Record<string, jest.Mock>;
|
||||
let usersRepository: Record<string, jest.Mock>;
|
||||
let notificationsService: Record<string, jest.Mock>;
|
||||
let storageService: Record<string, jest.Mock>;
|
||||
let chatRealtimeService: Record<string, jest.Mock>;
|
||||
@@ -28,6 +31,22 @@ describe('ChatService realtime message broadcasting', () => {
|
||||
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),
|
||||
@@ -42,7 +61,7 @@ describe('ChatService realtime message broadcasting', () => {
|
||||
|
||||
service = new ChatService(
|
||||
chatRepository as any,
|
||||
{} as any,
|
||||
usersRepository as any,
|
||||
notificationsService as any,
|
||||
storageService as any,
|
||||
chatRealtimeService as any,
|
||||
@@ -197,4 +216,408 @@ describe('ChatService realtime message broadcasting', () => {
|
||||
);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user