Files
fluxer/fluxer_api/src/api/search/meilisearch/MeilisearchIndexAdapter.test.ts
T

190 lines
6.1 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
import {MeilisearchMessageAdapter} from '@app/api/search/meilisearch/MeilisearchDomainAdapters';
import {MEILISEARCH_MAX_TRACKED_BULK_TASKS} from '@app/api/search/meilisearch/MeilisearchIndexAdapter';
import type {SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import {describe, expect, it} from 'vitest';
interface RecordedMeilisearchRequest {
method: string;
path: string;
body: unknown;
}
class FakeMeilisearchClient implements MeilisearchClient {
readonly requests: Array<RecordedMeilisearchRequest> = [];
readonly waitedTaskUids: Array<number> = [];
private nextTaskUid = 1;
indexExists = false;
async request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse> {
this.requests.push({method, path, body});
if (method === 'GET' && path.startsWith('/indexes/')) {
if (!this.indexExists) {
throw new Error('404 index_not_found');
}
return {uid: path.slice('/indexes/'.length)} as TResponse;
}
if (method === 'POST' && path === '/indexes') {
this.indexExists = true;
return this.nextTask() as TResponse;
}
if ((method === 'PUT' || method === 'PATCH') && path.includes('/settings/')) {
return this.nextTask() as TResponse;
}
if (method === 'POST' && path.endsWith('/documents')) {
return this.nextTask() as TResponse;
}
if (method === 'POST' && path.endsWith('/documents/delete-batch')) {
return this.nextTask() as TResponse;
}
if (method === 'POST' && path.endsWith('/search')) {
return {
hits: [{id: 'message-1'}],
estimatedTotalHits: 1,
} as TResponse;
}
throw new Error(`Unhandled fake Meilisearch request: ${method} ${path}`);
}
async waitForTask(taskUid: number): Promise<void> {
this.waitedTaskUids.push(taskUid);
}
clear(): void {
this.requests.length = 0;
this.waitedTaskUids.length = 0;
}
private nextTask(): MeilisearchTask {
return {
taskUid: this.nextTaskUid++,
status: 'enqueued',
};
}
}
describe('MeilisearchMessageAdapter', () => {
it('creates missing indexes and applies settings before becoming available', async () => {
const client = new FakeMeilisearchClient();
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
expect(adapter.isAvailable()).toBe(true);
expect(client.requests.map((request) => `${request.method} ${request.path}`)).toEqual([
'GET /indexes/messages',
'POST /indexes',
'PUT /indexes/messages/settings/searchable-attributes',
'PUT /indexes/messages/settings/filterable-attributes',
'PUT /indexes/messages/settings/sortable-attributes',
'PATCH /indexes/messages/settings/pagination',
]);
expect(client.waitedTaskUids).toEqual([1, 2, 3, 4, 5]);
expect(client.requests.find((request) => request.path.endsWith('/settings/pagination'))?.body).toEqual({
maxTotalHits: 10000,
});
});
it('builds Meilisearch search requests from message filters', async () => {
const client = new FakeMeilisearchClient();
client.indexExists = true;
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
client.clear();
const result = await adapter.search(
'hello',
{
guildId: 'guild-1',
channelIds: ['channel-"quoted"', 'channel-2'],
mentions: ['user-1'],
sortBy: 'timestamp',
sortOrder: 'asc',
},
{limit: 10, offset: 20},
);
expect(result).toEqual({hits: [{id: 'message-1'}], total: 1});
expect(client.requests).toHaveLength(1);
expect(client.requests[0]).toEqual({
method: 'POST',
path: '/indexes/messages/search',
body: {
q: 'hello',
filter:
'(guildId = "guild-1") AND ((channelId = "channel-\\"quoted\\"" OR channelId = "channel-2")) AND (mentionedUserIds = "user-1")',
limit: 10,
offset: 20,
sort: ['createdAt:asc', 'id:asc'],
attributesToSearchOn: ['content', 'embedContent'],
showRankingScore: false,
},
});
});
it('waits for queued bulk index tasks when refreshed', async () => {
const client = new FakeMeilisearchClient();
client.indexExists = true;
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
client.clear();
await adapter.bulkIndexDocuments([{id: 'message-1'} as SearchableMessage]);
expect(client.waitedTaskUids).toEqual([]);
await adapter.refreshIndex();
expect(client.waitedTaskUids).toEqual([5]);
});
it('does not retain task state for per-document writes', async () => {
const client = new FakeMeilisearchClient();
client.indexExists = true;
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
client.clear();
for (let index = 0; index < 500; index++) {
await adapter.indexDocument({id: `message-${index}`} as SearchableMessage);
await adapter.updateDocument({id: `message-${index}`} as SearchableMessage);
await adapter.deleteDocument(`message-${index}`);
}
expect(client.requests).toHaveLength(1500);
await adapter.refreshIndex();
expect(client.waitedTaskUids).toEqual([]);
});
it('bounds the tracked bulk task set', async () => {
const client = new FakeMeilisearchClient();
client.indexExists = true;
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
client.clear();
const batches = MEILISEARCH_MAX_TRACKED_BULK_TASKS + 100;
for (let index = 0; index < batches; index++) {
await adapter.bulkIndexDocuments([{id: `message-${index}`} as SearchableMessage]);
}
await adapter.refreshIndex();
expect(client.waitedTaskUids).toHaveLength(MEILISEARCH_MAX_TRACKED_BULK_TASKS);
expect(client.waitedTaskUids[0]).toBe(105);
});
it('drops tracked bulk tasks on shutdown', async () => {
const client = new FakeMeilisearchClient();
client.indexExists = true;
const adapter = new MeilisearchMessageAdapter({client});
await adapter.initialize();
client.clear();
await adapter.bulkIndexDocuments([{id: 'message-1'} as SearchableMessage]);
await adapter.shutdown();
await adapter.refreshIndex();
expect(client.waitedTaskUids).toEqual([]);
});
});