import { describe, expect, it } from "vitest"; import { citationsFromResults, SearchClient } from "./client.js"; import { stubEmbed } from "./embeddings.js"; import type { SearchDoc, ViewerContext } from "./types.js"; const viewer: ViewerContext = { userId: "u1", workspaceIds: ["ws1"], teamIds: ["t1"] }; function doc(partial: Partial & Pick): SearchDoc { return { workspaceId: "ws1", body: "the body says forecast targets", grants: { public: false, workspace: true, user: false, teams: [], users: [] }, archived: false, url: `/ws1/p/${partial.id}`, createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-06-01T00:00:00Z", ...partial, }; } interface Call { url: string; init: RequestInit; } /** Fake fetch returning scripted responses per call. */ function fakeFetch(responses: Array<() => unknown>) { const calls: Call[] = []; const fn = (async (url: string, init?: RequestInit) => { calls.push({ url, init: init as RequestInit }); const next = responses.shift(); if (!next) throw new Error("no more scripted responses"); const value = next(); if (value instanceof Response) return value; return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); }) as typeof fetch; return { fn, calls }; } describe("SearchClient", () => { it("indexes docs via _bulk and counts indexed items", async () => { const docs = [doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "A" })]; const { fn, calls } = fakeFetch([ () => ({ errors: false, items: [{ index: { status: 201 } }, { index: { status: 201 } }] }), ]); const client = new SearchClient({ baseUrl: "http://os:9200/", fetchImpl: fn, now: () => 0 }); const res = await client.indexDocs(docs); expect(res.indexed).toBe(2); expect(calls[0]!.url).toBe("http://os:9200/_bulk"); const body = calls[0]!.init.body as string; expect(body.split("\n")[0]).toContain('"_index":"factory-content"'); expect(body.split("\n")[0]).toContain('"_id":"page:pg_1"'); }); it("full-text search maps hits and sets the cursor at page boundary", async () => { const { fn } = fakeFetch([ () => ({ hits: { total: { value: 30 }, hits: [ { _id: "page:pg_1", _score: 12.4, _source: { ...doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Q3 Forecast" }), highlight: { body: ["…forecast targets…"] }, }, }, ], }, }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); const res = await client.search({ q: "forecast", pageSize: 1 }, viewer); expect(res.results).toHaveLength(1); expect(res.results[0]!.title).toBe("Q3 Forecast"); expect(res.results[0]!.snippet).toBe("…forecast targets…"); expect(res.results[0]!.source).toBe("bm25"); expect(res.results[0]!.score).toBe(12.4); expect(res.total).toBe(30); expect(res.cursor).toBeDefined(); }); it("T-155 sortBy=createdAt: date sort clause + (date, id) cursor at the page boundary", async () => { const { fn, calls } = fakeFetch([ () => ({ hits: { total: { value: 30 }, hits: [ { _id: "page:pg_new", _score: 9, _source: { ...doc({ docId: "page:pg_new", type: "page", id: "pg_new", title: "Newer", createdAt: "2025-06-01T00:00:00Z" }) }, }, ], }, }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); const res = await client.search({ q: "forecast", pageSize: 1, sortBy: "createdAt" }, viewer); // The request body carries the date sort clause (NOT the relevance _score order). const body = JSON.parse(calls[0]!.init.body as string) as Record; expect(body.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]); expect(body.search_after).toBeUndefined(); // The cursor is the (date, id) tuple — not the relevance (score, updatedAt, id) tuple. expect(res.cursor).toBeDefined(); const parsed = JSON.parse(Buffer.from(res.cursor!, "base64url").toString("utf8")); expect(parsed).toEqual(["2025-06-01T00:00:00Z", "page:pg_new"]); }); it("T-155 sortBy + page: decodes the (date, id) cursor into search_after", async () => { const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 5 }, hits: [] } })]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); const cursor = Buffer.from( JSON.stringify(["2025-06-01T00:00:00Z", "page:pg_new"]), "utf8", ).toString("base64url"); await client.search({ q: "forecast", pageSize: 10, sortBy: "updatedAt", page: cursor }, viewer); const body = JSON.parse(calls[0]!.init.body as string) as Record; expect(body.sort).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]); expect(body.search_after).toEqual(["2025-06-01T00:00:00Z", "page:pg_new"]); }); it("T-155 relevance (default) body is byte-identical to the pre-sort contract", async () => { // Regression guard: `sortBy` absent or "relevance" must produce the SAME // request body the old code always did (no `sort` key, relevance cursor). const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 0 }, hits: [] } })]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); await client.search({ q: "forecast" }, viewer); const body = JSON.parse(calls[0]!.init.body as string) as Record; expect(body.sort).toBeUndefined(); expect(body.search_after).toBeUndefined(); }); it("sends basic auth when configured", async () => { const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 0 }, hits: [] } })]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, auth: { username: "admin", password: "secret" }, }); await client.search({ q: "x" }, viewer); const headers = calls[0]!.init.headers as Record; expect(headers.authorization).toBe( "Basic " + Buffer.from("admin:secret").toString("base64"), ); }); it("maps 5xx and network failures to SEARCH_UNAVAILABLE (503)", async () => { const fn500 = (async () => new Response("boom", { status: 500 })) as typeof fetch; const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn500 }); const err = await client.search({ q: "x" }, viewer).catch((e: unknown) => e); expect(err).toBeInstanceOf(Error); expect((err as { code?: string }).code).toBe("SEARCH_UNAVAILABLE"); expect((err as { status?: number }).status).toBe(500); // upstream status preserved const fnDown = (async () => { throw new TypeError("fetch failed"); }) as typeof fetch; const client2 = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fnDown }); const err2 = await client2.search({ q: "x" }, viewer).catch((e: unknown) => e); expect((err2 as { code?: string }).code).toBe("SEARCH_UNAVAILABLE"); }); it("hybrid search runs two passes (BM25 + kNN) and fuses with RRF", async () => { const source = (id: string, score: number, extra: Record = {}) => ({ _id: id, _score: score, _source: { ...doc({ docId: id, type: "page", id: id.slice(5), title: id }), ...extra }, }); const { fn, calls } = fakeFetch([ // pass 1: BM25 (size 50) () => ({ hits: { total: { value: 3 }, hits: [source("page:pg_a", 9), source("page:pg_b", 8)] } }), // pass 2: kNN () => ({ hits: { total: { value: 2 }, hits: [source("page:pg_b", 0.9, { knn_score: 0.9 }), source("page:pg_c", 0.7, { knn_score: 0.7 })] } }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); const res = await client.search({ q: "forecast", mode: "hybrid" }, viewer, stubEmbed("forecast")); // RRF: pg_b in both lists (rank 2 bm25, rank 1 vec) → top expect(res.results[0]!.docId).toBe("page:pg_b"); expect(res.results[0]!.source).toBe("bm25+vec"); expect(res.results[0]!.score).toBeCloseTo(1 / 62 + 1 / 61, 12); const sources = res.results.map((r) => r.source); expect(sources).toContain("bm25"); expect(sources).toContain("vec"); // two _search calls: pass 1 BM25-only (no top-level `knn`), pass 2 the // `query.knn.` vector clause (TASK-0125: OpenSearch 2.15 rejects // the ES-style top-level `knn` key; the working form is query.knn) expect(calls).toHaveLength(2); const bm25Body = JSON.parse(calls[0]!.init.body as string); expect(bm25Body.knn).toBeUndefined(); const knnBody = JSON.parse(calls[1]!.init.body as string); expect(knnBody.knn).toBeUndefined(); expect(knnBody.query.knn.vector).toBeDefined(); expect(knnBody.query.knn.vector.k).toBe(50); expect(Array.isArray(knnBody.query.knn.vector.vector)).toBe(true); }); it("hybrid without a vector throws AI_ERROR", async () => { const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn }); const err = await client.search({ q: "x", mode: "hybrid" }, viewer).catch((e: unknown) => e); expect((err as { code?: string }).code).toBe("AI_ERROR"); }); it("deleteDoc issues DELETE on the doc endpoint", async () => { const { fn, calls } = fakeFetch([() => ({ result: "deleted" })]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); await client.deleteDoc("page:pg_1"); expect(calls[0]!.url).toBe("http://os:9200/factory-content/_doc/page%3Apg_1"); expect(calls[0]!.init.method).toBe("DELETE"); }); it("docExists batch-checks ids via _mget (reconcile index-loss detection)", async () => { const { fn, calls } = fakeFetch([ () => ({ docs: [{ _id: "page:pg_1", found: true }, { _id: "page:pg_2", found: false }] }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); const present = await client.docExists(["page:pg_1", "page:pg_2"]); expect(calls[0]!.url).toBe("http://os:9200/factory-content/_mget"); expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ ids: ["page:pg_1", "page:pg_2"] }); expect(present.has("page:pg_1")).toBe(true); expect(present.has("page:pg_2")).toBe(false); expect(await client.docExists([])).toEqual(new Set()); }); it("countDocs counts whole docs only (excludes chunk docs) and scopes by workspace (T-177)", async () => { const { fn, calls } = fakeFetch([ () => ({ hits: { total: { value: 42 }, hits: [] } }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); expect(await client.countDocs(["ws1", "ws2"])).toBe(42); expect(calls[0]!.url).toBe("http://os:9200/factory-content/_search"); const body = JSON.parse(calls[0]!.init.body as string) as { size: number; query: { bool: { filter: unknown[] } }; }; expect(body.size).toBe(0); // no hits returned const filter = body.query.bool.filter as Array>; // The chunk-exclusion clause is always present (type != "chunk"). expect(JSON.stringify(filter)).toContain('must_not'); expect(JSON.stringify(filter)).toContain('"chunk"'); // The tenant scope is a terms filter on workspaceId. expect(filter).toEqual( expect.arrayContaining([ expect.objectContaining({ terms: { workspaceId: ["ws1", "ws2"] } }), ]) ); }); it("countDocs: no workspace scope → no terms filter; 5xx → SEARCH_UNAVAILABLE", async () => { const { fn } = fakeFetch([() => ({ hits: { total: { value: 7 }, hits: [] } })]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); expect(await client.countDocs()).toBe(7); // A 5xx maps to SEARCH_UNAVAILABLE (the caller → healthy:false, never a guess). const { fn: fn500 } = fakeFetch([() => new Response("boom", { status: 503 })]); const client500 = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn500 }); await expect(client500.countDocs()).rejects.toMatchObject({ code: "SEARCH_UNAVAILABLE" }); }); it("suggest: prefix body with tenant+permission filters, maps to {id,type,title}, de-dupes, caps size", async () => { const { fn, calls } = fakeFetch([ () => ({ hits: { total: { value: 3 }, hits: [ { _id: "page:pg_1", _score: 1, _source: doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Q3 Forecast" }) }, // a chunk doc sharing the parent title (de-dupe by type:id) { _id: "page:pg_1:chunk:0", _score: 1, _source: doc({ docId: "page:pg_1:chunk:0", type: "page", id: "pg_1", title: "Q3 Forecast" }) }, { _id: "record:r_1", _score: 1, _source: doc({ docId: "record:r_1", type: "record", id: "r_1", title: "Forecast Review" }) }, ], }, }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); const res = await client.suggest("forecast", viewer, 8); expect(res).toEqual([ { id: "pg_1", type: "page", title: "Q3 Forecast" }, { id: "r_1", type: "record", title: "Forecast Review" }, ]); expect(calls[0]!.url).toBe("http://os:9200/factory-content/_search"); const body = JSON.parse(calls[0]!.init.body as string) as { size: number; query: { bool: { must: unknown[]; filter: Record[] } }; }; expect(body.size).toBe(8); const filterStr = JSON.stringify(body.query.bool.filter); // the SAME mandatory tenant + permission filters as full-text search expect(filterStr).toContain('"workspaceId":["ws1"]'); expect(filterStr).toContain('"grants.users":"u1"'); expect(filterStr).toContain('"grants.teams":["t1"]'); // the prefix clause targets title.keyword / id (search §4.2) expect(JSON.stringify(body.query.bool.must)).toContain('"title.keyword"'); }); it("suggest: caps size at 20 and at least 1", async () => { const { fn, calls } = fakeFetch([ () => ({ hits: { hits: [] } }), () => ({ hits: { hits: [] } }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }); await client.suggest("a", viewer, 999); expect(JSON.parse(calls[0]!.init.body as string).size).toBe(20); await client.suggest("a", viewer, 0); expect(JSON.parse(calls[1]!.init.body as string).size).toBe(1); }); }); describe("citationsFromResults", () => { it("builds citations from the top results", async () => { const { fn } = fakeFetch([ () => ({ hits: { total: { value: 1 }, hits: [ { _id: "page:pg_1", _score: 0.91, _source: doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Forecast" }), }, ], }, }), ]); const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }); const res = await client.search({ q: "forecast" }, viewer); const citations = citationsFromResults(res.results); expect(citations).toHaveLength(1); expect(citations[0]).toMatchObject({ docId: "page:pg_1", title: "Forecast", relevance: 0.91 }); }); });