import { describe, it, expect, vi } from 'vitest'; import { SearchService } from './search.service'; import type { MessageSearchPort, SearchHit } from './message-search.port'; import type { ActorResolver, MessagePrincipal } from '../identity/actor.resolver'; import type { PrismaService } from '../prisma/prisma.service'; const principal: MessagePrincipal = { userId: 'u1', orgId: 'org', appId: 'app' }; function make(opts: { ready?: boolean; threadIds?: string[]; hits?: SearchHit[]; scope?: { id: string } | null } = {}) { const engine: MessageSearchPort = { ready: () => opts.ready ?? true, index: vi.fn(async () => undefined), remove: vi.fn(async () => undefined), search: vi.fn(async () => opts.hits ?? []), }; const actors = { findScope: vi.fn(async () => (opts.scope === undefined ? { id: 'scope_1' } : opts.scope)), resolveActor: vi.fn(async () => ({ id: 'actor_1' })), } as unknown as ActorResolver; const prisma = { iiosThreadParticipant: { findMany: vi.fn(async () => (opts.threadIds ?? ['t1', 't2']).map((threadId) => ({ threadId }))) }, } as unknown as PrismaService; return { svc: new SearchService(engine, prisma, actors), engine }; } describe('SearchService (permission fence)', () => { it('searches only within the caller scope + their thread ids', async () => { const { svc, engine } = make({ threadIds: ['t1', 't2', 't3'], hits: [{ id: 'i1', threadId: 't2', text: 'hello world', subject: 'General', source: 'crm-messenger', at: 1, snippet: 'hello' }] }); const res = await svc.search(principal, { query: 'hello' }); expect(res[0]!.id).toBe('i1'); expect(engine.search).toHaveBeenCalledWith('scope_1', ['t1', 't2', 't3'], 'hello', 20); }); it('returns empty (no engine hit) for a blank query', async () => { const { svc, engine } = make(); expect(await svc.search(principal, { query: ' ' })).toEqual([]); expect(engine.search).not.toHaveBeenCalled(); }); it('returns empty when the caller belongs to no threads', async () => { const { svc, engine } = make({ threadIds: [] }); expect(await svc.search(principal, { query: 'hello' })).toEqual([]); expect(engine.search).not.toHaveBeenCalled(); }); it('returns empty when search is not configured (no-op engine)', async () => { const { svc, engine } = make({ ready: false }); expect(await svc.search(principal, { query: 'hello' })).toEqual([]); expect(engine.search).not.toHaveBeenCalled(); }); it('caps the limit at 50', async () => { const { svc, engine } = make(); await svc.search(principal, { query: 'x', limit: 999 }); expect((engine.search as ReturnType).mock.calls[0]![3]).toBe(50); }); });