import { BadRequestException, NotImplementedException } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; import { SearchQueryDto } from './dto/search-query.dto'; import type { ApiKey } from '../auth/entities/api-key.entity'; import type { SearchResults } from './search.types'; describe('SearchController', () => { const search = jest.fn(); const ctrl = new SearchController({ search } as unknown as SearchService); const ok = { hits: [], total: 0, tookMs: 1, provider: 'builtin-fts' } satisfies SearchResults; beforeEach(() => { search.mockReset(); }); it('throws 400 when q is empty', async () => { const dto: SearchQueryDto = { q: '' }; await expect(ctrl.search(dto, undefined)).rejects.toBeInstanceOf(BadRequestException); }); it('throws 400 when q is only whitespace', async () => { // @IsNotEmpty() on the DTO passes for ' ' (non-empty), so the controller's own trim guard is // what rejects whitespace-only q — this test pins that guard in place. const dto: SearchQueryDto = { q: ' ' }; await expect(ctrl.search(dto, undefined)).rejects.toBeInstanceOf(BadRequestException); expect(search).not.toHaveBeenCalled(); }); it('returns results and forwards the key allowedSessions as callerSessionIds', async () => { search.mockResolvedValue(ok); const apiKey = { allowedSessions: ['s1', 's2'] } as unknown as ApiKey; const dto: SearchQueryDto = { q: 'hello', limit: 5 }; const res = await ctrl.search(dto, apiKey); expect(search).toHaveBeenCalledWith(dto, ['s1', 's2']); expect(res).toBe(ok); }); it('passes undefined (no scope) for an unrestricted key (null allowedSessions)', async () => { search.mockResolvedValue(ok); // A null/empty allowlist (e.g. ADMIN) sees all sessions — mirrors GET /webhooks behavior. const apiKey = { allowedSessions: null } as unknown as ApiKey; const dto: SearchQueryDto = { q: 'hello' }; await ctrl.search(dto, apiKey); expect(search).toHaveBeenCalledWith(dto, undefined); }); it('derives callerSessionIds only from the key, never from the query (anti-smuggling)', async () => { search.mockResolvedValue(ok); // `sessionIds` is not a field on SearchQueryDto, so it cannot be expressed in the query at all; // the global ValidationPipe (forbidNonWhitelisted) would reject it, and SearchService clobbers // any sessionIds at the provider boundary. The controller itself derives scope solely from the // key — here there is no key → undefined. const dto: SearchQueryDto = { q: 'hello' }; await ctrl.search(dto, undefined); expect(search).toHaveBeenCalledWith(dto, undefined); }); it('propagates 501 (NotImplementedException) from the service when no provider is active', async () => { search.mockRejectedValue(new NotImplementedException('none')); await expect(ctrl.search({ q: 'x' }, undefined)).rejects.toBeInstanceOf(NotImplementedException); }); });