diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts new file mode 100644 index 000000000..82e3ae25f --- /dev/null +++ b/src/app/api/search/__tests__/route.test.ts @@ -0,0 +1,176 @@ +/** + * `GET /api/search` — admission, validation, the rate limit, and what the + * route hands the search and the log. + * + * The search itself is `searchRecords`, covered by its own unit tests and by + * `tests/integration/record-search.test.ts` against Postgres; here it is a + * spy, so the test can assert exactly what the route passes it. + * + * Mutation checks (each run, each seen red): + * - admit on `("read", "profile")` instead of the whole record → "admits + * the whole record only" goes red; + * - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red; + * - put the query into the annotation → "never logs the query" goes red; + * - pass `actor.id` as the record → "searches the record the session acts + * for" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + checkRateLimit: vi.fn(), + searchRecords: vi.fn(), + annotate: vi.fn(), + resolveModuleMap: vi.fn(), + domainVisible: vi.fn(() => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: mocks.annotate, + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: mocks.resolveModuleMap, +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => mocks.domainVisible), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/search/search-records", () => ({ + searchRecords: mocks.searchRecords, +})); + +import { GET } from "../route"; + +const handler = GET as unknown as (req: NextRequest) => Promise; + +function request(query: string): NextRequest { + return new NextRequest(`http://localhost/api/search?${query}`); +} + +const OWNER = { id: "owner-1", locale: "de" }; +const ACTOR = { id: "actor-1", locale: "en" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: OWNER, + actor: ACTOR, + grantId: "grant-1", + }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.resolveModuleMap.mockResolvedValue({ illness: false }); + mocks.searchRecords.mockResolvedValue({ + groups: [ + { + kind: "visit", + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Schulter", marks: [[0, 8]] }, + context: [], + snippet: null, + day: "2026-01-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + more: false, + }, + ], + truncated: false, + stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 }, + }); +}); + +describe("GET /api/search", () => { + it("admits the whole record only, at read", async () => { + await handler(request("q=schulter")); + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record"); + }); + + it("propagates a refused admission without searching", async () => { + mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied")); + await expect(handler(request("q=schulter"))).rejects.toThrow("denied"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("searches the record the session acts for, with its masks", async () => { + const res = await handler(request("q=Schulter&limit=7")); + expect(res.status).toBe(200); + expect(mocks.searchRecords).toHaveBeenCalledTimes(1); + const args = mocks.searchRecords.mock.calls[0][0]; + expect(args.recordId).toBe("owner-1"); + expect(args.query).toBe("Schulter"); + expect(args.limit).toBe(7); + expect(args.modules).toEqual({ illness: false }); + expect(args.domainVisible).toBe(mocks.domainVisible); + expect(args.timezone).toBe("Europe/Berlin"); + // Labels follow the reader, not the record. + expect(args.displayLocale).toBe("en"); + const body = await res.json(); + expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1"); + expect(body.data.truncated).toBe(false); + expect(body.data.stats).toBeUndefined(); + }); + + it("defaults the limit", async () => { + await handler(request("q=knie")); + expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5); + }); + + it.each([ + ["a one-letter query", "q=a"], + ["a blank query", "q=%20%20%20"], + ["a missing query", "limit=5"], + ["an over-long query", `q=${"x".repeat(101)}`], + ["a zero limit", "q=knie&limit=0"], + ["a limit above the cap", "q=knie&limit=21"], + ["a non-numeric limit", "q=knie&limit=lots"], + ])("refuses %s with 422 and never searches", async (_label, query) => { + const res = await handler(request(query)); + expect(res.status).toBe(422); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("does not echo a refused query", async () => { + const secret = "Psychotherapie".repeat(8); + const res = await handler(request(`q=${secret}`)); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain("Psychotherapie"); + }); + + it("rate limits per actor before reading anything", async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false }); + const res = await handler(request("q=schulter")); + expect(res.status).toBe(429); + expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("never logs the query, only its length and the counts", async () => { + await handler(request("q=Psychotherapie")); + const logged = JSON.stringify(mocks.annotate.mock.calls); + expect(logged).not.toMatch(/psychotherapie/i); + const run = mocks.annotate.mock.calls.find( + ([e]) => e.action.name === "search.query.run", + ); + expect(run?.[0].meta).toMatchObject({ + query_length: 14, + hits: 1, + groups: 1, + truncated: false, + delegated: true, + }); + }); +}); diff --git a/src/lib/search/__tests__/match.test.ts b/src/lib/search/__tests__/match.test.ts new file mode 100644 index 000000000..d70fcc870 --- /dev/null +++ b/src/lib/search/__tests__/match.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { + foldForMatch, + isHit, + markMatches, + scoreForQuery, + queryWords, + scoreCandidate, + snippetOf, + WEIGHT, + type SearchCandidate, +} from "../match"; + +function candidate( + fields: SearchCandidate["fields"], +): Pick { + return { fields }; +} + +const hit = (text: string, query: string, weight: number = WEIGHT.primary) => + isHit(scoreForQuery(candidate([{ text, weight }]), query)); + +/** The marked substrings, for readable assertions. */ +const marks = (text: string, query: string) => + markMatches(text, query).map(([s, e]) => text.slice(s, e)); + +describe("folding", () => { + it("ignores case, accents and the German keyboard spellings", () => { + expect(foldForMatch("Schulter")).toBe("schulter"); + expect(foldForMatch("Hüfte")).toBe(foldForMatch("Huefte")); + expect(foldForMatch("Fuß")).toBe(foldForMatch("Fuss")); + expect(foldForMatch("Müdigkeit")).toBe("mudigkeit"); + expect(foldForMatch(" FSME Impfung ")).toBe("fsme impfung"); + }); + + it("matches a term whatever way either side spells the umlaut", () => { + expect(hit("Schulter links", "schulter")).toBe(true); + expect(hit("schulter links", "SCHULTER")).toBe(true); + expect(hit("Hüftschmerzen", "hueft")).toBe(true); + expect(hit("Hueftschmerzen", "hüft")).toBe(true); + expect(hit("Fußgelenk", "fuss")).toBe(true); + expect(hit("Schläfrig", "schlaefrig")).toBe(true); + }); + + it("does not match what is not there", () => { + expect(hit("Knie rechts", "schulter")).toBe(false); + // A short word must start a word: "arm" is not inside "Darm". + expect(hit("Darmspiegelung", "arm")).toBe(false); + expect(hit("Armbruch", "arm")).toBe(true); + }); + + it("finds a body part across languages", () => { + expect(hit("Schulter", "shoulder")).toBe(true); + expect(hit("left shoulder", "Schulter")).toBe(true); + }); +}); + +describe("queryWords", () => { + it("drops filler and keeps the tokeniser's form", () => { + expect(queryWords("meine letzte Schulter OP")).toEqual(["schulter"]); + }); +}); + +describe("marks", () => { + it("marks the original characters, not the folded ones", () => { + expect(marks("Hüftgelenk und Hüfte", "hueft")).toEqual(["Hüft", "Hüft"]); + expect(marks("Fußgelenk", "fuss")).toEqual(["Fuß"]); + expect(marks("Huefte", "hüfte")).toEqual(["Huefte"]); + expect(marks("SCHULTER links", "schulter")).toEqual(["SCHULTER"]); + }); + + it("marks a short word only at a word start", () => { + expect(marks("Arm und Darm", "arm")).toEqual(["Arm"]); + }); + + it("marks a query too short for the tokeniser by itself", () => { + expect(marks("Hb und HbA1c", "hb")).toEqual(["Hb", "Hb"]); + }); + + it("merges overlapping marks", () => { + expect(markMatches("Schulterschmerz", "schulter schulterschmerz")).toEqual([ + [0, 15], + ]); + }); + + it("keeps ranges inside the text for multi-unit characters", () => { + const text = "😀 Schulter"; + const [[start, end]] = markMatches(text, "schulter"); + expect(text.slice(start, end)).toBe("Schulter"); + }); +}); + +describe("snippetOf", () => { + it("returns the whole short text with its marks", () => { + const s = snippetOf("Schmerzen in der Schulter", "schulter"); + expect(s?.text).toBe("Schmerzen in der Schulter"); + expect(s?.marks).toEqual([[17, 25]]); + }); + + it("cuts a long text around the first match at word boundaries", () => { + const filler = "Lorem ipsum dolor sit amet ".repeat(20); + const s = snippetOf(`${filler}die Schulter tut weh ${filler}`, "schulter"); + expect(s).not.toBeNull(); + expect(s!.text.startsWith("…")).toBe(true); + expect(s!.text.endsWith("…")).toBe(true); + expect(s!.text.length).toBeLessThanOrEqual(142); + const [[start, end]] = s!.marks; + expect(s!.text.slice(start, end)).toBe("Schulter"); + }); + + it("is null when the text does not contain the query", () => { + expect(snippetOf("Knie", "schulter")).toBeNull(); + }); +}); + +describe("ranking inputs", () => { + it("a title hit scores above a note hit", () => { + const title = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.primary }]), + "schulter", + ); + const note = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.tertiary, body: true }]), + "schulter", + ); + expect(title.score).toBeGreaterThan(note.score); + }); + + it("counts each matched word once", () => { + const s = scoreCandidate( + candidate([ + { text: "Schulter links", weight: WEIGHT.primary }, + { text: "links", weight: WEIGHT.secondary }, + ]), + ["schulter", "links"], + "schulter links", + ); + expect(s.matched).toBe(2); + }); +}); diff --git a/src/lib/search/__tests__/search-records.test.ts b/src/lib/search/__tests__/search-records.test.ts new file mode 100644 index 000000000..297838958 --- /dev/null +++ b/src/lib/search/__tests__/search-records.test.ts @@ -0,0 +1,121 @@ +/** + * The bounds of the record search and the masks that pick its sources. The + * reads themselves are exercised against Postgres in + * `tests/integration/record-search.test.ts`. + * + * Mutation checks (each run, each seen red): + * - drop the deadline check in `scanNewest` → "stops when the budget has + * run out" goes red; + * - drop the `scanned >= maxRows` stop → "never reads more than the cap" + * goes red; + * - drop the domain test from `readableSources` → "drops a source the grant + * does not cover" goes red. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import type { SearchCandidate } from "../match"; +import { + readableSources, + scanNewest, + type ScanBudget, +} from "../search-records"; + +function budget(deadlineIn = 10_000, clock = { t: 0 }): ScanBudget { + return { + deadline: clock.t + deadlineIn, + now: () => clock.t, + truncated: false, + rows: 0, + }; +} + +/** A source of `total` rows, newest first, every `hitEvery`-th one a hit. */ +function source(total: number, hitEvery = 1) { + const rows = Array.from({ length: total }, (_, i) => ({ id: `r${i}`, i })); + const reads: number[] = []; + return { + reads, + read: async (cursor: string | null, take: number) => { + const start = cursor ? rows.findIndex((r) => r.id === cursor) + 1 : 0; + reads.push(take); + return rows.slice(start, start + take); + }, + match: (row: { id: string; i: number }) => + row.i % hitEvery === 0 + ? ({ id: row.id } as unknown as SearchCandidate) + : null, + }; +} + +describe("scanNewest", () => { + it("reads a small source to its end and is not truncated", async () => { + const b = budget(); + const s = source(30, 10); + const out = await scanNewest({ budget: b, wanted: 5, batch: 10, ...s }); + expect(out.map((c) => c.id)).toEqual(["r0", "r10", "r20"]); + expect(b.truncated).toBe(false); + expect(b.rows).toBe(30); + }); + + it("stops once it holds more hits than wanted", async () => { + const b = budget(); + const s = source(10_000, 1); + const out = await scanNewest({ budget: b, wanted: 3, batch: 10, ...s }); + expect(out.length).toBe(10); + expect(s.reads).toEqual([10]); + expect(b.truncated).toBe(false); + }); + + it("never reads more than the cap, and says so", async () => { + const b = budget(); + const s = source(10_000, 1_000_000); + await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s }); + expect(b.rows).toBe(450); + expect(s.reads).toEqual([100, 100, 100, 100, 50]); + expect(b.truncated).toBe(true); + }); + + it("stops when the budget has run out, and says so", async () => { + const clock = { t: 0 }; + const b = budget(100, clock); + const s = source(10_000, 1_000_000); + const read = async (cursor: string | null, take: number) => { + clock.t += 60; + return s.read(cursor, take); + }; + await scanNewest({ budget: b, wanted: 3, batch: 10, read, match: s.match }); + expect(s.reads.length).toBe(2); + expect(b.truncated).toBe(true); + }); +}); + +describe("readableSources", () => { + const all = () => true; + + it("reads everything on an owner's record with every module on", () => { + expect(readableSources({}, all)).toHaveLength(13); + }); + + it("drops the sources of a switched-off module, never a core one", () => { + const sources = readableSources( + { illness: false, mood: false, inboundDocuments: false }, + all, + ); + expect(sources).not.toContain("condition"); + expect(sources).not.toContain("symptom"); + expect(sources).not.toContain("symptomNote"); + expect(sources).not.toContain("illnessNote"); + expect(sources).not.toContain("moodNote"); + expect(sources).not.toContain("document"); + expect(sources).toContain("visit"); + expect(sources).toContain("measurementNote"); + }); + + it("drops a source the grant does not cover", () => { + const sources = readableSources({}, (domain) => domain !== "mind"); + expect(sources).not.toContain("moodNote"); + expect(sources).toContain("visit"); + }); +}); diff --git a/src/lib/search/match.ts b/src/lib/search/match.ts index 7e04cd7ea..2bc858975 100644 --- a/src/lib/search/match.ts +++ b/src/lib/search/match.ts @@ -285,6 +285,18 @@ export function scoreCandidate( return { matched, score, phrase }; } +/** `scoreCandidate` for a raw query. */ +export function scoreForQuery( + candidate: Pick, + query: string, +): CandidateScore { + return scoreCandidate( + candidate, + queryWords(query), + normalizeSearchText(query), + ); +} + /** Whether a scored candidate stays in the result at all. */ export function isHit(score: CandidateScore): boolean { return score.matched > 0 || score.phrase; @@ -355,7 +367,9 @@ function foldWithMap(text: string): { from: number[]; to: number[]; } { - const chars: string[] = []; + // One entry per UTF-16 unit of the folded string, so an index `indexOf` + // returns can be looked up directly, astral characters included. + const units: string[] = []; const from: number[] = []; const to: number[] = []; // Whether the vowel at that position has already taken in its "e". @@ -367,27 +381,29 @@ function foldWithMap(text: string): { if (/\s/u.test(ch)) piece = " "; piece = piece.replace(/ß/g, "ss"); for (const out of piece) { - const prev = chars.length - 1; + const prev = units.length - 1; // "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's // source range instead of adding a character. if ( out === "e" && prev >= 0 && - /[aou]/.test(chars[prev]) && + /[aou]/.test(units[prev]) && !absorbed[prev] ) { to[prev] = end; absorbed[prev] = true; continue; } - chars.push(out); - from.push(index); - to.push(end); - absorbed.push(false); + for (let k = 0; k < out.length; k++) { + units.push(out[k]); + from.push(index); + to.push(end); + absorbed.push(false); + } } index = end; } - return { folded: chars.join(""), from, to }; + return { folded: units.join(""), from, to }; } /** @@ -397,9 +413,16 @@ function foldWithMap(text: string): { * text holds a different word), so a result can match with nothing marked. */ export function markMatches(text: string, query: string): MarkRange[] { + // The matcher's words; a query too short for the tokeniser ("Hb") is + // found by the phrase rule, so it is marked by its folded self. + const tokens = queryWords(query); const words = [ - ...new Set(queryWords(query).map((w) => foldGerman(w))), - ].filter(Boolean); + ...new Set( + (tokens.length > 0 ? tokens : foldForMatch(query).split(" ")).map((w) => + foldGerman(w), + ), + ), + ].filter((w) => w.length >= 2); if (words.length === 0 || text.length === 0) return []; const { folded, from, to } = foldWithMap(text); const ranges: MarkRange[] = [];