import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type { IMessage } from '..'; import { createMessageMethods } from './message'; import { createModels } from '../models'; jest.mock('~/config/winston', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), })); let mongoServer: InstanceType; let Message: mongoose.Model; let saveMessage: ReturnType['saveMessage']; let getMessages: ReturnType['getMessages']; let updateMessage: ReturnType['updateMessage']; let deleteMessages: ReturnType['deleteMessages']; let bulkSaveMessages: ReturnType['bulkSaveMessages']; let updateMessageText: ReturnType['updateMessageText']; let deleteMessagesSince: ReturnType['deleteMessagesSince']; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); const models = createModels(mongoose); Object.assign(mongoose.models, models); Message = mongoose.models.Message; const methods = createMessageMethods(mongoose); saveMessage = methods.saveMessage; getMessages = methods.getMessages; updateMessage = methods.updateMessage; deleteMessages = methods.deleteMessages; bulkSaveMessages = methods.bulkSaveMessages; updateMessageText = methods.updateMessageText; deleteMessagesSince = methods.deleteMessagesSince; await mongoose.connect(mongoUri); }); afterAll(async () => { await mongoose.disconnect(); await mongoServer.stop(); }); describe('Message Operations', () => { let mockCtx: { userId: string; isTemporary?: boolean; interfaceConfig?: { temporaryChatRetention?: number }; }; let mockMessageData: Partial = { messageId: 'msg123', conversationId: uuidv4(), text: 'Hello, world!', user: 'user123', }; beforeEach(async () => { // Clear database await Message.deleteMany({}); mockCtx = { userId: 'user123', interfaceConfig: { temporaryChatRetention: 24, // Default 24 hours }, }; mockMessageData = { messageId: 'msg123', conversationId: uuidv4(), text: 'Hello, world!', user: 'user123', }; }); describe('saveMessage', () => { it('should save a message for an authenticated user', async () => { const result = await saveMessage(mockCtx, mockMessageData); expect(result?.messageId).toBe('msg123'); expect(result?.user).toBe('user123'); expect(result?.text).toBe('Hello, world!'); // Verify the message was actually saved to the database const savedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); expect(savedMessage).toBeTruthy(); expect(savedMessage?.text).toBe('Hello, world!'); }); it('should throw an error for unauthenticated user', async () => { mockCtx.userId = null as unknown as string; await expect(saveMessage(mockCtx, mockMessageData)).rejects.toThrow('User not authenticated'); }); it('should handle invalid conversation ID gracefully', async () => { mockMessageData.conversationId = 'invalid-id'; const result = await saveMessage(mockCtx, mockMessageData); expect(result).toBeUndefined(); }); }); describe('updateMessageText', () => { it('should update message text for the authenticated user', async () => { // First save a message await saveMessage(mockCtx, mockMessageData); // Then update it await updateMessageText(mockCtx.userId, { messageId: 'msg123', text: 'Updated text' }); // Verify the update const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); expect(updatedMessage?.text).toBe('Updated text'); }); }); describe('updateMessage', () => { it('should update a message for the authenticated user', async () => { // First save a message await saveMessage(mockCtx, mockMessageData); const result = await updateMessage(mockCtx.userId, { messageId: 'msg123', text: 'Updated text', }); expect(result?.messageId).toBe('msg123'); expect(result?.text).toBe('Updated text'); // Verify in database const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); expect(updatedMessage?.text).toBe('Updated text'); }); it('should throw an error if message is not found', async () => { await expect( updateMessage(mockCtx.userId, { messageId: 'nonexistent', text: 'Test' }), ).rejects.toThrow('Message not found or user not authorized.'); }); }); describe('deleteMessagesSince', () => { it('should delete messages only for the authenticated user', async () => { const conversationId = uuidv4(); // Create multiple messages in the same conversation await saveMessage(mockCtx, { messageId: 'msg1', conversationId, text: 'First message', user: 'user123', }); await saveMessage(mockCtx, { messageId: 'msg2', conversationId, text: 'Second message', user: 'user123', }); await saveMessage(mockCtx, { messageId: 'msg3', conversationId, text: 'Third message', user: 'user123', }); // Delete messages since message2 (this should only delete messages created AFTER msg2) await deleteMessagesSince(mockCtx.userId, { messageId: 'msg2', conversationId, }); // Verify msg1 and msg2 remain, msg3 is deleted const remainingMessages = await Message.find({ conversationId, user: 'user123' }); expect(remainingMessages).toHaveLength(2); expect(remainingMessages.map((m) => m.messageId)).toContain('msg1'); expect(remainingMessages.map((m) => m.messageId)).toContain('msg2'); expect(remainingMessages.map((m) => m.messageId)).not.toContain('msg3'); }); it('should return undefined if no message is found', async () => { const result = await deleteMessagesSince(mockCtx.userId, { messageId: 'nonexistent', conversationId: 'convo123', }); expect(result).toBeUndefined(); }); }); describe('getMessages', () => { it('should retrieve messages with the correct filter', async () => { const conversationId = uuidv4(); // Save some messages await saveMessage(mockCtx, { messageId: 'msg1', conversationId, text: 'First message', user: 'user123', }); await saveMessage(mockCtx, { messageId: 'msg2', conversationId, text: 'Second message', user: 'user123', }); const messages = await getMessages({ conversationId }); expect(messages).toHaveLength(2); expect(messages[0].text).toBe('First message'); expect(messages[1].text).toBe('Second message'); }); }); describe('deleteMessages', () => { it('should delete messages with the correct filter', async () => { // Save some messages for different users await saveMessage(mockCtx, mockMessageData); await saveMessage( { userId: 'user456' }, { messageId: 'msg456', conversationId: uuidv4(), text: 'Other user message', user: 'user456', }, ); await deleteMessages({ user: 'user123' }); // Verify only user123's messages were deleted const user123Messages = await Message.find({ user: 'user123' }); const user456Messages = await Message.find({ user: 'user456' }); expect(user123Messages).toHaveLength(0); expect(user456Messages).toHaveLength(1); }); }); describe('Conversation Hijacking Prevention', () => { it("should not allow editing a message in another user's conversation", async () => { const victimConversationId = uuidv4(); const victimMessageId = 'victim-msg-123'; // First, save a message as the victim (but we'll try to edit as attacker) await saveMessage( { userId: 'victim123' }, { messageId: victimMessageId, conversationId: victimConversationId, text: 'Victim message', user: 'victim123', }, ); // Attacker tries to edit the victim's message await expect( updateMessage('attacker123', { messageId: victimMessageId, conversationId: victimConversationId, text: 'Hacked message', }), ).rejects.toThrow('Message not found or user not authorized.'); // Verify the original message is unchanged const originalMessage = await Message.findOne({ messageId: victimMessageId, user: 'victim123', }); expect(originalMessage?.text).toBe('Victim message'); }); it("should not allow deleting messages from another user's conversation", async () => { const victimConversationId = uuidv4(); const victimMessageId = 'victim-msg-123'; // Save a message as the victim await saveMessage( { userId: 'victim123' }, { messageId: victimMessageId, conversationId: victimConversationId, text: 'Victim message', user: 'victim123', }, ); // Attacker tries to delete from victim's conversation const result = await deleteMessagesSince('attacker123', { messageId: victimMessageId, conversationId: victimConversationId, }); expect(result).toBeUndefined(); // Verify the victim's message still exists const victimMessage = await Message.findOne({ messageId: victimMessageId, user: 'victim123', }); expect(victimMessage).toBeTruthy(); expect(victimMessage?.text).toBe('Victim message'); }); it("should not allow inserting a new message into another user's conversation", async () => { const victimConversationId = uuidv4(); // Attacker tries to save a message - this should succeed but with attacker's user ID const result = await saveMessage( { userId: 'attacker123' }, { conversationId: victimConversationId, text: 'Inserted malicious message', messageId: 'new-msg-123', user: 'attacker123', }, ); expect(result).toBeTruthy(); expect(result?.user).toBe('attacker123'); // Verify the message was saved with the attacker's user ID, not as an anonymous message const savedMessage = await Message.findOne({ messageId: 'new-msg-123' }); expect(savedMessage?.user).toBe('attacker123'); expect(savedMessage?.conversationId).toBe(victimConversationId); }); it('should allow retrieving messages from any conversation', async () => { const victimConversationId = uuidv4(); // Save a message in the victim's conversation await saveMessage( { userId: 'victim123' }, { messageId: 'victim-msg', conversationId: victimConversationId, text: 'Victim message', user: 'victim123', }, ); // Anyone should be able to retrieve messages by conversation ID const messages = await getMessages({ conversationId: victimConversationId }); expect(messages).toHaveLength(1); 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 app config with 24 hour retention mockCtx.interfaceConfig = { temporaryChatRetention: 24 }; mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, 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 ?? 0); 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 () => { mockCtx.isTemporary = false; const result = await saveMessage(mockCtx, 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 set const result = await saveMessage(mockCtx, mockMessageData); expect(result?.messageId).toBe('msg123'); expect(result?.expiredAt).toBeNull(); }); it('should use custom retention period from config', async () => { // Mock app config with 48 hour retention mockCtx.interfaceConfig = { temporaryChatRetention: 48 }; mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, 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 ?? 0); 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 mockCtx.interfaceConfig = { temporaryChatRetention: 0.5 }; // Half hour - should be clamped to 1 hour mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, 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 ?? 0); 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 mockCtx.interfaceConfig = { temporaryChatRetention: 10000 }; // Should be clamped to 8760 hours mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, 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 ?? 0); 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 mockCtx.interfaceConfig; mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, mockMessageData); const afterSave = new Date(); // Should still save the message with default retention period (30 days) expect(result?.messageId).toBe('msg123'); 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 ?? 0); 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 mockCtx.interfaceConfig = undefined; // Empty config mockCtx.isTemporary = true; const beforeSave = new Date(); const result = await saveMessage(mockCtx, 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 ?? 0); 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 mockCtx.interfaceConfig = { temporaryChatRetention: 24 }; mockCtx.isTemporary = true; const savedMessage = await saveMessage(mockCtx, mockMessageData); const originalExpiredAt = savedMessage?.expiredAt; // Now update the message without isTemporary flag mockCtx.isTemporary = undefined; const updatedMessage = await updateMessage(mockCtx.userId, { 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 mockCtx.interfaceConfig = { temporaryChatRetention: 24 }; mockCtx.isTemporary = true; const firstSave = await saveMessage(mockCtx, 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(mockCtx, updatedData); // Should update text but create new expiredAt expect(secondSave?.text).toBe('Updated text'); expect(secondSave?.expiredAt).toBeDefined(); expect(new Date(secondSave?.expiredAt ?? 0).getTime()).toBeGreaterThan( new Date(originalExpiredAt ?? 0).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(); }); }); describe('Message cursor pagination', () => { /** * Helper to create messages with specific timestamps * Uses collection.insertOne to bypass Mongoose timestamps */ const createMessageWithTimestamp = async ( index: number, conversationId: string, createdAt: Date, ) => { const messageId = uuidv4(); await Message.collection.insertOne({ messageId, conversationId, user: 'user123', text: `Message ${index}`, isCreatedByUser: index % 2 === 0, createdAt, updatedAt: createdAt, }); return Message.findOne({ messageId }).lean(); }; /** * Simulates the pagination logic from api/server/routes/messages.js * This tests the exact query pattern used in the route */ const getMessagesByCursor = async ({ conversationId, user, pageSize = 25, cursor = null as string | null, sortBy = 'createdAt', sortDirection = 'desc', }: { conversationId: string; user: string; pageSize?: number; cursor?: string | null; sortBy?: string; sortDirection?: string; }) => { const sortOrder = sortDirection === 'asc' ? 1 : -1; const sortField = ['createdAt', 'updatedAt'].includes(sortBy) ? sortBy : 'createdAt'; const cursorOperator = sortDirection === 'asc' ? '$gt' : '$lt'; const filter: Record = { conversationId, user }; if (cursor) { filter[sortField] = { [cursorOperator]: new Date(cursor) }; } const messages = await Message.find(filter) .sort({ [sortField]: sortOrder }) .limit(pageSize + 1) .lean(); let nextCursor: string | null = null; if (messages.length > pageSize) { messages.pop(); // Remove extra item used to detect next page // Create cursor from the last RETURNED item (not the popped one) nextCursor = (messages[messages.length - 1] as Record)[ sortField ] as string; } return { messages, nextCursor }; }; it('should return messages for a conversation with pagination', async () => { const conversationId = uuidv4(); const baseTime = new Date('2026-01-01T00:00:00.000Z'); // Create 30 messages to test pagination for (let i = 0; i < 30; i++) { const createdAt = new Date(baseTime.getTime() - i * 60000); // Each 1 minute apart await createMessageWithTimestamp(i, conversationId, createdAt); } // Fetch first page (pageSize 25) const page1 = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 25, }); expect(page1.messages).toHaveLength(25); expect(page1.nextCursor).toBeTruthy(); // Fetch second page using cursor const page2 = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 25, cursor: page1.nextCursor, }); // Should get remaining 5 messages expect(page2.messages).toHaveLength(5); expect(page2.nextCursor).toBeNull(); // Verify no duplicates and no gaps const allMessageIds = [ ...page1.messages.map((m) => m.messageId), ...page2.messages.map((m) => m.messageId), ]; const uniqueIds = new Set(allMessageIds); expect(uniqueIds.size).toBe(30); // All 30 messages accounted for expect(allMessageIds.length).toBe(30); // No duplicates }); it('should not skip message at page boundary (item 26 bug fix)', async () => { const conversationId = uuidv4(); const baseTime = new Date('2026-01-01T12:00:00.000Z'); // Create exactly 26 messages const messages: (IMessage | null)[] = []; for (let i = 0; i < 26; i++) { const createdAt = new Date(baseTime.getTime() - i * 60000); const msg = await createMessageWithTimestamp(i, conversationId, createdAt); messages.push(msg); } // The 26th message (index 25) should be on page 2 const item26 = messages[25]; // Fetch first page with pageSize 25 const page1 = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 25, }); expect(page1.messages).toHaveLength(25); expect(page1.nextCursor).toBeTruthy(); // Item 26 should NOT be in page 1 const page1Ids = page1.messages.map((m) => m.messageId); expect(page1Ids).not.toContain(item26!.messageId); // Fetch second page const page2 = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 25, cursor: page1.nextCursor, }); // Item 26 MUST be in page 2 (this was the bug - it was being skipped) expect(page2.messages).toHaveLength(1); expect((page2.messages[0] as { messageId: string }).messageId).toBe(item26!.messageId); }); it('should sort by createdAt DESC by default', async () => { const conversationId = uuidv4(); // Create messages with specific timestamps const msg1 = await createMessageWithTimestamp( 1, conversationId, new Date('2026-01-01T00:00:00.000Z'), ); const msg2 = await createMessageWithTimestamp( 2, conversationId, new Date('2026-01-02T00:00:00.000Z'), ); const msg3 = await createMessageWithTimestamp( 3, conversationId, new Date('2026-01-03T00:00:00.000Z'), ); const result = await getMessagesByCursor({ conversationId, user: 'user123', }); // Should be sorted by createdAt DESC (newest first) by default expect(result?.messages).toHaveLength(3); expect((result?.messages[0] as { messageId: string }).messageId).toBe(msg3!.messageId); expect((result?.messages[1] as { messageId: string }).messageId).toBe(msg2!.messageId); expect((result?.messages[2] as { messageId: string }).messageId).toBe(msg1!.messageId); }); it('should support ascending sort direction', async () => { const conversationId = uuidv4(); const msg1 = await createMessageWithTimestamp( 1, conversationId, new Date('2026-01-01T00:00:00.000Z'), ); const msg2 = await createMessageWithTimestamp( 2, conversationId, new Date('2026-01-02T00:00:00.000Z'), ); const result = await getMessagesByCursor({ conversationId, user: 'user123', sortDirection: 'asc', }); // Should be sorted by createdAt ASC (oldest first) expect(result?.messages).toHaveLength(2); expect((result?.messages[0] as { messageId: string }).messageId).toBe(msg1!.messageId); expect((result?.messages[1] as { messageId: string }).messageId).toBe(msg2!.messageId); }); it('should handle empty conversation', async () => { const conversationId = uuidv4(); const result = await getMessagesByCursor({ conversationId, user: 'user123', }); expect(result?.messages).toHaveLength(0); expect(result?.nextCursor).toBeNull(); }); it('should only return messages for the specified user', async () => { const conversationId = uuidv4(); const createdAt = new Date(); // Create a message for user123 await Message.collection.insertOne({ messageId: uuidv4(), conversationId, user: 'user123', text: 'User message', createdAt, updatedAt: createdAt, }); // Create a message for a different user await Message.collection.insertOne({ messageId: uuidv4(), conversationId, user: 'otherUser', text: 'Other user message', createdAt, updatedAt: createdAt, }); const result = await getMessagesByCursor({ conversationId, user: 'user123', }); // Should only return user123's message expect(result?.messages).toHaveLength(1); expect((result?.messages[0] as { user: string }).user).toBe('user123'); }); it('should handle exactly pageSize number of messages (no next page)', async () => { const conversationId = uuidv4(); const baseTime = new Date('2026-01-01T00:00:00.000Z'); // Create exactly 25 messages (equal to default pageSize) for (let i = 0; i < 25; i++) { const createdAt = new Date(baseTime.getTime() - i * 60000); await createMessageWithTimestamp(i, conversationId, createdAt); } const result = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 25, }); expect(result?.messages).toHaveLength(25); expect(result?.nextCursor).toBeNull(); // No next page }); it('should handle pageSize of 1', async () => { const conversationId = uuidv4(); const baseTime = new Date('2026-01-01T00:00:00.000Z'); // Create 3 messages for (let i = 0; i < 3; i++) { const createdAt = new Date(baseTime.getTime() - i * 60000); await createMessageWithTimestamp(i, conversationId, createdAt); } // Fetch with pageSize 1 let cursor: string | null = null; const allMessages: unknown[] = []; for (let page = 0; page < 5; page++) { const result = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 1, cursor, }); allMessages.push(...(result?.messages ?? [])); cursor = result?.nextCursor; if (!cursor) { break; } } // Should get all 3 messages without duplicates expect(allMessages).toHaveLength(3); const uniqueIds = new Set(allMessages.map((m) => (m as { messageId: string }).messageId)); expect(uniqueIds.size).toBe(3); }); it('should handle messages with same createdAt timestamp', async () => { const conversationId = uuidv4(); const sameTime = new Date('2026-01-01T12:00:00.000Z'); // Create multiple messages with the exact same timestamp const messages: (IMessage | null)[] = []; for (let i = 0; i < 5; i++) { const msg = await createMessageWithTimestamp(i, conversationId, sameTime); messages.push(msg); } const result = await getMessagesByCursor({ conversationId, user: 'user123', pageSize: 10, }); // All messages should be returned expect(result?.messages).toHaveLength(5); }); }); });