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); await service.onModuleInit(); }); it('calls configureIndex on init', () => { expect(searchPkg.configureIndex).toHaveBeenCalledWith(mockClient); }); it('always filters by tenantId', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'test'); expect(mockSearch).toHaveBeenCalledWith( 'test', expect.objectContaining({ filter: 'tenantId = "tnt-1"' }), ); }); it('returns hits and total', async () => { mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 }); const result = await service.search('tnt-1', 'hello'); expect(result.hits).toHaveLength(1); expect(result.total).toBe(1); expect(result.query).toBe('hello'); }); it('applies sourceGroupId filter alongside tenant', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'hello', 'grp-1'); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp-1"' }), ); }); it('applies tags filter alongside tenant', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'hello', undefined, ['#important']); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ filter: 'tenantId = "tnt-1" AND tags = "#important"' }), ); }); it('combines groupId and tags filters with AND, all behind tenant', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'hello', 'grp-1', ['#important', '#event']); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ filter: 'tenantId = "tnt-1" AND 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('tnt-1', 'hello'); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ page: 1, hitsPerPage: 20 }), ); }); it('escapes double-quotes in filter values to prevent injection', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'hello', 'grp"1"OR id EXISTS'); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp\\"1\\"OR id EXISTS"', }), ); }); it('clamps page to min 1 and limit to max 100', async () => { mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); await service.search('tnt-1', 'hello', undefined, undefined, 0, 999); expect(mockSearch).toHaveBeenCalledWith( 'hello', expect.objectContaining({ page: 1, hitsPerPage: 100 }), ); }); });