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>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user