| 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/app'); |
| jest.mock('./Message'); |
| 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 () => { |
| |
| await Conversation.deleteMany({}); |
|
|
| |
| jest.clearAllMocks(); |
|
|
| |
| getMessages.mockResolvedValue([]); |
| deleteMessages.mockResolvedValue({ deletedCount: 0 }); |
|
|
| mockReq = { |
| user: { id: 'user123' }, |
| body: {}, |
| config: { |
| interfaceConfig: { |
| temporaryChatRetention: 24, |
| }, |
| }, |
| }; |
|
|
| 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); |
|
|
| |
| 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 () => { |
| |
| const mongoose = require('mongoose'); |
| const mockMessages = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()]; |
| getMessages.mockResolvedValue(mockMessages); |
|
|
| await saveConvo(mockReq, mockConversationData); |
|
|
| |
| 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 () => { |
| |
| mockReq.config.interfaceConfig.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); |
|
|
| |
| 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 () => { |
| |
| 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 () => { |
| |
| mockReq.config.interfaceConfig.temporaryChatRetention = 48; |
|
|
| mockReq.body = { isTemporary: true }; |
|
|
| const beforeSave = new Date(); |
| const result = await saveConvo(mockReq, mockConversationData); |
|
|
| expect(result.expiredAt).toBeDefined(); |
|
|
| |
| 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 () => { |
| |
| mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; |
|
|
| mockReq.body = { isTemporary: true }; |
|
|
| const beforeSave = new Date(); |
| const result = await saveConvo(mockReq, mockConversationData); |
|
|
| expect(result.expiredAt).toBeDefined(); |
|
|
| |
| 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 () => { |
| |
| mockReq.config.interfaceConfig.temporaryChatRetention = 10000; |
|
|
| mockReq.body = { isTemporary: true }; |
|
|
| const beforeSave = new Date(); |
| const result = await saveConvo(mockReq, mockConversationData); |
|
|
| expect(result.expiredAt).toBeDefined(); |
|
|
| |
| 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 missing config gracefully', async () => { |
| |
| delete mockReq.config; |
|
|
| 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); |
|
|
| |
| const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000); |
| const actualExpirationTime = new Date(result.expiredAt); |
|
|
| expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| expectedExpirationTime.getTime() - 1000, |
| ); |
| expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(), |
| ); |
| }); |
|
|
| it('should use default retention when config is not provided', async () => { |
| |
| mockReq.config = {}; |
|
|
| mockReq.body = { isTemporary: true }; |
|
|
| const beforeSave = new Date(); |
| const result = await saveConvo(mockReq, mockConversationData); |
|
|
| expect(result.expiredAt).toBeDefined(); |
|
|
| |
| 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 () => { |
| |
| mockReq.config.interfaceConfig.temporaryChatRetention = 24; |
|
|
| mockReq.body = { isTemporary: true }; |
| const firstSave = await saveConvo(mockReq, mockConversationData); |
| const originalExpiredAt = firstSave.expiredAt; |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
| |
| const updatedData = { ...mockConversationData, title: 'Updated Title' }; |
| const secondSave = await saveConvo(mockReq, updatedData); |
|
|
| |
| 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 () => { |
| |
| mockReq.body = { isTemporary: false }; |
| const firstSave = await saveConvo(mockReq, mockConversationData); |
| expect(firstSave.expiredAt).toBeNull(); |
|
|
| |
| 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 () => { |
| |
| 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), |
| updatedAt: new Date(), |
| }); |
|
|
| |
| Conversation.meiliSearch = jest.fn().mockResolvedValue({ hits: [] }); |
|
|
| const result = await getConvosByCursor('user123'); |
|
|
| |
| expect(result.conversations).toHaveLength(1); |
| expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId); |
| }); |
|
|
| it('should filter out expired conversations in getConvosQueried', async () => { |
| |
| 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); |
|
|
| |
| 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(); |
| }); |
|
|
| 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] }, |
| }); |
|
|
| |
| 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 () => { |
| |
| |
|
|
| |
| 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); |
| expect(result.messages.deletedCount).toBe(0); |
|
|
| |
| 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 () => { |
| |
| await mongoose.disconnect(); |
|
|
| const result = await saveConvo(mockReq, mockConversationData); |
|
|
| expect(result).toEqual({ message: 'Error saving conversation' }); |
|
|
| |
| await mongoose.connect(mongoServer.getUri()); |
| }); |
| }); |
| }); |
|
|