Integrate Oudelaa backend features, security, tests, and deployment updates

This commit is contained in:
boutmoun123
2026-07-26 16:58:53 +03:00
parent 1b24ca4294
commit 2fd5322ef7
178 changed files with 19068 additions and 2592 deletions
+424 -1
View File
@@ -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 });
});
});
});