diff --git a/api/models/Conversation.js b/api/models/Conversation.js index b237c41e9..8a529dd10 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -1,6 +1,6 @@ const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const getCustomConfig = require('~/server/services/Config/getCustomConfig'); +const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); diff --git a/api/models/Conversation.spec.js b/api/models/Conversation.spec.js new file mode 100644 index 000000000..1acdb7750 --- /dev/null +++ b/api/models/Conversation.spec.js @@ -0,0 +1,572 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { EModelEndpoint } = require('librechat-data-provider'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { + deleteNullOrEmptyConversations, + searchConversation, + getConvosByCursor, + getConvosQueried, + getConvoFiles, + getConvoTitle, + deleteConvos, + saveConvo, + getConvo, +} = require('./Conversation'); +jest.mock('~/server/services/Config/getCustomConfig'); +jest.mock('./Message'); +const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); +const { getMessages, deleteMessages } = require('./Message'); + +const { Conversation } = require('~/db/models'); + +describe('Conversation Operations', () => { + let mongoServer; + let mockReq; + let mockConversationData; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + // Clear database + await Conversation.deleteMany({}); + + // Reset mocks + jest.clearAllMocks(); + + // Default mock implementations + getMessages.mockResolvedValue([]); + deleteMessages.mockResolvedValue({ deletedCount: 0 }); + + mockReq = { + user: { id: 'user123' }, + body: {}, + }; + + mockConversationData = { + conversationId: uuidv4(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + }; + }); + + describe('saveConvo', () => { + it('should save a conversation for an authenticated user', async () => { + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.user).toBe('user123'); + expect(result.title).toBe('Test Conversation'); + expect(result.endpoint).toBe(EModelEndpoint.openAI); + + // Verify the conversation was actually saved to the database + const savedConvo = await Conversation.findOne({ + conversationId: mockConversationData.conversationId, + user: 'user123', + }); + expect(savedConvo).toBeTruthy(); + expect(savedConvo.title).toBe('Test Conversation'); + }); + + it('should query messages when saving a conversation', async () => { + // Mock messages as ObjectIds + const mongoose = require('mongoose'); + const mockMessages = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()]; + getMessages.mockResolvedValue(mockMessages); + + await saveConvo(mockReq, mockConversationData); + + // Verify that getMessages was called with correct parameters + expect(getMessages).toHaveBeenCalledWith( + { conversationId: mockConversationData.conversationId }, + '_id', + ); + }); + + it('should handle newConversationId when provided', async () => { + const newConversationId = uuidv4(); + const result = await saveConvo(mockReq, { + ...mockConversationData, + newConversationId, + }); + + expect(result.conversationId).toBe(newConversationId); + }); + + it('should handle unsetFields metadata', async () => { + const metadata = { + unsetFields: { someField: 1 }, + }; + + await saveConvo(mockReq, mockConversationData, metadata); + + const savedConvo = await Conversation.findOne({ + conversationId: mockConversationData.conversationId, + }); + expect(savedConvo.someField).toBeUndefined(); + }); + }); + + describe('isTemporary conversation handling', () => { + it('should save a conversation with expiredAt when isTemporary is true', async () => { + // Mock custom config with 24 hour retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 24, + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveConvo(mockReq, mockConversationData); + const afterSave = new Date(); + + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.expiredAt).toBeDefined(); + expect(result.expiredAt).toBeInstanceOf(Date); + + // Verify expiredAt is approximately 24 hours in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(), + ); + }); + + it('should save a conversation without expiredAt when isTemporary is false', async () => { + mockReq.body = { isTemporary: false }; + + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.expiredAt).toBeNull(); + }); + + it('should save a conversation without expiredAt when isTemporary is not provided', async () => { + // No isTemporary in body + mockReq.body = {}; + + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.expiredAt).toBeNull(); + }); + + it('should use custom retention period from config', async () => { + // Mock custom config with 48 hour retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 48, + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 48 hours in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle minimum retention period (1 hour)', async () => { + // Mock custom config with less than minimum retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 1 hour in the future (minimum) + const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle maximum retention period (8760 hours)', async () => { + // Mock custom config with more than maximum retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 10000, // Should be clamped to 8760 hours + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 8760 hours (1 year) in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle getCustomConfig errors gracefully', async () => { + // Mock getCustomConfig to throw an error + getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); + + mockReq.body = { isTemporary: true }; + + const result = await saveConvo(mockReq, mockConversationData); + + // Should still save the conversation but with expiredAt as null + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.expiredAt).toBeNull(); + }); + + it('should use default retention when config is not provided', async () => { + // Mock getCustomConfig to return empty config + getCustomConfig.mockResolvedValue({}); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveConvo(mockReq, mockConversationData); + + expect(result.expiredAt).toBeDefined(); + + // Default retention is 30 days (720 hours) + const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should update expiredAt when saving existing temporary conversation', async () => { + // First save a temporary conversation + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 24, + }, + }); + + mockReq.body = { isTemporary: true }; + const firstSave = await saveConvo(mockReq, mockConversationData); + const originalExpiredAt = firstSave.expiredAt; + + // Wait a bit to ensure time difference + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Save again with same conversationId but different title + const updatedData = { ...mockConversationData, title: 'Updated Title' }; + const secondSave = await saveConvo(mockReq, updatedData); + + // Should update title and create new expiredAt + expect(secondSave.title).toBe('Updated Title'); + expect(secondSave.expiredAt).toBeDefined(); + expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan( + new Date(originalExpiredAt).getTime(), + ); + }); + + it('should not set expiredAt when updating non-temporary conversation', async () => { + // First save a non-temporary conversation + mockReq.body = { isTemporary: false }; + const firstSave = await saveConvo(mockReq, mockConversationData); + expect(firstSave.expiredAt).toBeNull(); + + // Update without isTemporary flag + mockReq.body = {}; + const updatedData = { ...mockConversationData, title: 'Updated Title' }; + const secondSave = await saveConvo(mockReq, updatedData); + + expect(secondSave.title).toBe('Updated Title'); + expect(secondSave.expiredAt).toBeNull(); + }); + + it('should filter out expired conversations in getConvosByCursor', async () => { + // Create some test conversations + const nonExpiredConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Non-expired', + endpoint: EModelEndpoint.openAI, + expiredAt: null, + updatedAt: new Date(), + }); + + await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Future expired', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now + updatedAt: new Date(), + }); + + // Mock Meili search + Conversation.meiliSearch = jest.fn().mockResolvedValue({ hits: [] }); + + const result = await getConvosByCursor('user123'); + + // Should only return conversations with null or non-existent expiredAt + expect(result.conversations).toHaveLength(1); + expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId); + }); + + it('should filter out expired conversations in getConvosQueried', async () => { + // Create test conversations + const nonExpiredConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Non-expired', + endpoint: EModelEndpoint.openAI, + expiredAt: null, + }); + + const expiredConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Expired', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + const convoIds = [ + { conversationId: nonExpiredConvo.conversationId }, + { conversationId: expiredConvo.conversationId }, + ]; + + const result = await getConvosQueried('user123', convoIds); + + // Should only return the non-expired conversation + expect(result.conversations).toHaveLength(1); + expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId); + expect(result.convoMap[nonExpiredConvo.conversationId]).toBeDefined(); + expect(result.convoMap[expiredConvo.conversationId]).toBeUndefined(); + }); + }); + + describe('searchConversation', () => { + it('should find a conversation by conversationId', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: 'Test', + endpoint: EModelEndpoint.openAI, + }); + + const result = await searchConversation(mockConversationData.conversationId); + + expect(result).toBeTruthy(); + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.user).toBe('user123'); + expect(result.title).toBeUndefined(); // Only returns conversationId and user + }); + + it('should return null if conversation not found', async () => { + const result = await searchConversation('non-existent-id'); + expect(result).toBeNull(); + }); + }); + + describe('getConvo', () => { + it('should retrieve a conversation for a user', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + }); + + const result = await getConvo('user123', mockConversationData.conversationId); + + expect(result.conversationId).toBe(mockConversationData.conversationId); + expect(result.user).toBe('user123'); + expect(result.title).toBe('Test Conversation'); + }); + + it('should return null if conversation not found', async () => { + const result = await getConvo('user123', 'non-existent-id'); + expect(result).toBeNull(); + }); + }); + + describe('getConvoTitle', () => { + it('should return the conversation title', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: 'Test Title', + endpoint: EModelEndpoint.openAI, + }); + + const result = await getConvoTitle('user123', mockConversationData.conversationId); + expect(result).toBe('Test Title'); + }); + + it('should return null if conversation has no title', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: null, + endpoint: EModelEndpoint.openAI, + }); + + const result = await getConvoTitle('user123', mockConversationData.conversationId); + expect(result).toBeNull(); + }); + + it('should return "New Chat" if conversation not found', async () => { + const result = await getConvoTitle('user123', 'non-existent-id'); + expect(result).toBe('New Chat'); + }); + }); + + describe('getConvoFiles', () => { + it('should return conversation files', async () => { + const files = ['file1', 'file2']; + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + files, + }); + + const result = await getConvoFiles(mockConversationData.conversationId); + expect(result).toEqual(files); + }); + + it('should return empty array if no files', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + }); + + const result = await getConvoFiles(mockConversationData.conversationId); + expect(result).toEqual([]); + }); + + it('should return empty array if conversation not found', async () => { + const result = await getConvoFiles('non-existent-id'); + expect(result).toEqual([]); + }); + }); + + describe('deleteConvos', () => { + it('should delete conversations and associated messages', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: 'To Delete', + endpoint: EModelEndpoint.openAI, + }); + + deleteMessages.mockResolvedValue({ deletedCount: 5 }); + + const result = await deleteConvos('user123', { + conversationId: mockConversationData.conversationId, + }); + + expect(result.deletedCount).toBe(1); + expect(result.messages.deletedCount).toBe(5); + expect(deleteMessages).toHaveBeenCalledWith({ + conversationId: { $in: [mockConversationData.conversationId] }, + }); + + // Verify conversation was deleted + const deletedConvo = await Conversation.findOne({ + conversationId: mockConversationData.conversationId, + }); + expect(deletedConvo).toBeNull(); + }); + + it('should throw error if no conversations found', async () => { + await expect(deleteConvos('user123', { conversationId: 'non-existent' })).rejects.toThrow( + 'Conversation not found or already deleted.', + ); + }); + }); + + describe('deleteNullOrEmptyConversations', () => { + it('should delete conversations with null, empty, or missing conversationIds', async () => { + // Since conversationId is required by the schema, we can't create documents with null/missing IDs + // This test should verify the function works when such documents exist (e.g., from data corruption) + + // For this test, let's create a valid conversation and verify the function doesn't delete it + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user4', + endpoint: EModelEndpoint.openAI, + }); + + deleteMessages.mockResolvedValue({ deletedCount: 0 }); + + const result = await deleteNullOrEmptyConversations(); + + expect(result.conversations.deletedCount).toBe(0); // No invalid conversations to delete + expect(result.messages.deletedCount).toBe(0); + + // Verify valid conversation remains + const remainingConvos = await Conversation.find({}); + expect(remainingConvos).toHaveLength(1); + expect(remainingConvos[0].conversationId).toBe(mockConversationData.conversationId); + }); + }); + + describe('Error Handling', () => { + it('should handle database errors in saveConvo', async () => { + // Force a database error by disconnecting + await mongoose.disconnect(); + + const result = await saveConvo(mockReq, mockConversationData); + + expect(result).toEqual({ message: 'Error saving conversation' }); + + // Reconnect for other tests + await mongoose.connect(mongoServer.getUri()); + }); + }); +}); diff --git a/api/models/Message.js b/api/models/Message.js index 3d5eee6ec..5a3f84a8e 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const getCustomConfig = require('~/server/services/Config/getCustomConfig'); +const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { Message } = require('~/db/models'); const idSchema = z.string().uuid(); diff --git a/api/models/Message.spec.js b/api/models/Message.spec.js index aebaebb44..8e954a12b 100644 --- a/api/models/Message.spec.js +++ b/api/models/Message.spec.js @@ -1,17 +1,21 @@ const mongoose = require('mongoose'); -const { MongoMemoryServer } = require('mongodb-memory-server'); const { v4: uuidv4 } = require('uuid'); const { messageSchema } = require('@librechat/data-schemas'); +const { MongoMemoryServer } = require('mongodb-memory-server'); const { saveMessage, getMessages, updateMessage, deleteMessages, + bulkSaveMessages, updateMessageText, deleteMessagesSince, } = require('./Message'); +jest.mock('~/server/services/Config/getCustomConfig'); +const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); + /** * @type {import('mongoose').Model} */ @@ -117,21 +121,21 @@ describe('Message Operations', () => { const conversationId = uuidv4(); // Create multiple messages in the same conversation - const message1 = await saveMessage(mockReq, { + await saveMessage(mockReq, { messageId: 'msg1', conversationId, text: 'First message', user: 'user123', }); - const message2 = await saveMessage(mockReq, { + await saveMessage(mockReq, { messageId: 'msg2', conversationId, text: 'Second message', user: 'user123', }); - const message3 = await saveMessage(mockReq, { + await saveMessage(mockReq, { messageId: 'msg3', conversationId, text: 'Third message', @@ -314,4 +318,265 @@ describe('Message Operations', () => { expect(messages[0].text).toBe('Victim message'); }); }); + + describe('isTemporary message handling', () => { + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + }); + + it('should save a message with expiredAt when isTemporary is true', async () => { + // Mock custom config with 24 hour retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 24, + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveMessage(mockReq, mockMessageData); + const afterSave = new Date(); + + expect(result.messageId).toBe('msg123'); + expect(result.expiredAt).toBeDefined(); + expect(result.expiredAt).toBeInstanceOf(Date); + + // Verify expiredAt is approximately 24 hours in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(), + ); + }); + + it('should save a message without expiredAt when isTemporary is false', async () => { + mockReq.body = { isTemporary: false }; + + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.messageId).toBe('msg123'); + expect(result.expiredAt).toBeNull(); + }); + + it('should save a message without expiredAt when isTemporary is not provided', async () => { + // No isTemporary in body + mockReq.body = {}; + + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.messageId).toBe('msg123'); + expect(result.expiredAt).toBeNull(); + }); + + it('should use custom retention period from config', async () => { + // Mock custom config with 48 hour retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 48, + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 48 hours in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle minimum retention period (1 hour)', async () => { + // Mock custom config with less than minimum retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 1 hour in the future (minimum) + const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle maximum retention period (8760 hours)', async () => { + // Mock custom config with more than maximum retention + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 10000, // Should be clamped to 8760 hours + }, + }); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.expiredAt).toBeDefined(); + + // Verify expiredAt is approximately 8760 hours (1 year) in the future + const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should handle getCustomConfig errors gracefully', async () => { + // Mock getCustomConfig to throw an error + getCustomConfig.mockRejectedValue(new Error('Config service unavailable')); + + mockReq.body = { isTemporary: true }; + + const result = await saveMessage(mockReq, mockMessageData); + + // Should still save the message but with expiredAt as null + expect(result.messageId).toBe('msg123'); + expect(result.expiredAt).toBeNull(); + }); + + it('should use default retention when config is not provided', async () => { + // Mock getCustomConfig to return empty config + getCustomConfig.mockResolvedValue({}); + + mockReq.body = { isTemporary: true }; + + const beforeSave = new Date(); + const result = await saveMessage(mockReq, mockMessageData); + + expect(result.expiredAt).toBeDefined(); + + // Default retention is 30 days (720 hours) + const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000); + const actualExpirationTime = new Date(result.expiredAt); + + expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( + expectedExpirationTime.getTime() - 1000, + ); + expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( + expectedExpirationTime.getTime() + 1000, + ); + }); + + it('should not update expiredAt on message update', async () => { + // First save a temporary message + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 24, + }, + }); + + mockReq.body = { isTemporary: true }; + const savedMessage = await saveMessage(mockReq, mockMessageData); + const originalExpiredAt = savedMessage.expiredAt; + + // Now update the message without isTemporary flag + mockReq.body = {}; + const updatedMessage = await updateMessage(mockReq, { + messageId: 'msg123', + text: 'Updated text', + }); + + // expiredAt should not be in the returned updated message object + expect(updatedMessage.expiredAt).toBeUndefined(); + + // Verify in database that expiredAt wasn't changed + const dbMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); + expect(dbMessage.expiredAt).toEqual(originalExpiredAt); + }); + + it('should preserve expiredAt when saving existing temporary message', async () => { + // First save a temporary message + getCustomConfig.mockResolvedValue({ + interface: { + temporaryChatRetention: 24, + }, + }); + + mockReq.body = { isTemporary: true }; + const firstSave = await saveMessage(mockReq, mockMessageData); + const originalExpiredAt = firstSave.expiredAt; + + // Wait a bit to ensure time difference + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Save again with same messageId but different text + const updatedData = { ...mockMessageData, text: 'Updated text' }; + const secondSave = await saveMessage(mockReq, updatedData); + + // Should update text but create new expiredAt + expect(secondSave.text).toBe('Updated text'); + expect(secondSave.expiredAt).toBeDefined(); + expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan( + new Date(originalExpiredAt).getTime(), + ); + }); + + it('should handle bulk operations with temporary messages', async () => { + // This test verifies bulkSaveMessages doesn't interfere with expiredAt + const messages = [ + { + messageId: 'bulk1', + conversationId: uuidv4(), + text: 'Bulk message 1', + user: 'user123', + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + { + messageId: 'bulk2', + conversationId: uuidv4(), + text: 'Bulk message 2', + user: 'user123', + expiredAt: null, + }, + ]; + + await bulkSaveMessages(messages); + + const savedMessages = await Message.find({ + messageId: { $in: ['bulk1', 'bulk2'] }, + }).lean(); + + expect(savedMessages).toHaveLength(2); + + const bulk1 = savedMessages.find((m) => m.messageId === 'bulk1'); + const bulk2 = savedMessages.find((m) => m.messageId === 'bulk2'); + + expect(bulk1.expiredAt).toBeDefined(); + expect(bulk2.expiredAt).toBeNull(); + }); + }); });