/** * SearchService unit tests (TASK-0097 S3) — Docker-free. * * Strategy: a REAL `SearchClient` (from `@factory/search`) is constructed * with an injectable `fetchImpl` that returns canned OpenSearch responses * (mirrors the pattern in packages/search/src/client.test.ts and the api's * common/__tests__). `prisma` + `redis` are fakes. The client is swapped in * via `setClientForTesting`, so no env / network is needed. * * Covers: * (a) caller-scope resolver builds workspaceIds + teamIds from prisma * (b) fail-closed: unknown workspaceId → 404; empty membership → empty, NO query * (c) 503: fetch throws → SEARCH_UNAVAILABLE, no backend detail leaked * (d) response envelope mapping (data/nextCursor/tookMs) + `q` + limit clamp 50 * (e) suggest maps prefix hits */ import { describe, expect, it } from "vitest"; import { SearchClient } from "@factory/search"; import type { SearchDoc, SearchType, ViewerContext } from "@factory/search"; import { SearchService } from "../search.service.js"; import { ApiError } from "../../../common/errors.js"; /* --------------------------------------------------------------- fakes */ interface Call { url: string; init: RequestInit; } /** Fake fetch returning scripted responses per call (records every call). */ function fakeFetch(responses: Array<() => unknown> | (() => Promise)) { const calls: Call[] = []; const fn = (async (url: string, init?: RequestInit) => { calls.push({ url, init: init as RequestInit }); if (typeof responses === "function") return responses(); // always throws const next = responses.shift(); if (!next) throw new Error("no more scripted responses"); const value = next(); return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" }, }); }) as typeof fetch; return { fn, calls }; } function doc( partial: Partial & Pick, ): SearchDoc { return { body: "the body", grants: { public: false, workspace: true, user: false, teams: [], users: [] }, archived: false, url: `/w/${partial.workspaceId}/p/${partial.id}`, createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-06-01T00:00:00Z", ...partial, }; } interface PrismaFake { workspaceMember: { findMany: (a: unknown) => Promise<{ workspaceId: string }[]> }; teamMember: { findMany: (a: unknown) => Promise<{ teamId: string }[]> }; workspace: { findMany: (a: unknown) => Promise<{ id: string }[]> }; } function makePrisma(opts: { memberships?: string[]; // workspaceIds the user belongs to activeWorkspaces?: string[]; // non-deleted workspaces (default = memberships) teamIds?: string[]; }): PrismaFake { const memberships = opts.memberships ?? []; const active = opts.activeWorkspaces ?? memberships; const teamIds = opts.teamIds ?? []; return { workspaceMember: { findMany: async () => memberships.map((workspaceId) => ({ workspaceId })) }, teamMember: { findMany: async () => teamIds.map((teamId) => ({ teamId })) }, workspace: { findMany: async () => active.map((id) => ({ id })) }, }; } /** Fake ioredis-shaped client (only the calls the service uses). */ function makeRedis() { const calls: string[] = []; return { calls, getRedis: () => ({ lrem: async (...a: unknown[]) => (calls.push("lrem"), 1), lpush: async (...a: unknown[]) => (calls.push("lpush"), 1), ltrim: async (...a: unknown[]) => (calls.push("ltrim"), "OK"), lrange: async (..._a: unknown[]) => (calls.push("lrange"), [] as string[]), del: async (...a: unknown[]) => (calls.push("del"), 1), pipeline: () => ({ zremrangebyscore: () => null, zadd: () => null, zcard: () => null, pexpire: () => null, exec: async () => [ [null, 1], [null, 1], [null, 1], [null, 1], ], }), }), }; } function makeService( prisma: PrismaFake, redis: ReturnType, client: SearchClient, ) { // 3rd arg: the TASK-0135 cache. A stub that always MISSES so the tests // exercise the live (uncached) path — matching the PERF_CACHE=off default // the unit suite runs under (the cache is a transparent accelerator here). const cache = { getSearch: async ( _ws: string | undefined, _principal: string, _input: unknown, loader: () => Promise ) => ({ data: await loader(), cached: false }) } as never; const service = new SearchService(prisma as never, redis as never, cache); service.setClientForTesting(client); return service; } const hit = (d: SearchDoc, score = 1.5) => ({ _id: d.docId, _score: score, _source: d }); const cannedHits = (docs: SearchDoc[], total?: number) => ({ hits: { total: { value: total ?? docs.length }, hits: docs.map((d) => hit(d)) }, }); const ws1 = "ws_1"; const ws2 = "ws_2"; const userA = "user_a"; /* ----------------------------------------------------------------- tests */ describe("SearchService — caller-scope resolver", () => { it("(a) builds workspaceIds (active only) + teamIds from prisma", async () => { const prisma = makePrisma({ memberships: [ws1, ws2], teamIds: ["team_1", "team_2"] }); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn }), ); const scope = await service.resolveScope(userA); expect(scope.workspaceIds.sort()).toEqual([ws1, ws2].sort()); expect(scope.teamIds.sort()).toEqual(["team_1", "team_2"]); }); it("drops workspaces that are deleted (non-member for scope purposes)", async () => { const prisma = makePrisma({ memberships: [ws1, ws2], activeWorkspaces: [ws1] }); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn }), ); const scope = await service.resolveScope(userA); expect(scope.workspaceIds).toEqual([ws1]); }); }); describe("SearchService — fail-closed scope", () => { it("(b) explicit workspaceId the caller is NOT a member of → 404 NOT_FOUND", async () => { const prisma = makePrisma({ memberships: [ws1] }); const { fn, calls } = fakeFetch([]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); await expect(service.search(userA, { q: "x", workspaceId: ws2 })).rejects.toMatchObject({ code: "NOT_FOUND", httpStatus: 404, }); // The 404 fires in viewerFor BEFORE any client call — assert NO query was // ever sent to OpenSearch (the fake records every fetch in `calls`). expect(calls).toHaveLength(0); }); it("(b) empty membership + no workspaceId → empty results, NO query sent", async () => { const prisma = makePrisma({ memberships: [] }); const { fn, calls } = fakeFetch([]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); const res = await service.search(userA, { q: "anything" }); expect(res).toEqual({ data: [], nextCursor: null, tookMs: 0 }); expect(calls).toHaveLength(0); }); }); describe("SearchService — 503 mapping", () => { it("(c) fetch throws (OpenSearch down) → 503 SEARCH_UNAVAILABLE, no backend detail leaked", async () => { const prisma = makePrisma({ memberships: [ws1] }); const alwaysFail = (async () => { throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.5:9200"); }) as unknown as () => Promise; const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: alwaysFail }), ); const err = await service.search(userA, { q: "x" }).catch((e: unknown) => e); expect(err).toBeInstanceOf(ApiError); const e = err as ApiError; expect(e.code).toBe("SEARCH_UNAVAILABLE"); expect(e.httpStatus).toBe(503); // generic message — no host/port/status leaked expect(e.message).not.toContain("10.0.0.5"); expect(e.message).not.toContain("ECONNREFUSED"); expect(e.message).toMatch(/temporarily unavailable/i); }); }); describe("SearchService — response envelope + input", () => { it("(d) maps to { data, nextCursor, tookMs }; `q` is the field; limit 1..50", async () => { const prisma = makePrisma({ memberships: [ws1], teamIds: ["t1"] }); const d1 = doc({ docId: `page:p1`, type: "page", id: "p1", title: "Forecast", workspaceId: ws1, }); const d2 = doc({ docId: `record:r1`, type: "record", id: "r1", title: "Forecast 2", workspaceId: ws1, }); const { fn, calls } = fakeFetch([() => cannedHits([d1, d2], 42)]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }), ); const res = await service.search(userA, { q: " forecast ", limit: 20 }); expect(res).toHaveProperty("data"); expect(res).toHaveProperty("nextCursor"); expect(res).toHaveProperty("tookMs"); expect(res.data).toHaveLength(2); expect(res.data[0]).toMatchObject({ docId: "page:p1", type: "page", id: "p1", title: "Forecast", }); expect(res.data[1]).toMatchObject({ docId: "record:r1", type: "record", id: "r1" }); // nextCursor is null when OpenSearch returns fewer than pageSize hits expect(res.nextCursor).toBeNull(); expect(typeof res.tookMs).toBe("number"); // the trimmed `q` + tenant clause reach the index const body = JSON.parse(calls[0].init.body as string); const s = JSON.stringify(body.query); expect(s).toContain("forecast"); expect(s).toContain(`"${ws1}"`); }); it("(d) `q` rename: the service keys off `q` (the live field the web sends)", async () => { const prisma = makePrisma({ memberships: [ws1] }); const { fn, calls } = fakeFetch([() => cannedHits([])]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); // `q` is the live field name (not the spec's `query`); the service keys off input.q await service.search(userA, { q: "forecast" }); const sent = JSON.parse(calls[0].init.body as string); expect(JSON.stringify(sent.query)).toContain("forecast"); }); it("(d) limit clamp: 51 rejected (VALIDATION), 50 accepted", async () => { const prisma = makePrisma({ memberships: [ws1] }); const { fn } = fakeFetch([() => cannedHits([]), () => cannedHits([])]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); await expect(service.search(userA, { q: "x", limit: 51 })).rejects.toMatchObject({ code: "VALIDATION_ERROR", }); const ok = await service.search(userA, { q: "x", limit: 50 }); expect(ok.data).toEqual([]); }); it("T-155 sortBy pass-through: date sort reaches the index; relevance stays unsorted", async () => { const prisma = makePrisma({ memberships: [ws1] }); const { fn, calls } = fakeFetch([() => cannedHits([]), () => cannedHits([]), () => cannedHits([])]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }), ); // sortBy=createdAt → the request body carries the date sort clause. await service.search(userA, { q: "forecast", sortBy: "createdAt" }); const dated = JSON.parse(calls[0]!.init.body as string); expect(dated.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]); // No sortBy (relevance default) → NO sort key (the tested contract). await service.search(userA, { q: "forecast" }); expect(JSON.parse(calls[1]!.init.body as string).sort).toBeUndefined(); // sortBy="relevance" is the default → also NO sort key (not the date branch). await service.search(userA, { q: "forecast", sortBy: "relevance" }); expect(JSON.parse(calls[2]!.init.body as string).sort).toBeUndefined(); }); it("(d) cursor round-trip: nextCursor from OpenSearch is passed back; page 2 uses search_after", async () => { const prisma = makePrisma({ memberships: [ws1] }); const mk = (n: number) => doc({ docId: `page:p${n}`, type: "page", id: `p${n}`, title: `T${n}`, workspaceId: ws1, updatedAt: `2025-06-0${n}T00:00:00Z`, }); const page1 = cannedHits([mk(1), mk(2)], 5); const page2 = cannedHits([mk(3)], 5); const { fn, calls } = fakeFetch([() => page1, () => page2]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }), ); const first = await service.search(userA, { q: "t", limit: 2 }); expect(first.nextCursor).not.toBeNull(); // feed the returned cursor back (unit-level round-trip of the opaque token) const second = await service.search(userA, { q: "t", limit: 2, cursor: first.nextCursor! }); expect(second.data[0]).toMatchObject({ docId: "page:p3" }); expect(second.nextCursor).toBeNull(); // the second request carried the decoded search_after tuple // ([score, lastUpdatedAt, lastDocId] of page 1 — score 1.5 is the canned // _score of the second hit `page:p2`) const body2 = JSON.parse(calls[1].init.body as string); expect(body2.search_after).toEqual([1.5, "2025-06-02T00:00:00Z", "page:p2"]); }); }); describe("SearchService — suggest", () => { it("(e) maps prefix hits to { id, type, title } (row id, not docId)", async () => { const prisma = makePrisma({ memberships: [ws1] }); const d1 = doc({ docId: "page:p1", type: "page", id: "p1", title: "Q3 Forecast", workspaceId: ws1, }); const d2 = doc({ docId: "record:r1", type: "record", id: "r1", title: "Forecast Review", workspaceId: ws1, }); const { fn, calls } = fakeFetch([() => cannedHits([d1, d2])]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); const items = await service.suggest(userA, "forecast", 8); expect(items).toEqual([ { id: "p1", type: "page", title: "Q3 Forecast" }, { id: "r1", type: "record", title: "Forecast Review" }, ]); const body = JSON.parse(calls[0].init.body as string); expect(body.size).toBe(8); }); it("(e) empty membership → [] with no query", async () => { const prisma = makePrisma({ memberships: [] }); const { fn, calls } = fakeFetch([]); const service = makeService( prisma, makeRedis(), new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }), ); expect(await service.suggest(userA, "forecast")).toEqual([]); expect(calls).toHaveLength(0); }); });