| | 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()); |
| | }); |
| | }); |
| | }); |
| |
|