1
17import { describe, expect, it } from "vitest";
18import { SearchClient } from "@factory/search";
19import type { SearchDoc, SearchType, ViewerContext } from "@factory/search";
20
21import { SearchService } from "../search.service.js";
22import { ApiError } from "../../../common/errors.js";
23
24
25
26interface Call {
27 url: string;
28 init: RequestInit;
29}
30
31
32function fakeFetch(responses: Array<() => unknown> | (() => Promise<never>)) {
33 const calls: Call[] = [];
34 const fn = (async (url: string, init?: RequestInit) => {
35 calls.push({ url, init: init as RequestInit });
36 if (typeof responses === "function") return responses();
37 const next = responses.shift();
38 if (!next) throw new Error("no more scripted responses");
39 const value = next();
40 return new Response(JSON.stringify(value), {
41 status: 200,
42 headers: { "content-type": "application/json" },
43 });
44 }) as typeof fetch;
45 return { fn, calls };
46}
47
48function doc(
49 partial: Partial<SearchDoc> & Pick<SearchDoc, "docId" | "type" | "id" | "title" | "workspaceId">,
50): SearchDoc {
51 return {
52 body: "the body",
53 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
54 archived: false,
55 url: `/w/${partial.workspaceId}/p/${partial.id}`,
56 createdAt: "2025-01-01T00:00:00Z",
57 updatedAt: "2025-06-01T00:00:00Z",
58 ...partial,
59 };
60}
61
62interface PrismaFake {
63 workspaceMember: { findMany: (a: unknown) => Promise<{ workspaceId: string }[]> };
64 teamMember: { findMany: (a: unknown) => Promise<{ teamId: string }[]> };
65 workspace: { findMany: (a: unknown) => Promise<{ id: string }[]> };
66}
67
68function makePrisma(opts: {
69 memberships?: string[];
70 activeWorkspaces?: string[];
71 teamIds?: string[];
72}): PrismaFake {
73 const memberships = opts.memberships ?? [];
74 const active = opts.activeWorkspaces ?? memberships;
75 const teamIds = opts.teamIds ?? [];
76 return {
77 workspaceMember: { findMany: async () => memberships.map((workspaceId) => ({ workspaceId })) },
78 teamMember: { findMany: async () => teamIds.map((teamId) => ({ teamId })) },
79 workspace: { findMany: async () => active.map((id) => ({ id })) },
80 };
81}
82
83
84function makeRedis() {
85 const calls: string[] = [];
86 return {
87 calls,
88 getRedis: () => ({
89 lrem: async (...a: unknown[]) => (calls.push("lrem"), 1),
90 lpush: async (...a: unknown[]) => (calls.push("lpush"), 1),
91 ltrim: async (...a: unknown[]) => (calls.push("ltrim"), "OK"),
92 lrange: async (..._a: unknown[]) => (calls.push("lrange"), [] as string[]),
93 del: async (...a: unknown[]) => (calls.push("del"), 1),
94 pipeline: () => ({
95 zremrangebyscore: () => null,
96 zadd: () => null,
97 zcard: () => null,
98 pexpire: () => null,
99 exec: async () => [
100 [null, 1],
101 [null, 1],
102 [null, 1],
103 [null, 1],
104 ],
105 }),
106 }),
107 };
108}
109
110function makeService(
111 prisma: PrismaFake,
112 redis: ReturnType<typeof makeRedis>,
113 client: SearchClient,
114) {
115
116
117
118 const cache = {
119 getSearch: async (
120 _ws: string | undefined,
121 _principal: string,
122 _input: unknown,
123 loader: () => Promise<unknown>
124 ) => ({ data: await loader(), cached: false })
125 } as never;
126 const service = new SearchService(prisma as never, redis as never, cache);
127 service.setClientForTesting(client);
128 return service;
129}
130
131const hit = (d: SearchDoc, score = 1.5) => ({ _id: d.docId, _score: score, _source: d });
132const cannedHits = (docs: SearchDoc[], total?: number) => ({
133 hits: { total: { value: total ?? docs.length }, hits: docs.map((d) => hit(d)) },
134});
135
136const ws1 = "ws_1";
137const ws2 = "ws_2";
138const userA = "user_a";
139
140
141
142describe("SearchService — caller-scope resolver", () => {
143 it("(a) builds workspaceIds (active only) + teamIds from prisma", async () => {
144 const prisma = makePrisma({ memberships: [ws1, ws2], teamIds: ["team_1", "team_2"] });
145 const service = makeService(
146 prisma,
147 makeRedis(),
148 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn }),
149 );
150 const scope = await service.resolveScope(userA);
151 expect(scope.workspaceIds.sort()).toEqual([ws1, ws2].sort());
152 expect(scope.teamIds.sort()).toEqual(["team_1", "team_2"]);
153 });
154
155 it("drops workspaces that are deleted (non-member for scope purposes)", async () => {
156 const prisma = makePrisma({ memberships: [ws1, ws2], activeWorkspaces: [ws1] });
157 const service = makeService(
158 prisma,
159 makeRedis(),
160 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fakeFetch([]).fn }),
161 );
162 const scope = await service.resolveScope(userA);
163 expect(scope.workspaceIds).toEqual([ws1]);
164 });
165});
166
167describe("SearchService — fail-closed scope", () => {
168 it("(b) explicit workspaceId the caller is NOT a member of → 404 NOT_FOUND", async () => {
169 const prisma = makePrisma({ memberships: [ws1] });
170 const { fn, calls } = fakeFetch([]);
171 const service = makeService(
172 prisma,
173 makeRedis(),
174 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
175 );
176 await expect(service.search(userA, { q: "x", workspaceId: ws2 })).rejects.toMatchObject({
177 code: "NOT_FOUND",
178 httpStatus: 404,
179 });
180
181
182 expect(calls).toHaveLength(0);
183 });
184
185 it("(b) empty membership + no workspaceId → empty results, NO query sent", async () => {
186 const prisma = makePrisma({ memberships: [] });
187 const { fn, calls } = fakeFetch([]);
188 const service = makeService(
189 prisma,
190 makeRedis(),
191 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
192 );
193 const res = await service.search(userA, { q: "anything" });
194 expect(res).toEqual({ data: [], nextCursor: null, tookMs: 0 });
195 expect(calls).toHaveLength(0);
196 });
197});
198
199describe("SearchService — 503 mapping", () => {
200 it("(c) fetch throws (OpenSearch down) → 503 SEARCH_UNAVAILABLE, no backend detail leaked", async () => {
201 const prisma = makePrisma({ memberships: [ws1] });
202 const alwaysFail = (async () => {
203 throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.5:9200");
204 }) as unknown as () => Promise<never>;
205 const service = makeService(
206 prisma,
207 makeRedis(),
208 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: alwaysFail }),
209 );
210 const err = await service.search(userA, { q: "x" }).catch((e: unknown) => e);
211 expect(err).toBeInstanceOf(ApiError);
212 const e = err as ApiError;
213 expect(e.code).toBe("SEARCH_UNAVAILABLE");
214 expect(e.httpStatus).toBe(503);
215
216 expect(e.message).not.toContain("10.0.0.5");
217 expect(e.message).not.toContain("ECONNREFUSED");
218 expect(e.message).toMatch(/temporarily unavailable/i);
219 });
220});
221
222describe("SearchService — response envelope + input", () => {
223 it("(d) maps to { data, nextCursor, tookMs }; `q` is the field; limit 1..50", async () => {
224 const prisma = makePrisma({ memberships: [ws1], teamIds: ["t1"] });
225 const d1 = doc({
226 docId: `page:p1`,
227 type: "page",
228 id: "p1",
229 title: "Forecast",
230 workspaceId: ws1,
231 });
232 const d2 = doc({
233 docId: `record:r1`,
234 type: "record",
235 id: "r1",
236 title: "Forecast 2",
237 workspaceId: ws1,
238 });
239 const { fn, calls } = fakeFetch([() => cannedHits([d1, d2], 42)]);
240 const service = makeService(
241 prisma,
242 makeRedis(),
243 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }),
244 );
245
246 const res = await service.search(userA, { q: " forecast ", limit: 20 });
247 expect(res).toHaveProperty("data");
248 expect(res).toHaveProperty("nextCursor");
249 expect(res).toHaveProperty("tookMs");
250 expect(res.data).toHaveLength(2);
251 expect(res.data[0]).toMatchObject({
252 docId: "page:p1",
253 type: "page",
254 id: "p1",
255 title: "Forecast",
256 });
257 expect(res.data[1]).toMatchObject({ docId: "record:r1", type: "record", id: "r1" });
258
259 expect(res.nextCursor).toBeNull();
260 expect(typeof res.tookMs).toBe("number");
261
262 const body = JSON.parse(calls[0].init.body as string);
263 const s = JSON.stringify(body.query);
264 expect(s).toContain("forecast");
265 expect(s).toContain(`"${ws1}"`);
266 });
267
268 it("(d) `q` rename: the service keys off `q` (the live field the web sends)", async () => {
269 const prisma = makePrisma({ memberships: [ws1] });
270 const { fn, calls } = fakeFetch([() => cannedHits([])]);
271 const service = makeService(
272 prisma,
273 makeRedis(),
274 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
275 );
276
277 await service.search(userA, { q: "forecast" });
278 const sent = JSON.parse(calls[0].init.body as string);
279 expect(JSON.stringify(sent.query)).toContain("forecast");
280 });
281
282 it("(d) limit clamp: 51 rejected (VALIDATION), 50 accepted", async () => {
283 const prisma = makePrisma({ memberships: [ws1] });
284 const { fn } = fakeFetch([() => cannedHits([]), () => cannedHits([])]);
285 const service = makeService(
286 prisma,
287 makeRedis(),
288 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
289 );
290 await expect(service.search(userA, { q: "x", limit: 51 })).rejects.toMatchObject({
291 code: "VALIDATION_ERROR",
292 });
293 const ok = await service.search(userA, { q: "x", limit: 50 });
294 expect(ok.data).toEqual([]);
295 });
296
297 it("T-155 sortBy pass-through: date sort reaches the index; relevance stays unsorted", async () => {
298 const prisma = makePrisma({ memberships: [ws1] });
299 const { fn, calls } = fakeFetch([() => cannedHits([]), () => cannedHits([]), () => cannedHits([])]);
300 const service = makeService(
301 prisma,
302 makeRedis(),
303 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }),
304 );
305
306
307 await service.search(userA, { q: "forecast", sortBy: "createdAt" });
308 const dated = JSON.parse(calls[0]!.init.body as string);
309 expect(dated.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]);
310
311
312 await service.search(userA, { q: "forecast" });
313 expect(JSON.parse(calls[1]!.init.body as string).sort).toBeUndefined();
314
315
316 await service.search(userA, { q: "forecast", sortBy: "relevance" });
317 expect(JSON.parse(calls[2]!.init.body as string).sort).toBeUndefined();
318 });
319
320 it("(d) cursor round-trip: nextCursor from OpenSearch is passed back; page 2 uses search_after", async () => {
321 const prisma = makePrisma({ memberships: [ws1] });
322 const mk = (n: number) =>
323 doc({
324 docId: `page:p${n}`,
325 type: "page",
326 id: `p${n}`,
327 title: `T${n}`,
328 workspaceId: ws1,
329 updatedAt: `2025-06-0${n}T00:00:00Z`,
330 });
331 const page1 = cannedHits([mk(1), mk(2)], 5);
332 const page2 = cannedHits([mk(3)], 5);
333 const { fn, calls } = fakeFetch([() => page1, () => page2]);
334 const service = makeService(
335 prisma,
336 makeRedis(),
337 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn, now: () => 0 }),
338 );
339
340 const first = await service.search(userA, { q: "t", limit: 2 });
341 expect(first.nextCursor).not.toBeNull();
342
343 const second = await service.search(userA, { q: "t", limit: 2, cursor: first.nextCursor! });
344 expect(second.data[0]).toMatchObject({ docId: "page:p3" });
345 expect(second.nextCursor).toBeNull();
346
347
348
349 const body2 = JSON.parse(calls[1].init.body as string);
350 expect(body2.search_after).toEqual([1.5, "2025-06-02T00:00:00Z", "page:p2"]);
351 });
352});
353
354describe("SearchService — suggest", () => {
355 it("(e) maps prefix hits to { id, type, title } (row id, not docId)", async () => {
356 const prisma = makePrisma({ memberships: [ws1] });
357 const d1 = doc({
358 docId: "page:p1",
359 type: "page",
360 id: "p1",
361 title: "Q3 Forecast",
362 workspaceId: ws1,
363 });
364 const d2 = doc({
365 docId: "record:r1",
366 type: "record",
367 id: "r1",
368 title: "Forecast Review",
369 workspaceId: ws1,
370 });
371 const { fn, calls } = fakeFetch([() => cannedHits([d1, d2])]);
372 const service = makeService(
373 prisma,
374 makeRedis(),
375 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
376 );
377 const items = await service.suggest(userA, "forecast", 8);
378 expect(items).toEqual([
379 { id: "p1", type: "page", title: "Q3 Forecast" },
380 { id: "r1", type: "record", title: "Forecast Review" },
381 ]);
382 const body = JSON.parse(calls[0].init.body as string);
383 expect(body.size).toBe(8);
384 });
385
386 it("(e) empty membership → [] with no query", async () => {
387 const prisma = makePrisma({ memberships: [] });
388 const { fn, calls } = fakeFetch([]);
389 const service = makeService(
390 prisma,
391 makeRedis(),
392 new SearchClient({ baseUrl: "http://os:9200", fetchImpl: fn }),
393 );
394 expect(await service.suggest(userA, "forecast")).toEqual([]);
395 expect(calls).toHaveLength(0);
396 });
397});
398