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:
@@ -16,6 +16,7 @@
|
||||
"@prisma/client": "^6.0.0",
|
||||
"@tower/config": "workspace:*",
|
||||
"@tower/logger": "workspace:*",
|
||||
"@tower/search": "workspace:*",
|
||||
"@tower/types": "workspace:*",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { SearchModule } from './modules/search/search.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
SearchModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
const mockSearchService = { search: jest.fn() };
|
||||
|
||||
describe('SearchController', () => {
|
||||
let controller: SearchController;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SearchController],
|
||||
providers: [{ provide: SearchService, useValue: mockSearchService }],
|
||||
}).compile();
|
||||
controller = module.get<SearchController>(SearchController);
|
||||
});
|
||||
|
||||
it('calls service with all parsed params', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' });
|
||||
await controller.search('hello', 'grp-1', 'important,event', '2', '10');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('hello', 'grp-1', ['important', 'event'], 2, 10);
|
||||
});
|
||||
|
||||
it('defaults page to 1 and limit to 20 when not provided', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
|
||||
await controller.search('');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, undefined, 1, 20);
|
||||
});
|
||||
|
||||
it('returns the service result directly', async () => {
|
||||
const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' };
|
||||
mockSearchService.search.mockResolvedValue(expected);
|
||||
const result = await controller.search('test');
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('splits tags on comma and trims whitespace', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
|
||||
await controller.search('', undefined, ' important , event ');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, ['important', 'event'], 1, 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get()
|
||||
search(
|
||||
@Query('q') q = '',
|
||||
@Query('groupId') groupId?: string,
|
||||
@Query('tags') tags?: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('limit') limit = '20',
|
||||
) {
|
||||
const tagList = tags
|
||||
? tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
return this.searchService.search(q, groupId, tagList, Number(page), Number(limit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MeiliSearch,
|
||||
MeiliDocument,
|
||||
MESSAGES_INDEX,
|
||||
createMeiliClient,
|
||||
configureIndex,
|
||||
} from '@tower/search';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService implements OnModuleInit {
|
||||
private readonly client: MeiliSearch;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.client = createMeiliClient(
|
||||
this.config.get('MEILI_URL', 'http://localhost:7700'),
|
||||
this.config.get('MEILI_MASTER_KEY', ''),
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await configureIndex(this.client).catch((err) =>
|
||||
console.warn('Failed to configure Meilisearch index:', err),
|
||||
);
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
groupId?: string,
|
||||
tags?: string[],
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ hits: MeiliDocument[]; total: number; page: number; limit: number; query: string }> {
|
||||
const filters: string[] = [];
|
||||
if (groupId) filters.push(`sourceGroupId = "${groupId}"`);
|
||||
if (tags?.length) filters.push(...tags.map((t) => `tags = "${t}"`));
|
||||
|
||||
const result = await this.client.index(MESSAGES_INDEX).search(query, {
|
||||
filter: filters.length ? filters.join(' AND ') : undefined,
|
||||
page,
|
||||
hitsPerPage: limit,
|
||||
sort: ['approvedAt:desc'],
|
||||
});
|
||||
|
||||
return {
|
||||
hits: result.hits as MeiliDocument[],
|
||||
total: result.totalHits ?? 0,
|
||||
page,
|
||||
limit,
|
||||
query,
|
||||
};
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -44,6 +44,9 @@ importers:
|
||||
'@tower/logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/logger
|
||||
'@tower/search':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/search
|
||||
'@tower/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
|
||||
Reference in New Issue
Block a user