feat(api): add SearchModule with GET /search endpoint backed by Meilisearch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:28:13 +05:30
parent 6f18433c67
commit 8ad5f737bd
8 changed files with 223 additions and 0 deletions
@@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SearchService } from './search.service';
import * as searchPkg from '@tower/search';
jest.mock('@tower/search', () => ({
createMeiliClient: jest.fn(),
configureIndex: jest.fn().mockResolvedValue(undefined),
MESSAGES_INDEX: 'tower-messages',
}));
describe('SearchService', () => {
let service: SearchService;
const mockSearch = jest.fn();
const mockIndex = jest.fn().mockReturnValue({ search: mockSearch });
const mockClient = { index: mockIndex };
beforeEach(async () => {
jest.clearAllMocks();
(searchPkg.createMeiliClient as jest.Mock).mockReturnValue(mockClient);
const module: TestingModule = await Test.createTestingModule({
providers: [
SearchService,
{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('') } },
],
}).compile();
service = module.get<SearchService>(SearchService);
await service.onModuleInit();
});
it('calls configureIndex on init', () => {
expect(searchPkg.configureIndex).toHaveBeenCalledWith(mockClient);
});
it('returns hits and total', async () => {
mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 });
const result = await service.search('hello');
expect(result.hits).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.query).toBe('hello');
});
it('searches with no filter when no groupId or tags', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('test');
expect(mockSearch).toHaveBeenCalledWith('test', expect.objectContaining({ filter: undefined }));
});
it('applies sourceGroupId filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'sourceGroupId = "grp-1"' }),
);
});
it('applies tags filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', undefined, ['#important']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'tags = "#important"' }),
);
});
it('combines groupId and tags filters with AND', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1', ['#important', '#event']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({
filter: 'sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
}),
);
});
it('defaults page to 1 and hitsPerPage to 20', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ page: 1, hitsPerPage: 20 }),
);
});
});