mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-11 21:18:51 +01:00
- Changed default sorting from 'createdAt' to 'updatedAt' in both Conversation and Message routes. - Updated pagination logic to ensure the cursor is created from the last returned item instead of the popped item, preventing skipped items at page boundaries. - Added comprehensive tests for pagination behavior, ensuring no messages or conversations are skipped and that sorting works as expected.
833 lines
30 KiB
JavaScript
833 lines
30 KiB
JavaScript
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 () => {
|
|
// Clear database
|
|
await Conversation.deleteMany({});
|
|
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Default mock implementations
|
|
getMessages.mockResolvedValue([]);
|
|
deleteMessages.mockResolvedValue({ deletedCount: 0 });
|
|
|
|
mockReq = {
|
|
user: { id: 'user123' },
|
|
body: {},
|
|
config: {
|
|
interfaceConfig: {
|
|
temporaryChatRetention: 24, // Default 24 hours
|
|
},
|
|
},
|
|
};
|
|
|
|
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 app config with 24 hour retention
|
|
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);
|
|
|
|
// 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 app config with 48 hour retention
|
|
mockReq.config.interfaceConfig.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 app config with less than minimum retention
|
|
mockReq.config.interfaceConfig.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 app config with more than maximum retention
|
|
mockReq.config.interfaceConfig.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 missing config gracefully', async () => {
|
|
// Simulate missing config - should use default retention period
|
|
delete mockReq.config;
|
|
|
|
mockReq.body = { isTemporary: true };
|
|
|
|
const beforeSave = new Date();
|
|
const result = await saveConvo(mockReq, mockConversationData);
|
|
const afterSave = new Date();
|
|
|
|
// Should still save the conversation with default retention period (30 days)
|
|
expect(result.conversationId).toBe(mockConversationData.conversationId);
|
|
expect(result.expiredAt).toBeDefined();
|
|
expect(result.expiredAt).toBeInstanceOf(Date);
|
|
|
|
// Verify expiredAt is approximately 30 days in the future (720 hours)
|
|
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 () => {
|
|
// Mock getAppConfig to return empty config
|
|
mockReq.config = {}; // Empty config
|
|
|
|
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
|
|
mockReq.config.interfaceConfig.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());
|
|
});
|
|
});
|
|
|
|
describe('getConvosByCursor pagination', () => {
|
|
/**
|
|
* Helper to create conversations with specific timestamps
|
|
* Uses collection.insertOne to bypass Mongoose timestamps entirely
|
|
*/
|
|
const createConvoWithTimestamps = async (index, createdAt, updatedAt) => {
|
|
const conversationId = uuidv4();
|
|
// Use collection-level insert to bypass Mongoose timestamps
|
|
await Conversation.collection.insertOne({
|
|
conversationId,
|
|
user: 'user123',
|
|
title: `Conversation ${index}`,
|
|
endpoint: EModelEndpoint.openAI,
|
|
expiredAt: null,
|
|
isArchived: false,
|
|
createdAt,
|
|
updatedAt,
|
|
});
|
|
return Conversation.findOne({ conversationId }).lean();
|
|
};
|
|
|
|
it('should not skip conversations at page boundaries', async () => {
|
|
// Create 30 conversations to ensure pagination (limit is 25)
|
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
|
const convos = [];
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
const updatedAt = new Date(baseTime.getTime() - i * 60000); // Each 1 minute apart
|
|
const convo = await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
|
convos.push(convo);
|
|
}
|
|
|
|
// Fetch first page
|
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
|
|
|
expect(page1.conversations).toHaveLength(25);
|
|
expect(page1.nextCursor).toBeTruthy();
|
|
|
|
// Fetch second page using cursor
|
|
const page2 = await getConvosByCursor('user123', {
|
|
limit: 25,
|
|
cursor: page1.nextCursor,
|
|
});
|
|
|
|
// Should get remaining 5 conversations
|
|
expect(page2.conversations).toHaveLength(5);
|
|
expect(page2.nextCursor).toBeNull();
|
|
|
|
// Verify no duplicates and no gaps
|
|
const allIds = [
|
|
...page1.conversations.map((c) => c.conversationId),
|
|
...page2.conversations.map((c) => c.conversationId),
|
|
];
|
|
const uniqueIds = new Set(allIds);
|
|
|
|
expect(uniqueIds.size).toBe(30); // All 30 conversations accounted for
|
|
expect(allIds.length).toBe(30); // No duplicates
|
|
});
|
|
|
|
it('should include conversation at exact page boundary (item 26 bug fix)', async () => {
|
|
// This test specifically verifies the fix for the bug where item 26
|
|
// (the first item that should appear on page 2) was being skipped
|
|
|
|
const baseTime = new Date('2026-01-01T12:00:00.000Z');
|
|
|
|
// Create exactly 26 conversations
|
|
const convos = [];
|
|
for (let i = 0; i < 26; i++) {
|
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
|
const convo = await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
|
convos.push(convo);
|
|
}
|
|
|
|
// The 26th conversation (index 25) should be on page 2
|
|
const item26 = convos[25];
|
|
|
|
// Fetch first page with limit 25
|
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
|
|
|
expect(page1.conversations).toHaveLength(25);
|
|
expect(page1.nextCursor).toBeTruthy();
|
|
|
|
// Item 26 should NOT be in page 1
|
|
const page1Ids = page1.conversations.map((c) => c.conversationId);
|
|
expect(page1Ids).not.toContain(item26.conversationId);
|
|
|
|
// Fetch second page
|
|
const page2 = await getConvosByCursor('user123', {
|
|
limit: 25,
|
|
cursor: page1.nextCursor,
|
|
});
|
|
|
|
// Item 26 MUST be in page 2 (this was the bug - it was being skipped)
|
|
expect(page2.conversations).toHaveLength(1);
|
|
expect(page2.conversations[0].conversationId).toBe(item26.conversationId);
|
|
});
|
|
|
|
it('should sort by updatedAt DESC by default', async () => {
|
|
// Create conversations with different updatedAt times
|
|
// Note: createdAt is older but updatedAt varies
|
|
const convo1 = await createConvoWithTimestamps(
|
|
1,
|
|
new Date('2026-01-01T00:00:00.000Z'), // oldest created
|
|
new Date('2026-01-03T00:00:00.000Z'), // most recently updated
|
|
);
|
|
|
|
const convo2 = await createConvoWithTimestamps(
|
|
2,
|
|
new Date('2026-01-02T00:00:00.000Z'), // middle created
|
|
new Date('2026-01-02T00:00:00.000Z'), // middle updated
|
|
);
|
|
|
|
const convo3 = await createConvoWithTimestamps(
|
|
3,
|
|
new Date('2026-01-03T00:00:00.000Z'), // newest created
|
|
new Date('2026-01-01T00:00:00.000Z'), // oldest updated
|
|
);
|
|
|
|
const result = await getConvosByCursor('user123');
|
|
|
|
// Should be sorted by updatedAt DESC (most recent first)
|
|
expect(result.conversations).toHaveLength(3);
|
|
expect(result.conversations[0].conversationId).toBe(convo1.conversationId); // Jan 3 updatedAt
|
|
expect(result.conversations[1].conversationId).toBe(convo2.conversationId); // Jan 2 updatedAt
|
|
expect(result.conversations[2].conversationId).toBe(convo3.conversationId); // Jan 1 updatedAt
|
|
});
|
|
|
|
it('should handle conversations with same updatedAt (tie-breaker)', async () => {
|
|
const sameTime = new Date('2026-01-01T12:00:00.000Z');
|
|
|
|
// Create 3 conversations with exact same updatedAt
|
|
const convo1 = await createConvoWithTimestamps(1, sameTime, sameTime);
|
|
const convo2 = await createConvoWithTimestamps(2, sameTime, sameTime);
|
|
const convo3 = await createConvoWithTimestamps(3, sameTime, sameTime);
|
|
|
|
const result = await getConvosByCursor('user123');
|
|
|
|
// All 3 should be returned (no skipping due to same timestamps)
|
|
expect(result.conversations).toHaveLength(3);
|
|
|
|
const returnedIds = result.conversations.map((c) => c.conversationId);
|
|
expect(returnedIds).toContain(convo1.conversationId);
|
|
expect(returnedIds).toContain(convo2.conversationId);
|
|
expect(returnedIds).toContain(convo3.conversationId);
|
|
});
|
|
|
|
it('should handle cursor pagination with conversations updated during pagination', async () => {
|
|
// Simulate the scenario where a conversation is updated between page fetches
|
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
|
|
|
// Create 30 conversations
|
|
for (let i = 0; i < 30; i++) {
|
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
|
}
|
|
|
|
// Fetch first page
|
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
|
expect(page1.conversations).toHaveLength(25);
|
|
|
|
// Now update one of the conversations that should be on page 2
|
|
// to have a newer updatedAt (simulating user activity during pagination)
|
|
const convosOnPage2 = await Conversation.find({ user: 'user123' })
|
|
.sort({ updatedAt: -1 })
|
|
.skip(25)
|
|
.limit(5);
|
|
|
|
if (convosOnPage2.length > 0) {
|
|
const updatedConvo = convosOnPage2[0];
|
|
await Conversation.updateOne(
|
|
{ _id: updatedConvo._id },
|
|
{ updatedAt: new Date('2026-01-02T00:00:00.000Z') }, // Much newer
|
|
);
|
|
}
|
|
|
|
// Fetch second page with original cursor
|
|
const page2 = await getConvosByCursor('user123', {
|
|
limit: 25,
|
|
cursor: page1.nextCursor,
|
|
});
|
|
|
|
// The updated conversation might not be in page 2 anymore
|
|
// (it moved to the front), but we should still get remaining items
|
|
// without errors and without infinite loops
|
|
expect(page2.conversations.length).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should correctly decode and use cursor for pagination', async () => {
|
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
|
|
|
// Create 30 conversations
|
|
for (let i = 0; i < 30; i++) {
|
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
|
}
|
|
|
|
// Fetch first page
|
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
|
|
|
// Decode the cursor to verify it's based on the last RETURNED item
|
|
const decodedCursor = JSON.parse(Buffer.from(page1.nextCursor, 'base64').toString());
|
|
|
|
// The cursor should match the last item in page1 (item at index 24)
|
|
const lastReturnedItem = page1.conversations[24];
|
|
|
|
expect(new Date(decodedCursor.primary).getTime()).toBe(
|
|
new Date(lastReturnedItem.updatedAt).getTime(),
|
|
);
|
|
});
|
|
|
|
it('should support sortBy createdAt when explicitly requested', async () => {
|
|
// Create conversations with different timestamps
|
|
const convo1 = await createConvoWithTimestamps(
|
|
1,
|
|
new Date('2026-01-03T00:00:00.000Z'), // newest created
|
|
new Date('2026-01-01T00:00:00.000Z'), // oldest updated
|
|
);
|
|
|
|
const convo2 = await createConvoWithTimestamps(
|
|
2,
|
|
new Date('2026-01-01T00:00:00.000Z'), // oldest created
|
|
new Date('2026-01-03T00:00:00.000Z'), // newest updated
|
|
);
|
|
|
|
// Verify timestamps were set correctly
|
|
expect(new Date(convo1.createdAt).getTime()).toBe(
|
|
new Date('2026-01-03T00:00:00.000Z').getTime(),
|
|
);
|
|
expect(new Date(convo2.createdAt).getTime()).toBe(
|
|
new Date('2026-01-01T00:00:00.000Z').getTime(),
|
|
);
|
|
|
|
const result = await getConvosByCursor('user123', { sortBy: 'createdAt' });
|
|
|
|
// Should be sorted by createdAt DESC
|
|
expect(result.conversations).toHaveLength(2);
|
|
expect(result.conversations[0].conversationId).toBe(convo1.conversationId); // Jan 3 createdAt
|
|
expect(result.conversations[1].conversationId).toBe(convo2.conversationId); // Jan 1 createdAt
|
|
});
|
|
|
|
it('should handle empty result set gracefully', async () => {
|
|
const result = await getConvosByCursor('user123');
|
|
|
|
expect(result.conversations).toHaveLength(0);
|
|
expect(result.nextCursor).toBeNull();
|
|
});
|
|
|
|
it('should handle exactly limit number of conversations (no next page)', async () => {
|
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
|
|
|
// Create exactly 25 conversations (equal to default limit)
|
|
for (let i = 0; i < 25; i++) {
|
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
|
}
|
|
|
|
const result = await getConvosByCursor('user123', { limit: 25 });
|
|
|
|
expect(result.conversations).toHaveLength(25);
|
|
expect(result.nextCursor).toBeNull(); // No next page
|
|
});
|
|
});
|
|
});
|