test(search): cover the matcher, the scan bounds and the route

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:29:41 +02:00
1 parent 1766ffa3d2
commit 9b86ec2e10
4 files changed
+471 -10

No files matched your search

+176
View File
@@ -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<Response>;
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,
});
});
});
+141
View File
@@ -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<SearchCandidate, "fields"> {
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);
});
});
@@ -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");
});
});
+33 -10
View File
@@ -285,6 +285,18 @@ export function scoreCandidate(
return { matched, score, phrase };
}
/** `scoreCandidate` for a raw query. */
export function scoreForQuery(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
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[] = [];