2025-12-19 09:00:41 -06:00
|
|
|
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
|
|
|
import mongoose from 'mongoose';
|
|
|
|
|
import { EModelEndpoint } from 'librechat-data-provider';
|
|
|
|
|
import { createConversationModel } from '~/models/convo';
|
|
|
|
|
import { createMessageModel } from '~/models/message';
|
|
|
|
|
import { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
|
|
|
|
|
|
|
|
|
|
const mockAddDocuments = jest.fn();
|
2026-01-16 16:30:00 +01:00
|
|
|
const mockAddDocumentsInBatches = jest.fn();
|
|
|
|
|
const mockUpdateDocuments = jest.fn();
|
|
|
|
|
const mockDeleteDocument = jest.fn();
|
|
|
|
|
const mockDeleteDocuments = jest.fn();
|
|
|
|
|
const mockGetDocument = jest.fn();
|
2025-12-19 09:00:41 -06:00
|
|
|
const mockIndex = jest.fn().mockReturnValue({
|
|
|
|
|
getRawInfo: jest.fn(),
|
|
|
|
|
updateSettings: jest.fn(),
|
|
|
|
|
addDocuments: mockAddDocuments,
|
2026-01-16 16:30:00 +01:00
|
|
|
addDocumentsInBatches: mockAddDocumentsInBatches,
|
|
|
|
|
updateDocuments: mockUpdateDocuments,
|
|
|
|
|
deleteDocument: mockDeleteDocument,
|
|
|
|
|
deleteDocuments: mockDeleteDocuments,
|
|
|
|
|
getDocument: mockGetDocument,
|
2025-12-19 09:00:41 -06:00
|
|
|
getDocuments: jest.fn().mockReturnValue({ results: [] }),
|
|
|
|
|
});
|
|
|
|
|
jest.mock('meilisearch', () => {
|
|
|
|
|
return {
|
|
|
|
|
MeiliSearch: jest.fn().mockImplementation(() => {
|
|
|
|
|
return {
|
|
|
|
|
index: mockIndex,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Meilisearch Mongoose plugin', () => {
|
|
|
|
|
const OLD_ENV = process.env;
|
|
|
|
|
|
|
|
|
|
let mongoServer: MongoMemoryServer;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
process.env = {
|
|
|
|
|
...OLD_ENV,
|
|
|
|
|
// Set a fake meilisearch host/key so that we activate the meilisearch plugin
|
|
|
|
|
MEILI_HOST: 'foo',
|
|
|
|
|
MEILI_MASTER_KEY: 'bar',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mongoServer = await MongoMemoryServer.create();
|
|
|
|
|
const mongoUri = mongoServer.getUri();
|
|
|
|
|
await mongoose.connect(mongoUri);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockAddDocuments.mockClear();
|
2026-01-16 16:30:00 +01:00
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
mockUpdateDocuments.mockClear();
|
|
|
|
|
mockDeleteDocument.mockClear();
|
|
|
|
|
mockDeleteDocuments.mockClear();
|
|
|
|
|
mockGetDocument.mockClear();
|
2025-12-19 09:00:41 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await mongoose.disconnect();
|
|
|
|
|
await mongoServer.stop();
|
|
|
|
|
|
|
|
|
|
process.env = OLD_ENV;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('saving conversation indexes w/ meilisearch', async () => {
|
|
|
|
|
await createConversationModel(mongoose).create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-30 17:22:39 +01:00
|
|
|
test('saving conversation indexes with expiredAt=null w/ meilisearch', async () => {
|
|
|
|
|
await createConversationModel(mongoose).create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 09:00:41 -06:00
|
|
|
test('saving TTL conversation does NOT index w/ meilisearch', async () => {
|
|
|
|
|
await createConversationModel(mongoose).create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('saving messages indexes w/ meilisearch', async () => {
|
|
|
|
|
await createMessageModel(mongoose).create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-30 17:22:39 +01:00
|
|
|
test('saving messages with expiredAt=null indexes w/ meilisearch', async () => {
|
|
|
|
|
await createMessageModel(mongoose).create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 09:00:41 -06:00
|
|
|
test('saving TTL messages does NOT index w/ meilisearch', async () => {
|
|
|
|
|
await createMessageModel(mongoose).create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
expect(mockAddDocuments).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('sync w/ meili does not include TTL documents', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.syncWithMeili();
|
|
|
|
|
|
|
|
|
|
expect(mockAddDocuments).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-01-13 17:49:02 +01:00
|
|
|
|
|
|
|
|
describe('estimatedDocumentCount usage in syncWithMeili', () => {
|
|
|
|
|
test('syncWithMeili completes successfully with estimatedDocumentCount', async () => {
|
|
|
|
|
// Clear any previous documents
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Create test documents
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation 1',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation 2',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Trigger sync - should use estimatedDocumentCount internally
|
|
|
|
|
await expect(conversationModel.syncWithMeili()).resolves.not.toThrow();
|
|
|
|
|
|
|
|
|
|
// Verify documents were processed
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('syncWithMeili handles empty collection correctly', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await messageModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Verify collection is empty
|
|
|
|
|
const count = await messageModel.estimatedDocumentCount();
|
|
|
|
|
expect(count).toBe(0);
|
|
|
|
|
|
|
|
|
|
// Sync should complete without error even with 0 estimated documents
|
|
|
|
|
await expect(messageModel.syncWithMeili()).resolves.not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('estimatedDocumentCount returns count for non-empty collection', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Create documents
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test 1',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test 2',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const estimatedCount = await conversationModel.estimatedDocumentCount();
|
|
|
|
|
expect(estimatedCount).toBeGreaterThanOrEqual(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('estimatedDocumentCount is available on model', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
|
|
|
|
|
// Verify the method exists and is callable
|
|
|
|
|
expect(typeof messageModel.estimatedDocumentCount).toBe('function');
|
|
|
|
|
|
|
|
|
|
// Should be able to call it
|
|
|
|
|
const result = await messageModel.estimatedDocumentCount();
|
|
|
|
|
expect(typeof result).toBe('number');
|
|
|
|
|
expect(result).toBeGreaterThanOrEqual(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('syncWithMeili handles mix of syncable and TTL documents correctly', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await messageModel.deleteMany({});
|
|
|
|
|
mockAddDocuments.mockClear();
|
|
|
|
|
|
|
|
|
|
// Create syncable documents (expiredAt: null)
|
|
|
|
|
await messageModel.create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await messageModel.create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create TTL documents (expiredAt set to a date)
|
|
|
|
|
await messageModel.create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await messageModel.create({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: false,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// estimatedDocumentCount should count all documents (both syncable and TTL)
|
|
|
|
|
const estimatedCount = await messageModel.estimatedDocumentCount();
|
|
|
|
|
expect(estimatedCount).toBe(4);
|
|
|
|
|
|
|
|
|
|
// Actual syncable documents (expiredAt: null)
|
|
|
|
|
const syncableCount = await messageModel.countDocuments({ expiredAt: null });
|
|
|
|
|
expect(syncableCount).toBe(2);
|
|
|
|
|
|
|
|
|
|
// Sync should complete successfully even though estimated count is higher than processed count
|
|
|
|
|
await expect(messageModel.syncWithMeili()).resolves.not.toThrow();
|
|
|
|
|
|
|
|
|
|
// Only syncable documents should be indexed (2 documents, not 4)
|
|
|
|
|
// The mock should be called once per batch, and we have 2 documents
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Verify that only 2 documents were indexed (the syncable ones)
|
|
|
|
|
const indexedCount = await messageModel.countDocuments({ _meiliIndex: true });
|
|
|
|
|
expect(indexedCount).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-16 16:30:00 +01:00
|
|
|
|
|
|
|
|
describe('New batch processing and retry functionality', () => {
|
|
|
|
|
test('processSyncBatch uses addDocumentsInBatches', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
mockAddDocuments.mockClear();
|
|
|
|
|
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Run sync which should call processSyncBatch internally
|
|
|
|
|
await conversationModel.syncWithMeili();
|
|
|
|
|
|
|
|
|
|
// Verify addDocumentsInBatches was called (new batch method)
|
|
|
|
|
expect(mockAddDocumentsInBatches).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('addObjectToMeili retries on failure', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
|
|
|
|
|
// Mock addDocuments to fail twice then succeed
|
|
|
|
|
mockAddDocuments
|
|
|
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
|
.mockResolvedValueOnce({});
|
|
|
|
|
|
|
|
|
|
// Create a document which triggers addObjectToMeili
|
|
|
|
|
await conversationModel.create({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Retry',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Wait for async operations to complete
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
|
|
|
|
|
|
// Verify addDocuments was called multiple times due to retries
|
|
|
|
|
expect(mockAddDocuments).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getSyncProgress returns accurate progress information', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Insert documents directly to control the _meiliIndex flag
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Indexed',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Not Indexed',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const progress = await conversationModel.getSyncProgress();
|
|
|
|
|
|
|
|
|
|
expect(progress.totalDocuments).toBe(2);
|
|
|
|
|
expect(progress.totalProcessed).toBe(1);
|
|
|
|
|
expect(progress.isComplete).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getSyncProgress excludes TTL documents from counts', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// Insert syncable documents (expiredAt: null)
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Syncable Indexed',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Syncable Not Indexed',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Insert TTL documents (expiredAt set) - these should NOT be counted
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'TTL Document 1',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: true,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'TTL Document 2',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const progress = await conversationModel.getSyncProgress();
|
|
|
|
|
|
|
|
|
|
// Only syncable documents should be counted (2 total, 1 indexed)
|
|
|
|
|
expect(progress.totalDocuments).toBe(2);
|
|
|
|
|
expect(progress.totalProcessed).toBe(1);
|
|
|
|
|
expect(progress.isComplete).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getSyncProgress shows completion when all syncable documents are indexed', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await messageModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
// All syncable documents are indexed
|
|
|
|
|
await messageModel.collection.insertOne({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
_meiliIndex: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await messageModel.collection.insertOne({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: false,
|
|
|
|
|
_meiliIndex: true,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add TTL document - should not affect completion status
|
|
|
|
|
await messageModel.collection.insertOne({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const progress = await messageModel.getSyncProgress();
|
|
|
|
|
|
|
|
|
|
expect(progress.totalDocuments).toBe(2);
|
|
|
|
|
expect(progress.totalProcessed).toBe(2);
|
|
|
|
|
expect(progress.isComplete).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Error handling in processSyncBatch', () => {
|
|
|
|
|
test('syncWithMeili fails when processSyncBatch encounters addDocumentsInBatches error', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
|
|
|
|
|
// Insert a document to sync
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock addDocumentsInBatches to fail
|
|
|
|
|
mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('MeiliSearch connection error'));
|
|
|
|
|
|
|
|
|
|
// Sync should throw the error
|
|
|
|
|
await expect(conversationModel.syncWithMeili()).rejects.toThrow(
|
|
|
|
|
'MeiliSearch connection error',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify the error was logged
|
|
|
|
|
expect(mockAddDocumentsInBatches).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Document should NOT be marked as indexed since sync failed
|
|
|
|
|
// Note: direct collection.insertOne doesn't set default values, so _meiliIndex may be undefined
|
|
|
|
|
const doc = await conversationModel.findOne({});
|
|
|
|
|
expect(doc?._meiliIndex).not.toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('syncWithMeili fails when processSyncBatch encounters updateMany error', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
|
|
|
|
|
// Insert a document
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test Conversation',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock addDocumentsInBatches to succeed but simulate updateMany failure
|
|
|
|
|
mockAddDocumentsInBatches.mockResolvedValueOnce({});
|
|
|
|
|
|
|
|
|
|
// Spy on updateMany and make it fail
|
|
|
|
|
const updateManySpy = jest
|
|
|
|
|
.spyOn(conversationModel, 'updateMany')
|
|
|
|
|
.mockRejectedValueOnce(new Error('Database connection error'));
|
|
|
|
|
|
|
|
|
|
// Sync should throw the error
|
|
|
|
|
await expect(conversationModel.syncWithMeili()).rejects.toThrow('Database connection error');
|
|
|
|
|
|
|
|
|
|
expect(updateManySpy).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Restore original implementation
|
|
|
|
|
updateManySpy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('processSyncBatch logs error and throws when addDocumentsInBatches fails', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await messageModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('Network timeout'));
|
|
|
|
|
|
|
|
|
|
await messageModel.collection.insertOne({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const indexMock = mockIndex();
|
|
|
|
|
const documents = await messageModel.find({ _meiliIndex: false }).lean();
|
|
|
|
|
|
|
|
|
|
// Should throw the error
|
|
|
|
|
await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow(
|
|
|
|
|
'Network timeout',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(mockAddDocumentsInBatches).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('processSyncBatch handles empty document array gracefully', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
const indexMock = mockIndex();
|
|
|
|
|
|
|
|
|
|
// Should not throw with empty array
|
|
|
|
|
await expect(conversationModel.processSyncBatch(indexMock, [])).resolves.not.toThrow();
|
|
|
|
|
|
|
|
|
|
// Should not call addDocumentsInBatches
|
|
|
|
|
expect(mockAddDocumentsInBatches).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('syncWithMeili stops processing when batch fails and does not process remaining documents', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
|
|
|
|
|
// Create multiple documents
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: `Test Conversation ${i}`,
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mock addDocumentsInBatches to fail on first call
|
|
|
|
|
mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('First batch failed'));
|
|
|
|
|
|
|
|
|
|
// Sync should fail on the first batch
|
|
|
|
|
await expect(conversationModel.syncWithMeili()).rejects.toThrow('First batch failed');
|
|
|
|
|
|
|
|
|
|
// Should have attempted only once before failing
|
|
|
|
|
expect(mockAddDocumentsInBatches).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
// No documents should be indexed since sync failed
|
|
|
|
|
const indexedCount = await conversationModel.countDocuments({ _meiliIndex: true });
|
|
|
|
|
expect(indexedCount).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('error in processSyncBatch is properly logged before being thrown', async () => {
|
|
|
|
|
const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await messageModel.deleteMany({});
|
|
|
|
|
|
|
|
|
|
const testError = new Error('Test error for logging');
|
|
|
|
|
mockAddDocumentsInBatches.mockRejectedValueOnce(testError);
|
|
|
|
|
|
|
|
|
|
await messageModel.collection.insertOne({
|
|
|
|
|
messageId: new mongoose.Types.ObjectId(),
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
isCreatedByUser: true,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const indexMock = mockIndex();
|
|
|
|
|
const documents = await messageModel.find({ _meiliIndex: false }).lean();
|
|
|
|
|
|
|
|
|
|
// Should throw the same error that was passed to it
|
|
|
|
|
await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow(testError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('syncWithMeili properly propagates processSyncBatch errors', async () => {
|
|
|
|
|
const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods;
|
|
|
|
|
await conversationModel.deleteMany({});
|
|
|
|
|
mockAddDocumentsInBatches.mockClear();
|
|
|
|
|
|
|
|
|
|
await conversationModel.collection.insertOne({
|
|
|
|
|
conversationId: new mongoose.Types.ObjectId(),
|
|
|
|
|
user: new mongoose.Types.ObjectId(),
|
|
|
|
|
title: 'Test',
|
|
|
|
|
endpoint: EModelEndpoint.openAI,
|
|
|
|
|
_meiliIndex: false,
|
|
|
|
|
expiredAt: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const customError = new Error('Custom sync error');
|
|
|
|
|
mockAddDocumentsInBatches.mockRejectedValueOnce(customError);
|
|
|
|
|
|
|
|
|
|
// The error should propagate all the way up
|
|
|
|
|
await expect(conversationModel.syncWithMeili()).rejects.toThrow('Custom sync error');
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-12-19 09:00:41 -06:00
|
|
|
});
|