1import { describe, expect, it } from "vitest";
2
3import { citationsFromResults, SearchClient } from "./client.js";
4import { stubEmbed } from "./embeddings.js";
5import type { SearchDoc, ViewerContext } from "./types.js";
6
7const viewer: ViewerContext = { userId: "u1", workspaceIds: ["ws1"], teamIds: ["t1"] };
8
9function doc(partial: Partial<SearchDoc> & Pick<SearchDoc, "docId" | "type" | "id" | "title">): SearchDoc {
10 return {
11 workspaceId: "ws1",
12 body: "the body says forecast targets",
13 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
14 archived: false,
15 url: `/ws1/p/${partial.id}`,
16 createdAt: "2025-01-01T00:00:00Z",
17 updatedAt: "2025-06-01T00:00:00Z",
18 ...partial,
19 };
20}
21
22interface Call {
23 url: string;
24 init: RequestInit;
25}
26
27
28function fakeFetch(responses: Array<() => unknown>) {
29 const calls: Call[] = [];
30 const fn = (async (url: string, init?: RequestInit) => {
31 calls.push({ url, init: init as RequestInit });
32 const next = responses.shift();
33 if (!next) throw new Error("no more scripted responses");
34 const value = next();
35 if (value instanceof Response) return value;
36 return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
37 }) as typeof fetch;
38 return { fn, calls };
39}
40
41describe("SearchClient", () => {
42 it("indexes docs via _bulk and counts indexed items", async () => {
43 const docs = [doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "A" })];
44 const { fn, calls } = fakeFetch([
45 () => ({ errors: false, items: [{ index: { status: 201 } }, { index: { status: 201 } }] }),
46 ]);
47 const client = new SearchClient({ baseUrl: "http://os:9200/", fetchImpl: fn, now: () => 0 });
48 const res = await client.indexDocs(docs);
49 expect(res.indexed).toBe(2);
50 expect(calls[0]!.url).toBe("http://os:9200/_bulk");
51 const body = calls[0]!.init.body as string;
52 expect(body.split("\n")[0]).toContain('"_index":"factory-content"');
53 expect(body.split("\n")[0]).toContain('"_id":"page:pg_1"');
54 });
55
56 it("full-text search maps hits and sets the cursor at page boundary", async () => {
57 const { fn } = fakeFetch([
58 () => ({
59 hits: {
60 total: { value: 30 },
61 hits: [
62 {
63 _id: "page:pg_1",
64 _score: 12.4,
65 _source: {
66 ...doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Q3 Forecast" }),
67 highlight: { body: ["…<em>forecast</em> targets…"] },
68 },
69 },
70 ],
71 },
72 }),
73 ]);
74 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
75 const res = await client.search({ q: "forecast", pageSize: 1 }, viewer);
76 expect(res.results).toHaveLength(1);
77 expect(res.results[0]!.title).toBe("Q3 Forecast");
78 expect(res.results[0]!.snippet).toBe("…<em>forecast</em> targets…");
79 expect(res.results[0]!.source).toBe("bm25");
80 expect(res.results[0]!.score).toBe(12.4);
81 expect(res.total).toBe(30);
82 expect(res.cursor).toBeDefined();
83 });
84
85 it("T-155 sortBy=createdAt: date sort clause + (date, id) cursor at the page boundary", async () => {
86 const { fn, calls } = fakeFetch([
87 () => ({
88 hits: {
89 total: { value: 30 },
90 hits: [
91 {
92 _id: "page:pg_new",
93 _score: 9,
94 _source: { ...doc({ docId: "page:pg_new", type: "page", id: "pg_new", title: "Newer", createdAt: "2025-06-01T00:00:00Z" }) },
95 },
96 ],
97 },
98 }),
99 ]);
100 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
101 const res = await client.search({ q: "forecast", pageSize: 1, sortBy: "createdAt" }, viewer);
102
103 const body = JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
104 expect(body.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]);
105 expect(body.search_after).toBeUndefined();
106
107 expect(res.cursor).toBeDefined();
108 const parsed = JSON.parse(Buffer.from(res.cursor!, "base64url").toString("utf8"));
109 expect(parsed).toEqual(["2025-06-01T00:00:00Z", "page:pg_new"]);
110 });
111
112 it("T-155 sortBy + page: decodes the (date, id) cursor into search_after", async () => {
113 const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 5 }, hits: [] } })]);
114 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
115 const cursor = Buffer.from(
116 JSON.stringify(["2025-06-01T00:00:00Z", "page:pg_new"]),
117 "utf8",
118 ).toString("base64url");
119 await client.search({ q: "forecast", pageSize: 10, sortBy: "updatedAt", page: cursor }, viewer);
120 const body = JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
121 expect(body.sort).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]);
122 expect(body.search_after).toEqual(["2025-06-01T00:00:00Z", "page:pg_new"]);
123 });
124
125 it("T-155 relevance (default) body is byte-identical to the pre-sort contract", async () => {
126
127
128 const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 0 }, hits: [] } })]);
129 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
130 await client.search({ q: "forecast" }, viewer);
131 const body = JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
132 expect(body.sort).toBeUndefined();
133 expect(body.search_after).toBeUndefined();
134 });
135
136 it("sends basic auth when configured", async () => {
137 const { fn, calls } = fakeFetch([() => ({ hits: { total: { value: 0 }, hits: [] } })]);
138 const client = new SearchClient({
139 baseUrl: "http://os:9200",
140 fetchImpl: fn,
141 auth: { username: "admin", password: "secret" },
142 });
143 await client.search({ q: "x" }, viewer);
144 const headers = calls[0]!.init.headers as Record<string, string>;
145 expect(headers.authorization).toBe(
146 "Basic " + Buffer.from("admin:secret").toString("base64"),
147 );
148 });
149
150 it("maps 5xx and network failures to SEARCH_UNAVAILABLE (503)", async () => {
151 const fn500 = (async () => new Response("boom", { status: 500 })) as typeof fetch;
152 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn500 });
153 const err = await client.search({ q: "x" }, viewer).catch((e: unknown) => e);
154 expect(err).toBeInstanceOf(Error);
155 expect((err as { code?: string }).code).toBe("SEARCH_UNAVAILABLE");
156 expect((err as { status?: number }).status).toBe(500);
157
158 const fnDown = (async () => {
159 throw new TypeError("fetch failed");
160 }) as typeof fetch;
161 const client2 = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fnDown });
162 const err2 = await client2.search({ q: "x" }, viewer).catch((e: unknown) => e);
163 expect((err2 as { code?: string }).code).toBe("SEARCH_UNAVAILABLE");
164 });
165
166 it("hybrid search runs two passes (BM25 + kNN) and fuses with RRF", async () => {
167 const source = (id: string, score: number, extra: Record<string, unknown> = {}) => ({
168 _id: id,
169 _score: score,
170 _source: { ...doc({ docId: id, type: "page", id: id.slice(5), title: id }), ...extra },
171 });
172 const { fn, calls } = fakeFetch([
173
174 () => ({ hits: { total: { value: 3 }, hits: [source("page:pg_a", 9), source("page:pg_b", 8)] } }),
175
176 () => ({ 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 })] } }),
177 ]);
178 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
179 const res = await client.search({ q: "forecast", mode: "hybrid" }, viewer, stubEmbed("forecast"));
180
181 expect(res.results[0]!.docId).toBe("page:pg_b");
182 expect(res.results[0]!.source).toBe("bm25+vec");
183 expect(res.results[0]!.score).toBeCloseTo(1 / 62 + 1 / 61, 12);
184 const sources = res.results.map((r) => r.source);
185 expect(sources).toContain("bm25");
186 expect(sources).toContain("vec");
187
188
189
190 expect(calls).toHaveLength(2);
191 const bm25Body = JSON.parse(calls[0]!.init.body as string);
192 expect(bm25Body.knn).toBeUndefined();
193 const knnBody = JSON.parse(calls[1]!.init.body as string);
194 expect(knnBody.knn).toBeUndefined();
195 expect(knnBody.query.knn.vector).toBeDefined();
196 expect(knnBody.query.knn.vector.k).toBe(50);
197 expect(Array.isArray(knnBody.query.knn.vector.vector)).toBe(true);
198 });
199
200 it("hybrid without a vector throws AI_ERROR", async () => {
201 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn });
202 const err = await client.search({ q: "x", mode: "hybrid" }, viewer).catch((e: unknown) => e);
203 expect((err as { code?: string }).code).toBe("AI_ERROR");
204 });
205
206 it("deleteDoc issues DELETE on the doc endpoint", async () => {
207 const { fn, calls } = fakeFetch([() => ({ result: "deleted" })]);
208 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
209 await client.deleteDoc("page:pg_1");
210 expect(calls[0]!.url).toBe("http://os:9200/factory-content/_doc/page%3Apg_1");
211 expect(calls[0]!.init.method).toBe("DELETE");
212 });
213
214 it("docExists batch-checks ids via _mget (reconcile index-loss detection)", async () => {
215 const { fn, calls } = fakeFetch([
216 () => ({ docs: [{ _id: "page:pg_1", found: true }, { _id: "page:pg_2", found: false }] }),
217 ]);
218 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
219 const present = await client.docExists(["page:pg_1", "page:pg_2"]);
220 expect(calls[0]!.url).toBe("http://os:9200/factory-content/_mget");
221 expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ ids: ["page:pg_1", "page:pg_2"] });
222 expect(present.has("page:pg_1")).toBe(true);
223 expect(present.has("page:pg_2")).toBe(false);
224 expect(await client.docExists([])).toEqual(new Set());
225 });
226
227 it("countDocs counts whole docs only (excludes chunk docs) and scopes by workspace (T-177)", async () => {
228 const { fn, calls } = fakeFetch([
229 () => ({ hits: { total: { value: 42 }, hits: [] } }),
230 ]);
231 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
232 expect(await client.countDocs(["ws1", "ws2"])).toBe(42);
233 expect(calls[0]!.url).toBe("http://os:9200/factory-content/_search");
234 const body = JSON.parse(calls[0]!.init.body as string) as {
235 size: number;
236 query: { bool: { filter: unknown[] } };
237 };
238 expect(body.size).toBe(0);
239 const filter = body.query.bool.filter as Array<Record<string, unknown>>;
240
241 expect(JSON.stringify(filter)).toContain('must_not');
242 expect(JSON.stringify(filter)).toContain('"chunk"');
243
244 expect(filter).toEqual(
245 expect.arrayContaining([
246 expect.objectContaining({ terms: { workspaceId: ["ws1", "ws2"] } }),
247 ])
248 );
249 });
250
251 it("countDocs: no workspace scope → no terms filter; 5xx → SEARCH_UNAVAILABLE", async () => {
252 const { fn } = fakeFetch([() => ({ hits: { total: { value: 7 }, hits: [] } })]);
253 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
254 expect(await client.countDocs()).toBe(7);
255
256
257 const { fn: fn500 } = fakeFetch([() => new Response("boom", { status: 503 })]);
258 const client500 = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn500 });
259 await expect(client500.countDocs()).rejects.toMatchObject({ code: "SEARCH_UNAVAILABLE" });
260 });
261
262 it("suggest: prefix body with tenant+permission filters, maps to {id,type,title}, de-dupes, caps size", async () => {
263 const { fn, calls } = fakeFetch([
264 () => ({
265 hits: {
266 total: { value: 3 },
267 hits: [
268 { _id: "page:pg_1", _score: 1, _source: doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Q3 Forecast" }) },
269
270 { _id: "page:pg_1:chunk:0", _score: 1, _source: doc({ docId: "page:pg_1:chunk:0", type: "page", id: "pg_1", title: "Q3 Forecast" }) },
271 { _id: "record:r_1", _score: 1, _source: doc({ docId: "record:r_1", type: "record", id: "r_1", title: "Forecast Review" }) },
272 ],
273 },
274 }),
275 ]);
276 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
277 const res = await client.suggest("forecast", viewer, 8);
278 expect(res).toEqual([
279 { id: "pg_1", type: "page", title: "Q3 Forecast" },
280 { id: "r_1", type: "record", title: "Forecast Review" },
281 ]);
282 expect(calls[0]!.url).toBe("http://os:9200/factory-content/_search");
283 const body = JSON.parse(calls[0]!.init.body as string) as {
284 size: number;
285 query: { bool: { must: unknown[]; filter: Record<string, unknown>[] } };
286 };
287 expect(body.size).toBe(8);
288 const filterStr = JSON.stringify(body.query.bool.filter);
289
290 expect(filterStr).toContain('"workspaceId":["ws1"]');
291 expect(filterStr).toContain('"grants.users":"u1"');
292 expect(filterStr).toContain('"grants.teams":["t1"]');
293
294 expect(JSON.stringify(body.query.bool.must)).toContain('"title.keyword"');
295 });
296
297 it("suggest: caps size at 20 and at least 1", async () => {
298 const { fn, calls } = fakeFetch([
299 () => ({ hits: { hits: [] } }),
300 () => ({ hits: { hits: [] } }),
301 ]);
302 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn });
303 await client.suggest("a", viewer, 999);
304 expect(JSON.parse(calls[0]!.init.body as string).size).toBe(20);
305 await client.suggest("a", viewer, 0);
306 expect(JSON.parse(calls[1]!.init.body as string).size).toBe(1);
307 });
308});
309
310describe("citationsFromResults", () => {
311 it("builds citations from the top results", async () => {
312 const { fn } = fakeFetch([
313 () => ({
314 hits: {
315 total: { value: 1 },
316 hits: [
317 {
318 _id: "page:pg_1",
319 _score: 0.91,
320 _source: doc({ docId: "page:pg_1", type: "page", id: "pg_1", title: "Forecast" }),
321 },
322 ],
323 },
324 }),
325 ]);
326 const client = new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 });
327 const res = await client.search({ q: "forecast" }, viewer);
328 const citations = citationsFromResults(res.results);
329 expect(citations).toHaveLength(1);
330 expect(citations[0]).toMatchObject({ docId: "page:pg_1", title: "Forecast", relevance: 0.91 });
331 });
332});
333