1import { describe, expect, it } from "vitest";
2
3import {
4 buildFuzzyBody,
5 buildFullTextBody,
6 buildHybridBody,
7 buildHybridKnnOnlyBody,
8 buildPrefixBody,
9 chunkExclusion,
10 CONTENT_INDEX_ALIAS,
11 contentIndex,
12 dateSortClauses,
13 dateSortField,
14 decodeCursor,
15 decodeDateCursor,
16 encodeCursor,
17 encodeDateCursor,
18 tenantFilter,
19 visibilityFilter,
20 withDateSort,
21 withSearchAfter,
22} from "./queries.js";
23import type { ViewerContext } from "./types.js";
24
25const viewer: ViewerContext = { userId: "u1", workspaceIds: ["ws1"], teamIds: ["t1"] };
26
27function queryBool(body: Record<string, unknown>): { must?: unknown[]; filter?: unknown[] } {
28 return (body.query as { bool: { must?: unknown[]; filter?: unknown[] } }).bool;
29}
30
31describe("content index (single-index layout decision)", () => {
32 it("is the factory-content alias", () => {
33 expect(contentIndex()).toBe("factory-content");
34 expect(CONTENT_INDEX_ALIAS).toBe("factory-content");
35 });
36});
37
38describe("tenantFilter", () => {
39 it("restricts to the caller's workspaces (mandatory)", () => {
40 expect(tenantFilter(viewer)).toEqual({ terms: { workspaceId: ["ws1"] } });
41 expect(tenantFilter({ userId: "u1", workspaceIds: ["a", "b"], teamIds: [] })).toEqual({
42 terms: { workspaceId: ["a", "b"] },
43 });
44 });
45});
46
47describe("visibilityFilter", () => {
48 it("allows public, workspace, user and team grants (TASK-0100 S5: term/terms clauses)", () => {
49
50
51 const f = visibilityFilter(viewer) as { bool: { should: unknown[] } };
52 const should = f.bool.should;
53 expect(should).toContainEqual({ term: { "grants.public": true } });
54 expect(should).toContainEqual({ term: { "grants.workspace": true } });
55 expect(should).toContainEqual({ term: { "grants.users": "u1" } });
56 expect(should).toContainEqual({ terms: { "grants.teams": ["t1"] } });
57 });
58
59 it("with no teams the should list has 3 clauses (public, workspace, user)", () => {
60 const f = visibilityFilter({ userId: "u1", workspaceIds: ["ws1"], teamIds: [] }) as {
61 bool: { should: unknown[] };
62 };
63 expect(f.bool.should).toHaveLength(3);
64 expect(f.bool.should).toContainEqual({ term: { "grants.public": true } });
65 expect(f.bool.should).toContainEqual({ term: { "grants.workspace": true } });
66 expect(f.bool.should).toContainEqual({ term: { "grants.users": "u1" } });
67 });
68});
69
70describe("buildFullTextBody", () => {
71 it("builds a bool must + filter with weighted multi_match", () => {
72 const body = buildFullTextBody({ q: "q3 forecast", pageSize: 10 }, viewer);
73 const q = queryBool(body);
74 const mm = (q.must as Record<string, unknown>[])[0] as { multi_match: Record<string, unknown> };
75 expect(mm.multi_match.fields).toEqual(["title^3", "body^1", "createdByName^0.5"]);
76 expect(mm.multi_match.query).toBe("q3 forecast");
77 expect(body.size).toBe(10);
78 expect(body.track_total_hits).toBe(10000);
79 });
80
81 it("always applies the tenant filter (single-index isolation)", () => {
82 const body = buildFullTextBody({ q: "x" }, viewer);
83 expect(queryBool(body).filter).toContainEqual({ terms: { workspaceId: ["ws1"] } });
84 });
85
86 it("applies type and filter chips to the filter array (camelCase fields)", () => {
87 const body = buildFullTextBody(
88 {
89 q: "x",
90 types: ["page", "block"],
91 filters: { teams: ["t9"], archived: false, updatedAfter: "2025-01-01T00:00:00Z" },
92 },
93 viewer,
94 );
95 const filter = queryBool(body).filter ?? [];
96 expect(filter).toContainEqual({ terms: { type: ["page", "block"] } });
97 expect(filter).toContainEqual({ terms: { teamId: ["t9"] } });
98 expect(filter).toContainEqual({ term: { archived: false } });
99 expect(filter).toContainEqual({ range: { updatedAt: { gte: "2025-01-01T00:00:00Z" } } });
100 });
101
102 it("default pageSize is 25", () => {
103 const body = buildFullTextBody({ q: "x" }, viewer);
104 expect(body.size).toBe(25);
105 });
106});
107
108describe("chunk doc exclusion (TASK-0099 — keyword search stays whole-doc)", () => {
109 it("chunkExclusion() targets type=chunk via a term clause", () => {
110 expect(chunkExclusion()).toEqual({ term: { type: "chunk" } });
111 });
112
113 it("full-text search carries a mandatory must_not chunk exclusion (inside the bool query)", () => {
114 const body = buildFullTextBody({ q: "x" }, viewer);
115 const bool = (body.query as { bool: { must_not?: unknown[] } }).bool;
116 expect(bool.must_not).toEqual([{ term: { type: "chunk" } }]);
117
118
119 const typed = buildFullTextBody({ q: "x", types: ["page"] }, viewer);
120 expect((typed.query as { bool: { must_not?: unknown[] } }).bool.must_not).toEqual([
121 { term: { type: "chunk" } },
122 ]);
123 });
124
125 it("prefix and fuzzy bodies carry the exclusion inside their bool query", () => {
126 const prefix = buildPrefixBody("abc", viewer) as { query: { bool: { must_not?: unknown[] } } };
127 expect(prefix.query.bool.must_not).toEqual([{ term: { type: "chunk" } }]);
128 const fuzzy = buildFuzzyBody("abc", viewer) as { query: { bool: { must_not?: unknown[] } } };
129 expect(fuzzy.query.bool.must_not).toEqual([{ term: { type: "chunk" } }]);
130 });
131
132 it("the hybrid BM25 leg excludes chunks while the kNN leg targets them", () => {
133 const body = buildHybridBody({ q: "x" }, viewer, [0.1]);
134 const q = body.query as { bool: { must_not?: unknown[] } };
135 expect(q.bool.must_not).toEqual([{ term: { type: "chunk" } }]);
136
137
138
139
140
141 expect(body.knn).toBeUndefined();
142 const knnOnly = buildHybridKnnOnlyBody([0.1], viewer) as {
143 query: { knn: { vector: { filter?: { bool?: { must_not?: unknown[] } } } } };
144 };
145 expect(knnOnly.query.knn.vector.filter?.bool?.must_not).toBeUndefined();
146 });
147});
148
149describe("buildPrefixBody", () => {
150 it("prefixes title.keyword and id (lowercased)", () => {
151 const body = buildPrefixBody("Q3", viewer, 5);
152 const q = body.query as { bool: { must: unknown[] } };
153 const inner = (q.bool.must[0] as { bool: { should: unknown[] } }).bool.should;
154 expect(inner).toContainEqual({ prefix: { "title.keyword": "q3" } });
155 expect(inner).toContainEqual({ prefix: { id: "q3" } });
156 expect(body.size).toBe(5);
157
158 expect((body.query as { bool: { filter: unknown[] } }).bool.filter).toContainEqual({
159 terms: { workspaceId: ["ws1"] },
160 });
161 });
162
163 it("matches case-insensitively via the analyzed title (spec §4.2 factory_prefix)", () => {
164
165
166
167
168 const body = buildPrefixBody("Q3", viewer, 5);
169 const q = body.query as { bool: { must: unknown[] } };
170 const inner = (q.bool.must[0] as { bool: { should: unknown[] } }).bool.should;
171 expect(inner).toContainEqual({ match_phrase_prefix: { title: "q3" } });
172
173
174 expect(inner).toContainEqual({ prefix: { "title.keyword": "q3" } });
175 expect(inner).toContainEqual({ prefix: { id: "q3" } });
176 });
177});
178
179describe("buildFuzzyBody", () => {
180 it("fuzzes title.keyword with AUTO by default", () => {
181 const body = buildFuzzyBody("revnue", viewer);
182 const q = body.query as { bool: { must: { fuzzy: { "title.keyword": { value: string; fuzziness: string } } }[] } };
183 expect(q.bool.must[0]!.fuzzy["title.keyword"]).toEqual({ value: "revnue", fuzziness: "AUTO" });
184 });
185
186 it("accepts explicit fuzziness", () => {
187 const body = buildFuzzyBody("revnue", viewer, { fuzziness: 2 });
188 const q = body.query as { bool: { must: { fuzzy: { "title.keyword": { fuzziness: number } } }[] } };
189 expect(q.bool.must[0]!.fuzzy["title.keyword"].fuzziness).toBe(2);
190 });
191});
192
193describe("buildHybridBody", () => {
194 it("is the BM25 (pass-1) leg: full-text body, no top-level knn (OpenSearch 2.15)", () => {
195 const body = buildHybridBody({ q: "forecast" }, viewer, [0.1, 0.2]);
196
197
198
199
200 expect(body.knn).toBeUndefined();
201 expect(body.query).toEqual(buildFullTextBody({ q: "forecast" }, viewer).query);
202
203 expect(queryBool(body).must).toHaveLength(1);
204 expect(queryBool(body).filter).toContainEqual({ terms: { workspaceId: ["ws1"] } });
205 });
206});
207
208describe("buildHybridKnnOnlyBody", () => {
209 it("emits the query.knn.<field> vector clause with the mandatory pre-filters", () => {
210 const body = buildHybridKnnOnlyBody([1], viewer, 40) as {
211 size: number;
212 query: { knn: { vector: { vector: number[]; k: number; filter: unknown } } };
213 };
214
215
216
217
218 expect(body.size).toBe(40);
219 expect(body.query.knn.vector.vector).toEqual([1]);
220 expect(body.query.knn.vector.k).toBe(40);
221 expect(body.query.knn.vector.filter).toEqual({
222 bool: { filter: [{ terms: { workspaceId: ["ws1"] } }, expect.anything()] },
223 });
224 });
225});
226
227describe("cursors", () => {
228 it("round-trips the search_after tuple", () => {
229 const tuple: [number, string, string] = [12.4, "2025-06-01T00:00:00Z", "page:pg_1"];
230 const cursor = encodeCursor(tuple);
231 expect(cursor).toBe(Buffer.from(JSON.stringify(tuple), "utf8").toString("base64url"));
232 expect(decodeCursor(cursor)).toEqual(tuple);
233 });
234
235 it("rejects malformed cursors", () => {
236 expect(() => decodeCursor(Buffer.from("not-json", "utf8").toString("base64url"))).toThrow();
237 expect(() => decodeCursor(Buffer.from(JSON.stringify(["a"])).toString("base64url"))).toThrow();
238 });
239
240 it("withSearchAfter adds sort + search_after (camelCase updatedAt)", () => {
241 const body = buildFullTextBody({ q: "x" }, viewer);
242 const paged = withSearchAfter(body, [1.0, "t", "id"]);
243 expect(paged.search_after).toEqual([1.0, "t", "id"]);
244 expect(paged.sort).toEqual([{ _score: "desc" }, { updatedAt: "desc" }, { id: "asc" }]);
245 });
246});
247
248describe("T-155 date sorts (additive branches)", () => {
249 it("dateSortField maps the date sorts and throws on relevance", () => {
250 expect(dateSortField("createdAt")).toBe("createdAt");
251 expect(dateSortField("updatedAt")).toBe("updatedAt");
252 expect(() => dateSortField("relevance")).toThrow();
253 });
254
255 it("dateSortClauses = newest-first on the date field + _id asc tiebreak", () => {
256 expect(dateSortClauses("createdAt")).toEqual([{ createdAt: "desc" }, { _id: "asc" }]);
257 expect(dateSortClauses("updatedAt")).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]);
258 });
259
260 it("withDateSort adds the date sort (and search_after when a cursor is present)", () => {
261 const body = buildFullTextBody({ q: "x" }, viewer);
262 const first = withDateSort(body, "createdAt");
263 expect(first.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]);
264 expect(first.search_after).toBeUndefined();
265
266 const paged = withDateSort(body, "updatedAt", ["2025-06-01T00:00:00Z", "page:pg_1"]);
267 expect(paged.sort).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]);
268 expect(paged.search_after).toEqual(["2025-06-01T00:00:00Z", "page:pg_1"]);
269 });
270
271 it("date cursor round-trips the (dateIso, id) tuple and rejects malformed", () => {
272 const tuple: [string, string] = ["2025-06-01T00:00:00Z", "page:pg_1"];
273 const cursor = encodeDateCursor(tuple);
274 expect(cursor).toBe(Buffer.from(JSON.stringify(tuple), "utf8").toString("base64url"));
275 expect(decodeDateCursor(cursor)).toEqual(tuple);
276 expect(() => decodeDateCursor(Buffer.from("not-json", "utf8").toString("base64url"))).toThrow();
277 expect(() => decodeDateCursor(Buffer.from(JSON.stringify(["a"])).toString("base64url"))).toThrow();
278 });
279
280 it("does NOT touch the relevance body (no sort key) — the tested path is unchanged", () => {
281 expect(buildFullTextBody({ q: "x" }, viewer)).not.toHaveProperty("sort");
282 expect(buildFullTextBody({ q: "x", sortBy: "relevance" }, viewer)).not.toHaveProperty("sort");
283 });
284});
285