import { describe, expect, it } from "vitest"; import { buildFuzzyBody, buildFullTextBody, buildHybridBody, buildHybridKnnOnlyBody, buildPrefixBody, chunkExclusion, CONTENT_INDEX_ALIAS, contentIndex, dateSortClauses, dateSortField, decodeCursor, decodeDateCursor, encodeCursor, encodeDateCursor, tenantFilter, visibilityFilter, withDateSort, withSearchAfter, } from "./queries.js"; import type { ViewerContext } from "./types.js"; const viewer: ViewerContext = { userId: "u1", workspaceIds: ["ws1"], teamIds: ["t1"] }; function queryBool(body: Record): { must?: unknown[]; filter?: unknown[] } { return (body.query as { bool: { must?: unknown[]; filter?: unknown[] } }).bool; } describe("content index (single-index layout decision)", () => { it("is the factory-content alias", () => { expect(contentIndex()).toBe("factory-content"); expect(CONTENT_INDEX_ALIAS).toBe("factory-content"); }); }); describe("tenantFilter", () => { it("restricts to the caller's workspaces (mandatory)", () => { expect(tenantFilter(viewer)).toEqual({ terms: { workspaceId: ["ws1"] } }); expect(tenantFilter({ userId: "u1", workspaceIds: ["a", "b"], teamIds: [] })).toEqual({ terms: { workspaceId: ["a", "b"] }, }); }); }); describe("visibilityFilter", () => { it("allows public, workspace, user and team grants (TASK-0100 S5: term/terms clauses)", () => { // The raw field-value form ({ "grants.public": true }) is rejected by // OpenSearch in a bool context — the clauses must be explicit term/terms. const f = visibilityFilter(viewer) as { bool: { should: unknown[] } }; const should = f.bool.should; expect(should).toContainEqual({ term: { "grants.public": true } }); expect(should).toContainEqual({ term: { "grants.workspace": true } }); expect(should).toContainEqual({ term: { "grants.users": "u1" } }); expect(should).toContainEqual({ terms: { "grants.teams": ["t1"] } }); }); it("with no teams the should list has 3 clauses (public, workspace, user)", () => { const f = visibilityFilter({ userId: "u1", workspaceIds: ["ws1"], teamIds: [] }) as { bool: { should: unknown[] }; }; expect(f.bool.should).toHaveLength(3); expect(f.bool.should).toContainEqual({ term: { "grants.public": true } }); expect(f.bool.should).toContainEqual({ term: { "grants.workspace": true } }); expect(f.bool.should).toContainEqual({ term: { "grants.users": "u1" } }); }); }); describe("buildFullTextBody", () => { it("builds a bool must + filter with weighted multi_match", () => { const body = buildFullTextBody({ q: "q3 forecast", pageSize: 10 }, viewer); const q = queryBool(body); const mm = (q.must as Record[])[0] as { multi_match: Record }; expect(mm.multi_match.fields).toEqual(["title^3", "body^1", "createdByName^0.5"]); expect(mm.multi_match.query).toBe("q3 forecast"); expect(body.size).toBe(10); expect(body.track_total_hits).toBe(10000); }); it("always applies the tenant filter (single-index isolation)", () => { const body = buildFullTextBody({ q: "x" }, viewer); expect(queryBool(body).filter).toContainEqual({ terms: { workspaceId: ["ws1"] } }); }); it("applies type and filter chips to the filter array (camelCase fields)", () => { const body = buildFullTextBody( { q: "x", types: ["page", "block"], filters: { teams: ["t9"], archived: false, updatedAfter: "2025-01-01T00:00:00Z" }, }, viewer, ); const filter = queryBool(body).filter ?? []; expect(filter).toContainEqual({ terms: { type: ["page", "block"] } }); expect(filter).toContainEqual({ terms: { teamId: ["t9"] } }); expect(filter).toContainEqual({ term: { archived: false } }); expect(filter).toContainEqual({ range: { updatedAt: { gte: "2025-01-01T00:00:00Z" } } }); }); it("default pageSize is 25", () => { const body = buildFullTextBody({ q: "x" }, viewer); expect(body.size).toBe(25); }); }); describe("chunk doc exclusion (TASK-0099 — keyword search stays whole-doc)", () => { it("chunkExclusion() targets type=chunk via a term clause", () => { expect(chunkExclusion()).toEqual({ term: { type: "chunk" } }); }); it("full-text search carries a mandatory must_not chunk exclusion (inside the bool query)", () => { const body = buildFullTextBody({ q: "x" }, viewer); const bool = (body.query as { bool: { must_not?: unknown[] } }).bool; expect(bool.must_not).toEqual([{ term: { type: "chunk" } }]); // even with a `types` filter — the exclusion is independent of it, // because the API omits the types clause when unspecified const typed = buildFullTextBody({ q: "x", types: ["page"] }, viewer); expect((typed.query as { bool: { must_not?: unknown[] } }).bool.must_not).toEqual([ { term: { type: "chunk" } }, ]); }); it("prefix and fuzzy bodies carry the exclusion inside their bool query", () => { const prefix = buildPrefixBody("abc", viewer) as { query: { bool: { must_not?: unknown[] } } }; expect(prefix.query.bool.must_not).toEqual([{ term: { type: "chunk" } }]); const fuzzy = buildFuzzyBody("abc", viewer) as { query: { bool: { must_not?: unknown[] } } }; expect(fuzzy.query.bool.must_not).toEqual([{ term: { type: "chunk" } }]); }); it("the hybrid BM25 leg excludes chunks while the kNN leg targets them", () => { const body = buildHybridBody({ q: "x" }, viewer, [0.1]); const q = body.query as { bool: { must_not?: unknown[] } }; expect(q.bool.must_not).toEqual([{ term: { type: "chunk" } }]); // TASK-0125: pass 1 (buildHybridBody) is BM25-only — no top-level `knn` // block (OpenSearch 2.15 rejects the ES-style top-level `knn` key, and // `query.knn` cannot share a body with a `bool` query). The vector leg is // the separate kNN-only pass; its pre-filter must NOT exclude chunks — // they are the vector leg's target. expect(body.knn).toBeUndefined(); const knnOnly = buildHybridKnnOnlyBody([0.1], viewer) as { query: { knn: { vector: { filter?: { bool?: { must_not?: unknown[] } } } } }; }; expect(knnOnly.query.knn.vector.filter?.bool?.must_not).toBeUndefined(); }); }); describe("buildPrefixBody", () => { it("prefixes title.keyword and id (lowercased)", () => { const body = buildPrefixBody("Q3", viewer, 5); const q = body.query as { bool: { must: unknown[] } }; const inner = (q.bool.must[0] as { bool: { should: unknown[] } }).bool.should; expect(inner).toContainEqual({ prefix: { "title.keyword": "q3" } }); expect(inner).toContainEqual({ prefix: { id: "q3" } }); expect(body.size).toBe(5); // tenant + permission filters are present expect((body.query as { bool: { filter: unknown[] } }).bool.filter).toContainEqual({ terms: { workspaceId: ["ws1"] }, }); }); it("matches case-insensitively via the analyzed title (spec §4.2 factory_prefix)", () => { // The lowercased prefix must also hit the analyzed (lowercased) `title` // field, otherwise a title whose case differs from the prefix (e.g. an // uppercase "Q3" title vs the lowercased "q3" prefix) is invisible to // autocomplete even though full-text finds it. const body = buildPrefixBody("Q3", viewer, 5); const q = body.query as { bool: { must: unknown[] } }; const inner = (q.bool.must[0] as { bool: { should: unknown[] } }).bool.should; expect(inner).toContainEqual({ match_phrase_prefix: { title: "q3" } }); // ...while still carrying the exact keyword prefix (fast path for // already-lowercase titles) and the id prefix. expect(inner).toContainEqual({ prefix: { "title.keyword": "q3" } }); expect(inner).toContainEqual({ prefix: { id: "q3" } }); }); }); describe("buildFuzzyBody", () => { it("fuzzes title.keyword with AUTO by default", () => { const body = buildFuzzyBody("revnue", viewer); const q = body.query as { bool: { must: { fuzzy: { "title.keyword": { value: string; fuzziness: string } } }[] } }; expect(q.bool.must[0]!.fuzzy["title.keyword"]).toEqual({ value: "revnue", fuzziness: "AUTO" }); }); it("accepts explicit fuzziness", () => { const body = buildFuzzyBody("revnue", viewer, { fuzziness: 2 }); const q = body.query as { bool: { must: { fuzzy: { "title.keyword": { fuzziness: number } } }[] } }; expect(q.bool.must[0]!.fuzzy["title.keyword"].fuzziness).toBe(2); }); }); describe("buildHybridBody", () => { it("is the BM25 (pass-1) leg: full-text body, no top-level knn (OpenSearch 2.15)", () => { const body = buildHybridBody({ q: "forecast" }, viewer, [0.1, 0.2]); // TASK-0125: the old body carried an ES-style top-level `knn` block, which // OpenSearch 2.15 rejects with 400 (verified live); `query.knn` cannot be // combined with a `bool`/full-text query in one body, so pass 1 is // full-text only and the vector leg runs as its own request (pass 2). expect(body.knn).toBeUndefined(); expect(body.query).toEqual(buildFullTextBody({ q: "forecast" }, viewer).query); // the BM25 query is there + tenant filter present expect(queryBool(body).must).toHaveLength(1); expect(queryBool(body).filter).toContainEqual({ terms: { workspaceId: ["ws1"] } }); }); }); describe("buildHybridKnnOnlyBody", () => { it("emits the query.knn. vector clause with the mandatory pre-filters", () => { const body = buildHybridKnnOnlyBody([1], viewer, 40) as { size: number; query: { knn: { vector: { vector: number[]; k: number; filter: unknown } } }; }; // TASK-0125: OpenSearch 2.15's kNN query is the field-keyed `query.knn` // form (the ES-style top-level `knn` key 400s), with an inline `filter` // pre-filter (FAISS engine) — tenant + visibility, so the ANN search only // sees the viewer's own docs. `num_candidates` is not part of this grammar. expect(body.size).toBe(40); expect(body.query.knn.vector.vector).toEqual([1]); expect(body.query.knn.vector.k).toBe(40); expect(body.query.knn.vector.filter).toEqual({ bool: { filter: [{ terms: { workspaceId: ["ws1"] } }, expect.anything()] }, }); }); }); describe("cursors", () => { it("round-trips the search_after tuple", () => { const tuple: [number, string, string] = [12.4, "2025-06-01T00:00:00Z", "page:pg_1"]; const cursor = encodeCursor(tuple); expect(cursor).toBe(Buffer.from(JSON.stringify(tuple), "utf8").toString("base64url")); expect(decodeCursor(cursor)).toEqual(tuple); }); it("rejects malformed cursors", () => { expect(() => decodeCursor(Buffer.from("not-json", "utf8").toString("base64url"))).toThrow(); expect(() => decodeCursor(Buffer.from(JSON.stringify(["a"])).toString("base64url"))).toThrow(); }); it("withSearchAfter adds sort + search_after (camelCase updatedAt)", () => { const body = buildFullTextBody({ q: "x" }, viewer); const paged = withSearchAfter(body, [1.0, "t", "id"]); expect(paged.search_after).toEqual([1.0, "t", "id"]); expect(paged.sort).toEqual([{ _score: "desc" }, { updatedAt: "desc" }, { id: "asc" }]); }); }); describe("T-155 date sorts (additive branches)", () => { it("dateSortField maps the date sorts and throws on relevance", () => { expect(dateSortField("createdAt")).toBe("createdAt"); expect(dateSortField("updatedAt")).toBe("updatedAt"); expect(() => dateSortField("relevance")).toThrow(); }); it("dateSortClauses = newest-first on the date field + _id asc tiebreak", () => { expect(dateSortClauses("createdAt")).toEqual([{ createdAt: "desc" }, { _id: "asc" }]); expect(dateSortClauses("updatedAt")).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]); }); it("withDateSort adds the date sort (and search_after when a cursor is present)", () => { const body = buildFullTextBody({ q: "x" }, viewer); const first = withDateSort(body, "createdAt"); expect(first.sort).toEqual([{ createdAt: "desc" }, { _id: "asc" }]); expect(first.search_after).toBeUndefined(); const paged = withDateSort(body, "updatedAt", ["2025-06-01T00:00:00Z", "page:pg_1"]); expect(paged.sort).toEqual([{ updatedAt: "desc" }, { _id: "asc" }]); expect(paged.search_after).toEqual(["2025-06-01T00:00:00Z", "page:pg_1"]); }); it("date cursor round-trips the (dateIso, id) tuple and rejects malformed", () => { const tuple: [string, string] = ["2025-06-01T00:00:00Z", "page:pg_1"]; const cursor = encodeDateCursor(tuple); expect(cursor).toBe(Buffer.from(JSON.stringify(tuple), "utf8").toString("base64url")); expect(decodeDateCursor(cursor)).toEqual(tuple); expect(() => decodeDateCursor(Buffer.from("not-json", "utf8").toString("base64url"))).toThrow(); expect(() => decodeDateCursor(Buffer.from(JSON.stringify(["a"])).toString("base64url"))).toThrow(); }); it("does NOT touch the relevance body (no sort key) — the tested path is unchanged", () => { expect(buildFullTextBody({ q: "x" }, viewer)).not.toHaveProperty("sort"); expect(buildFullTextBody({ q: "x", sortBy: "relevance" }, viewer)).not.toHaveProperty("sort"); }); });