Files
iios/packages/iios-service/src/search/search.service.spec.ts
T
maaz519 8af25def83 feat(search): Meilisearch message search — indexer + permission-scoped query
New search module: a message index (Meilisearch, MEILI_URL-gated; no-op when unset),
a SearchProjector that indexes on message.sent, and a SearchService whose permission
fence runs server-side — a query only touches the caller's own scope AND the threads
they currently belong to (resolved live from participant rows, so membership changes
reflect immediately). Every conversation kind (chat/mail/sms) is a message with a TEXT
part, so all are searchable through one index. POST /v1/search + /reindex (backfill).
5 fence tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30

58 lines
2.7 KiB
TypeScript

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: '<em>hello</em>' }] });
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<typeof vi.fn>).mock.calls[0]![3]).toBe(50);
});
});