From 365deb305a9fe895006b0eb7c8014565e21d252c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 7 Jun 2026 22:24:21 +0200 Subject: [PATCH] test(search): cover cursor, snippet, and searchNotes query body --- tests/opensearch_test.ts | 204 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/opensearch_test.ts diff --git a/tests/opensearch_test.ts b/tests/opensearch_test.ts new file mode 100644 index 0000000..5382311 --- /dev/null +++ b/tests/opensearch_test.ts @@ -0,0 +1,204 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { + decodeCursor, + encodeCursor, + firstSnippet, + searchNotes, +} from "../src/search/opensearch.ts"; +import { installFetchStub, jsonResponse } from "./_helpers/fetch_stub.ts"; + +const withOpenSearchEnv = (fn: () => Promise | void) => { + return async () => { + const previousUrl = Deno.env.get("OPENSEARCH_URL"); + const previousIndex = Deno.env.get("OPENSEARCH_INDEX"); + Deno.env.set("OPENSEARCH_URL", "http://opensearch.test"); + Deno.env.set("OPENSEARCH_INDEX", "notes"); + try { + await fn(); + } finally { + if (previousUrl === undefined) Deno.env.delete("OPENSEARCH_URL"); + else Deno.env.set("OPENSEARCH_URL", previousUrl); + if (previousIndex === undefined) Deno.env.delete("OPENSEARCH_INDEX"); + else Deno.env.set("OPENSEARCH_INDEX", previousIndex); + } + }; +}; + +Deno.test("encodeCursor and decodeCursor round-trip", () => { + const cursor = encodeCursor(3.14, "abc123"); + assertEquals(decodeCursor(cursor), [3.14, "abc123"]); +}); + +Deno.test("decodeCursor returns undefined on garbage input", () => { + assertEquals(decodeCursor("not-base64-at-all!!!"), undefined); + assertEquals(decodeCursor(btoa("not-json")), undefined); + assertEquals(decodeCursor(btoa(JSON.stringify([1]))), undefined); + assertEquals( + decodeCursor(btoa(JSON.stringify(["wrong", "type"]))), + undefined, + ); +}); + +Deno.test("firstSnippet prefers highlight.content > title > source content > title fallback", () => { + assertEquals( + firstSnippet( + { content: ["hl-content"], title: ["hl-title"] }, + { title: "src-title", content: "src-content" }, + ), + "hl-content", + ); + assertEquals( + firstSnippet({ title: ["hl-title"] }, { + title: "src-title", + content: "src-content", + }), + "hl-title", + ); + const longContent = "a".repeat(500); + assertEquals( + firstSnippet(undefined, { content: longContent, title: "ignored" }).length, + 200, + ); + assertEquals(firstSnippet(undefined, { title: "only-title" }), "only-title"); +}); + +Deno.test( + "searchNotes returns empty when OPENSEARCH_URL is unset", + async () => { + const previousUrl = Deno.env.get("OPENSEARCH_URL"); + Deno.env.delete("OPENSEARCH_URL"); + try { + const stub = installFetchStub(() => + jsonResponse({ should: "not be called" }) + ); + try { + const result = await searchNotes({ + q: "hello", + discoverableOnly: true, + limit: 10, + }); + assertEquals(result, { results: [] }); + assertEquals(stub.calls.length, 0); + } finally { + stub.restore(); + } + } finally { + if (previousUrl !== undefined) { + Deno.env.set("OPENSEARCH_URL", previousUrl); + } + } + }, +); + +Deno.test( + "searchNotes builds multi_match body with filters and cursor", + withOpenSearchEnv(async () => { + const stub = installFetchStub(() => + jsonResponse({ + hits: { + hits: [ + { + _source: { + did: "did:plc:a", + rkey: "rk-a", + title: "T", + content: "c", + }, + _score: 1.5, + highlight: { content: ["snippet"] }, + }, + { + _source: { + did: "did:plc:b", + rkey: "rk-b", + title: "U", + content: "d", + }, + _score: 1.0, + }, + ], + }, + }) + ); + try { + const result = await searchNotes({ + q: "hello world", + discoverableOnly: true, + did: "did:plc:a", + limit: 2, + cursor: encodeCursor(2.0, "rk-prev"), + }); + assertEquals(result.results.length, 2); + assertEquals(result.results[0].snippet, "snippet"); + assertEquals(result.results[1].snippet, "d"); + assertEquals(result.cursor, encodeCursor(1.0, "rk-b")); + + assertEquals(stub.calls.length, 1); + const sent = JSON.parse(stub.calls[0].body) as Record; + assertEquals(sent.size, 2); + assertEquals(sent.search_after, [2.0, "rk-prev"]); + const query = sent.query as { + bool: { must: unknown[]; filter: unknown[] }; + }; + const multi = + (query.bool.must[0] as { multi_match: Record }) + .multi_match; + assertEquals(multi.query, "hello world"); + assertEquals(multi.fields, ["title^2", "content"]); + assertEquals(multi.fuzziness, "AUTO"); + assertEquals(query.bool.filter, [ + { term: { discoverable: true } }, + { term: { did: "did:plc:a" } }, + ]); + } finally { + stub.restore(); + } + }), +); + +Deno.test( + "searchNotes returns cursor only when the page is full", + withOpenSearchEnv(async () => { + const stub = installFetchStub(() => + jsonResponse({ + hits: { + hits: [ + { + _source: { did: "did:plc:a", rkey: "rk", title: "T" }, + _score: 1, + }, + ], + }, + }) + ); + try { + const result = await searchNotes({ + q: "hi", + discoverableOnly: false, + limit: 5, + }); + assertEquals(result.results.length, 1); + assertEquals(result.cursor, undefined); + } finally { + stub.restore(); + } + }), +); + +Deno.test( + "searchNotes throws on non-ok response", + withOpenSearchEnv(async () => { + const stub = installFetchStub(() => new Response("boom", { status: 500 })); + try { + await assertRejects(() => + searchNotes({ + q: "hi", + discoverableOnly: false, + limit: 5, + }) + ); + } finally { + stub.restore(); + } + }), +);