Bladeren bron

RHL-024 refactor(search): implement search normalization and API input handling with tests

Code_Uwe 3 weken geleden
bovenliggende
commit
6057fb0850

+ 108 - 0
lib/frontend/search/normalizeState.js

@@ -0,0 +1,108 @@
+import {
+	SEARCH_SCOPE,
+	DEFAULT_SEARCH_LIMIT,
+	SEARCH_LIMITS,
+} from "@/lib/frontend/search/urlState";
+
+function isNonEmptyString(value) {
+	return typeof value === "string" && value.trim().length > 0;
+}
+
+function normalizeBranch(value) {
+	return isNonEmptyString(value) ? value.trim() : null;
+}
+
+function normalizeLimit(value) {
+	const n = Number(value);
+	if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT;
+	return SEARCH_LIMITS.includes(n) ? n : DEFAULT_SEARCH_LIMIT;
+}
+
+/**
+ * Normalize URL-derived search state for the current user and route context.
+ *
+ * Why this exists:
+ * - The URL is shareable and can contain stale/foreign params.
+ * - The UI route already defines the "branch context" (/:branch/search).
+ * - Branch users must never get cross-branch scope semantics in the UI.
+ *
+ * Policy:
+ * - branch users: force SINGLE on the current route branch
+ * - admin/dev: SINGLE always uses the current route branch
+ * - MULTI/ALL: do not carry a single-branch value (branch=null)
+ *
+ * @param {{
+ *   q: string|null,
+ *   scope: "single"|"multi"|"all",
+ *   branch: string|null,
+ *   branches: string[],
+ *   limit: number,
+ *   from: string|null,
+ *   to: string|null
+ * }|null|undefined} state
+ * @param {{ routeBranch: string, user: { role: string, branchId: string|null }|null }} options
+ * @returns {{
+ *   q: string|null,
+ *   scope: "single"|"multi"|"all",
+ *   branch: string|null,
+ *   branches: string[],
+ *   limit: number,
+ *   from: string|null,
+ *   to: string|null
+ * }}
+ */
+export function normalizeSearchUrlStateForUser(
+	state,
+	{ routeBranch, user } = {}
+) {
+	const route = normalizeBranch(routeBranch);
+
+	const base = {
+		q: isNonEmptyString(state?.q) ? state.q.trim() : null,
+		scope: state?.scope || SEARCH_SCOPE.SINGLE,
+		branch: normalizeBranch(state?.branch),
+		branches: Array.isArray(state?.branches) ? state.branches : [],
+		limit: normalizeLimit(state?.limit),
+		from: isNonEmptyString(state?.from) ? state.from.trim() : null,
+		to: isNonEmptyString(state?.to) ? state.to.trim() : null,
+	};
+
+	// Defensive: if no routeBranch is available (should not happen in this app),
+	// fall back to the parsed branch value.
+	if (!route) {
+		return {
+			...base,
+			branch: base.scope === SEARCH_SCOPE.SINGLE ? base.branch : null,
+			branches: base.scope === SEARCH_SCOPE.MULTI ? base.branches : [],
+		};
+	}
+
+	const role = user?.role;
+
+	// Branch users: force SINGLE on the current route branch.
+	if (role === "branch") {
+		return {
+			...base,
+			scope: SEARCH_SCOPE.SINGLE,
+			branch: route,
+			branches: [],
+		};
+	}
+
+	// Admin/dev: SINGLE always uses the current route branch (route context wins over URL param).
+	if (role === "admin" || role === "dev") {
+		if (base.scope === SEARCH_SCOPE.SINGLE) {
+			return { ...base, branch: route, branches: [] };
+		}
+
+		if (base.scope === SEARCH_SCOPE.MULTI) {
+			return { ...base, branch: null };
+		}
+
+		// ALL
+		return { ...base, scope: SEARCH_SCOPE.ALL, branch: null, branches: [] };
+	}
+
+	// Unknown roles: fail-safe to route-branch SINGLE.
+	return { ...base, scope: SEARCH_SCOPE.SINGLE, branch: route, branches: [] };
+}

+ 118 - 0
lib/frontend/search/normalizeState.test.js

@@ -0,0 +1,118 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	SEARCH_SCOPE,
+	DEFAULT_SEARCH_LIMIT,
+} from "@/lib/frontend/search/urlState";
+import { normalizeSearchUrlStateForUser } from "./normalizeState.js";
+
+describe("lib/frontend/search/normalizeState", () => {
+	it("forces SINGLE for branch users on the route branch", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: "NL99",
+				branches: ["NL01", "NL02"],
+				limit: 200,
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL01", user: { role: "branch", branchId: "NL01" } }
+		);
+
+		expect(normalized).toEqual({
+			q: "x",
+			scope: SEARCH_SCOPE.SINGLE,
+			branch: "NL01",
+			branches: [],
+			limit: 200,
+			from: null,
+			to: null,
+		});
+	});
+
+	it("admin/dev: SINGLE uses route branch even if URL carries a different branch", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL77",
+				branches: [],
+				limit: DEFAULT_SEARCH_LIMIT,
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL01", user: { role: "admin", branchId: null } }
+		);
+
+		expect(normalized.branch).toBe("NL01");
+		expect(normalized.scope).toBe(SEARCH_SCOPE.SINGLE);
+		expect(normalized.branches).toEqual([]);
+	});
+
+	it("admin/dev: MULTI keeps branches and clears branch", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.MULTI,
+				branch: "NL01",
+				branches: ["NL06", "NL20"],
+				limit: 50,
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL99", user: { role: "dev", branchId: null } }
+		);
+
+		expect(normalized.scope).toBe(SEARCH_SCOPE.MULTI);
+		expect(normalized.branch).toBe(null);
+		expect(normalized.branches).toEqual(["NL06", "NL20"]);
+		expect(normalized.limit).toBe(50);
+	});
+
+	it("admin/dev: ALL clears branch and branches", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: "NL01",
+				branches: ["NL06"],
+				limit: 200,
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL99", user: { role: "admin", branchId: null } }
+		);
+
+		expect(normalized).toEqual({
+			q: "x",
+			scope: SEARCH_SCOPE.ALL,
+			branch: null,
+			branches: [],
+			limit: 200,
+			from: null,
+			to: null,
+		});
+	});
+
+	it("unknown roles: fail-safe to SINGLE on route branch", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: "NL01",
+				branches: ["NL06"],
+				limit: 999, // invalid -> normalized to default
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL02", user: { role: "user", branchId: "NL02" } }
+		);
+
+		expect(normalized.scope).toBe(SEARCH_SCOPE.SINGLE);
+		expect(normalized.branch).toBe("NL02");
+		expect(normalized.limit).toBe(DEFAULT_SEARCH_LIMIT);
+	});
+});

+ 79 - 0
lib/frontend/search/resultsSorting.js

@@ -0,0 +1,79 @@
+export const SEARCH_RESULTS_SORT = Object.freeze({
+	RELEVANCE: "relevance",
+	DATE_DESC: "date_desc",
+	FILENAME_ASC: "filename_asc",
+});
+
+function pad2(value) {
+	return String(value || "").padStart(2, "0");
+}
+
+/**
+ * Build an ISO-like date key (YYYY-MM-DD) from a search item.
+ *
+ * Note:
+ * - The backend guarantees year/month/day for search items.
+ * - We still keep this defensive to avoid runtime crashes on malformed items.
+ *
+ * @param {any} item
+ * @returns {string}
+ */
+export function toSearchItemIsoDateKey(item) {
+	const y = String(item?.year || "");
+	const m = pad2(item?.month);
+	const d = pad2(item?.day);
+	return `${y}-${m}-${d}`;
+}
+
+/**
+ * Format the search item date as German UI string: DD.MM.YYYY
+ *
+ * @param {any} item
+ * @returns {string}
+ */
+export function formatSearchItemDateDe(item) {
+	const y = String(item?.year || "");
+	const m = pad2(item?.month);
+	const d = pad2(item?.day);
+	return `${d}.${m}.${y}`;
+}
+
+/**
+ * Sort search items according to the selected sort mode.
+ *
+ * Policy:
+ * - RELEVANCE: keep backend order (we return a shallow copy to avoid accidental mutation)
+ * - DATE_DESC: newest date first, then filename asc (stable & predictable)
+ * - FILENAME_ASC: filename asc
+ *
+ * @param {any[]} items
+ * @param {"relevance"|"date_desc"|"filename_asc"|string} sortMode
+ * @returns {any[]}
+ */
+export function sortSearchItems(items, sortMode) {
+	const arr = Array.isArray(items) ? [...items] : [];
+
+	if (sortMode === SEARCH_RESULTS_SORT.RELEVANCE) return arr;
+
+	if (sortMode === SEARCH_RESULTS_SORT.DATE_DESC) {
+		return arr.sort((a, b) => {
+			const da = toSearchItemIsoDateKey(a);
+			const db = toSearchItemIsoDateKey(b);
+
+			if (da !== db) return da < db ? 1 : -1;
+
+			const fa = String(a?.filename || "");
+			const fb = String(b?.filename || "");
+			return fa.localeCompare(fb, "de");
+		});
+	}
+
+	if (sortMode === SEARCH_RESULTS_SORT.FILENAME_ASC) {
+		return arr.sort((a, b) =>
+			String(a?.filename || "").localeCompare(String(b?.filename || ""), "de")
+		);
+	}
+
+	// Unknown sort mode => fail-safe to backend order.
+	return arr;
+}

+ 54 - 0
lib/frontend/search/resultsSorting.test.js

@@ -0,0 +1,54 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	SEARCH_RESULTS_SORT,
+	toSearchItemIsoDateKey,
+	formatSearchItemDateDe,
+	sortSearchItems,
+} from "./resultsSorting.js";
+
+describe("lib/frontend/search/resultsSorting", () => {
+	it("builds ISO date keys (YYYY-MM-DD) with zero padding", () => {
+		expect(toSearchItemIsoDateKey({ year: "2025", month: "1", day: "2" })).toBe(
+			"2025-01-02"
+		);
+	});
+
+	it("formats German date strings (DD.MM.YYYY) with zero padding", () => {
+		expect(formatSearchItemDateDe({ year: "2025", month: "1", day: "2" })).toBe(
+			"02.01.2025"
+		);
+	});
+
+	it("RELEVANCE keeps backend order", () => {
+		const items = [{ filename: "b.pdf" }, { filename: "a.pdf" }];
+		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.RELEVANCE);
+		expect(out.map((x) => x.filename)).toEqual(["b.pdf", "a.pdf"]);
+	});
+
+	it("DATE_DESC sorts newest date first, then filename asc", () => {
+		const items = [
+			{ year: "2025", month: "12", day: "18", filename: "b.pdf" },
+			{ year: "2025", month: "12", day: "18", filename: "a.pdf" },
+			{ year: "2025", month: "01", day: "02", filename: "z.pdf" },
+		];
+
+		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.DATE_DESC);
+
+		expect(
+			out.map((x) => `${toSearchItemIsoDateKey(x)}|${x.filename}`)
+		).toEqual(["2025-12-18|a.pdf", "2025-12-18|b.pdf", "2025-01-02|z.pdf"]);
+	});
+
+	it("FILENAME_ASC sorts by filename", () => {
+		const items = [
+			{ filename: "b.pdf" },
+			{ filename: "a.pdf" },
+			{ filename: "c.pdf" },
+		];
+
+		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.FILENAME_ASC);
+		expect(out.map((x) => x.filename)).toEqual(["a.pdf", "b.pdf", "c.pdf"]);
+	});
+});

+ 100 - 0
lib/frontend/search/searchApiInput.js

@@ -0,0 +1,100 @@
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+
+function isNonEmptyString(value) {
+	return typeof value === "string" && value.trim().length > 0;
+}
+
+/**
+ * Build the apiClient.search(...) input from URL state + current user context.
+ *
+ * Why this exists:
+ * - Search UI state is URL-driven and shareable.
+ * - Cursor is intentionally kept out of the URL by default (client state only).
+ * - Role/scoping rules must be enforced consistently (branch users are always single-branch).
+ *
+ * Return shape:
+ * - input: object for apiClient.search(...) or null (no search yet)
+ * - error: ApiClientError or null (local validation / fast-fail)
+ *
+ * @param {{
+ *   urlState: {
+ *     q: string|null,
+ *     scope: "single"|"multi"|"all",
+ *     branch: string|null,
+ *     branches: string[],
+ *     from: string|null,
+ *     to: string|null
+ *   },
+ *   routeBranch: string,
+ *   user: { role: string, branchId: string|null }|null,
+ *   cursor?: string|null,
+ *   limit?: number
+ * }} args
+ * @returns {{ input: any|null, error: ApiClientError|null }}
+ */
+export function buildSearchApiInput({
+	urlState,
+	routeBranch,
+	user,
+	cursor = null,
+	limit = 100,
+}) {
+	const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
+
+	// No query => no search request. UI should show an "idle" empty state.
+	if (!q) return { input: null, error: null };
+
+	const input = { q, limit };
+
+	// Keep from/to as pass-through for RHL-025 (future).
+	if (isNonEmptyString(urlState?.from)) input.from = urlState.from.trim();
+	if (isNonEmptyString(urlState?.to)) input.to = urlState.to.trim();
+
+	if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
+
+	const role = user?.role;
+
+	// Branch users: always restricted to the current route branch.
+	if (role === "branch") {
+		input.branch = routeBranch;
+		return { input, error: null };
+	}
+
+	// Admin/dev: respect scope rules from URL state.
+	if (role === "admin" || role === "dev") {
+		if (urlState.scope === SEARCH_SCOPE.ALL) {
+			input.scope = "all";
+			return { input, error: null };
+		}
+
+		if (urlState.scope === SEARCH_SCOPE.MULTI) {
+			const branches = Array.isArray(urlState.branches)
+				? urlState.branches
+				: [];
+
+			if (branches.length === 0) {
+				return {
+					input: null,
+					error: new ApiClientError({
+						status: 400,
+						code: "VALIDATION_SEARCH_BRANCHES",
+						message: "Missing branches parameter for multi scope",
+					}),
+				};
+			}
+
+			input.scope = "multi";
+			input.branches = branches;
+			return { input, error: null };
+		}
+
+		// SINGLE
+		input.branch = routeBranch;
+		return { input, error: null };
+	}
+
+	// Unknown role: fail-safe to single-branch using the route context.
+	input.branch = routeBranch;
+	return { input, error: null };
+}

+ 125 - 0
lib/frontend/search/searchApiInput.test.js

@@ -0,0 +1,125 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { buildSearchApiInput } from "./searchApiInput.js";
+import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+
+describe("lib/frontend/search/searchApiInput", () => {
+	it("returns {input:null} when q is missing", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: null,
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toBe(null);
+		expect(error).toBe(null);
+	});
+
+	it("branch users always use routeBranch (single) and ignore scope", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: "NL99",
+				branches: ["NL06"],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "branch", branchId: "NL01" },
+		});
+
+		expect(error).toBe(null);
+		expect(input).toEqual({ q: "x", limit: 100, branch: "NL01" });
+	});
+
+	it("admin/dev: ALL uses scope=all", () => {
+		const { input } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: null,
+				branches: [],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toEqual({ q: "x", limit: 100, scope: "all" });
+	});
+
+	it("admin/dev: MULTI uses scope=multi + branches", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.MULTI,
+				branch: null,
+				branches: ["NL06", "NL20"],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "dev", branchId: null },
+			cursor: "abc",
+			limit: 100,
+		});
+
+		expect(error).toBe(null);
+		expect(input).toEqual({
+			q: "x",
+			limit: 100,
+			cursor: "abc",
+			scope: "multi",
+			branches: ["NL06", "NL20"],
+		});
+	});
+
+	it("admin/dev: MULTI without branches returns a validation ApiClientError", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.MULTI,
+				branch: null,
+				branches: [],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toBe(null);
+		expect(error).toMatchObject({
+			name: "ApiClientError",
+			code: "VALIDATION_SEARCH_BRANCHES",
+			status: 400,
+		});
+	});
+
+	it("admin/dev: SINGLE uses routeBranch as branch param", () => {
+		const { input } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL99",
+				branches: [],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toEqual({ q: "x", limit: 100, branch: "NL01" });
+	});
+});

+ 36 - 0
lib/frontend/search/urlState.js

@@ -4,6 +4,11 @@ export const SEARCH_SCOPE = Object.freeze({
 	ALL: "all",
 });
 
+// Backend constraint (app/api/search/route.js): limit must be 50..200.
+// We expose a strict allowed set in the UI to keep URLs predictable and avoid 400s.
+export const SEARCH_LIMITS = Object.freeze([50, 100, 200]);
+export const DEFAULT_SEARCH_LIMIT = 100;
+
 /**
  * Read a query parameter from either:
  * - URLSearchParams (client-side `useSearchParams()`)
@@ -87,6 +92,18 @@ export function serializeBranchesCsv(branches) {
 	return cleaned.length > 0 ? cleaned.join(",") : null;
 }
 
+function normalizeLimit(raw) {
+	const s = normalizeTrimmedOrNull(raw);
+	if (!s) return DEFAULT_SEARCH_LIMIT;
+
+	if (!/^\d+$/.test(s)) return DEFAULT_SEARCH_LIMIT;
+
+	const n = Number(s);
+	if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT;
+
+	return SEARCH_LIMITS.includes(n) ? n : DEFAULT_SEARCH_LIMIT;
+}
+
 /**
  * Parse search URL state from query params.
  *
@@ -98,6 +115,7 @@ export function serializeBranchesCsv(branches) {
  * Notes:
  * - For SINGLE we default to the route branch if branch param is missing.
  * - For MULTI/ALL we intentionally return branch=null (UI is route-context but API scope is not single).
+ * - limit is restricted to SEARCH_LIMITS for predictable UX and to avoid backend 400s.
  *
  * @param {URLSearchParams|Record<string, any>|any} searchParams
  * @param {{ routeBranch?: string|null }=} options
@@ -106,6 +124,7 @@ export function serializeBranchesCsv(branches) {
  *   scope: "single"|"multi"|"all",
  *   branch: string|null,
  *   branches: string[],
+ *   limit: number,
  *   from: string|null,
  *   to: string|null
  * }}
@@ -118,6 +137,8 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
 
 	const branches = parseBranchesCsv(readParam(searchParams, "branches"));
 
+	const limit = normalizeLimit(readParam(searchParams, "limit"));
+
 	const from = normalizeTrimmedOrNull(readParam(searchParams, "from"));
 	const to = normalizeTrimmedOrNull(readParam(searchParams, "to"));
 
@@ -138,6 +159,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
 			scope,
 			branch,
 			branches: [],
+			limit,
 			from,
 			to,
 		};
@@ -149,6 +171,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
 			scope,
 			branch: null,
 			branches,
+			limit,
 			from,
 			to,
 		};
@@ -160,6 +183,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
 		scope: SEARCH_SCOPE.ALL,
 		branch: null,
 		branches: [],
+		limit,
 		from,
 		to,
 	};
@@ -173,6 +197,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
  * - scope
  * - branch
  * - branches
+ * - limit
  * - from
  * - to
  *
@@ -181,6 +206,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
  *   scope?: "single"|"multi"|"all"|string|null,
  *   branch?: string|null,
  *   branches?: string[]|null,
+ *   limit?: number|null,
  *   from?: string|null,
  *   to?: string|null
  * }} state
@@ -194,6 +220,11 @@ export function serializeSearchUrlState(state) {
 	const from = normalizeTrimmedOrNull(s.from);
 	const to = normalizeTrimmedOrNull(s.to);
 
+	const limit =
+		Number.isInteger(s.limit) && SEARCH_LIMITS.includes(s.limit)
+			? s.limit
+			: DEFAULT_SEARCH_LIMIT;
+
 	if (q) params.set("q", q);
 
 	// Scope: we only emit what the UI needs for shareability.
@@ -210,6 +241,11 @@ export function serializeSearchUrlState(state) {
 		if (branch) params.set("branch", branch);
 	}
 
+	// Only include non-default limit to keep URLs shorter.
+	if (limit !== DEFAULT_SEARCH_LIMIT) {
+		params.set("limit", String(limit));
+	}
+
 	// from/to are additive for RHL-025. We allow carrying them already.
 	if (from) params.set("from", from);
 	if (to) params.set("to", to);

+ 35 - 1
lib/frontend/search/urlState.test.js

@@ -3,6 +3,8 @@
 import { describe, it, expect } from "vitest";
 import {
 	SEARCH_SCOPE,
+	SEARCH_LIMITS,
+	DEFAULT_SEARCH_LIMIT,
 	parseBranchesCsv,
 	serializeBranchesCsv,
 	parseSearchUrlState,
@@ -40,6 +42,7 @@ describe("lib/frontend/search/urlState", () => {
 				scope: SEARCH_SCOPE.SINGLE,
 				branch: "NL01",
 				branches: [],
+				limit: DEFAULT_SEARCH_LIMIT,
 				from: null,
 				to: null,
 			});
@@ -53,6 +56,7 @@ describe("lib/frontend/search/urlState", () => {
 			expect(state.q).toBe("test");
 			expect(state.branch).toBe("NL02");
 			expect(state.branches).toEqual([]);
+			expect(SEARCH_LIMITS.includes(state.limit)).toBe(true);
 		});
 
 		it("parses ALL when scope=all is set (highest precedence)", () => {
@@ -61,6 +65,7 @@ describe("lib/frontend/search/urlState", () => {
 				scope: "all",
 				branch: "NL01",
 				branches: "NL06,NL20",
+				limit: "200",
 			});
 
 			const state = parseSearchUrlState(sp, { routeBranch: "NL99" });
@@ -70,6 +75,7 @@ describe("lib/frontend/search/urlState", () => {
 				scope: SEARCH_SCOPE.ALL,
 				branch: null,
 				branches: [],
+				limit: 200,
 				from: null,
 				to: null,
 			});
@@ -80,6 +86,7 @@ describe("lib/frontend/search/urlState", () => {
 				q: " reifen ",
 				scope: "multi",
 				branches: "NL06, NL20, NL06",
+				limit: "50",
 			});
 
 			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
@@ -88,6 +95,7 @@ describe("lib/frontend/search/urlState", () => {
 			expect(state.q).toBe("reifen");
 			expect(state.branch).toBe(null);
 			expect(state.branches).toEqual(["NL06", "NL20"]);
+			expect(state.limit).toBe(50);
 		});
 
 		it("parses MULTI when branches=... is present even without scope", () => {
@@ -100,6 +108,7 @@ describe("lib/frontend/search/urlState", () => {
 
 			expect(state.scope).toBe(SEARCH_SCOPE.MULTI);
 			expect(state.branches).toEqual(["NL06", "NL20"]);
+			expect(state.limit).toBe(DEFAULT_SEARCH_LIMIT);
 		});
 
 		it("keeps from/to when provided", () => {
@@ -108,12 +117,20 @@ describe("lib/frontend/search/urlState", () => {
 				branch: "NL01",
 				from: "2025-12-01",
 				to: "2025-12-31",
+				limit: "200",
 			});
 
 			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
 
 			expect(state.from).toBe("2025-12-01");
 			expect(state.to).toBe("2025-12-31");
+			expect(state.limit).toBe(200);
+		});
+
+		it("falls back to default limit for invalid values", () => {
+			const sp = new URLSearchParams({ q: "x", branch: "NL01", limit: "999" });
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+			expect(state.limit).toBe(DEFAULT_SEARCH_LIMIT);
 		});
 	});
 
@@ -123,6 +140,7 @@ describe("lib/frontend/search/urlState", () => {
 				q: "bridgestone",
 				scope: SEARCH_SCOPE.SINGLE,
 				branch: "NL01",
+				limit: DEFAULT_SEARCH_LIMIT,
 			});
 
 			expect(qs).toBe("q=bridgestone&branch=NL01");
@@ -133,6 +151,7 @@ describe("lib/frontend/search/urlState", () => {
 				q: "reifen",
 				scope: SEARCH_SCOPE.MULTI,
 				branches: ["NL06", "NL20"],
+				limit: DEFAULT_SEARCH_LIMIT,
 			});
 
 			expect(qs).toBe("q=reifen&scope=multi&branches=NL06%2CNL20");
@@ -142,21 +161,36 @@ describe("lib/frontend/search/urlState", () => {
 			const qs = serializeSearchUrlState({
 				q: "x",
 				scope: SEARCH_SCOPE.ALL,
+				limit: DEFAULT_SEARCH_LIMIT,
 			});
 
 			expect(qs).toBe("q=x&scope=all");
 		});
 
+		it("includes limit when non-default", () => {
+			const qs = serializeSearchUrlState({
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				limit: 200,
+			});
+
+			expect(qs).toBe("q=x&branch=NL01&limit=200");
+		});
+
 		it("includes from/to when present (future-proof for RHL-025)", () => {
 			const qs = serializeSearchUrlState({
 				q: "x",
 				scope: SEARCH_SCOPE.SINGLE,
 				branch: "NL01",
+				limit: 200,
 				from: "2025-12-01",
 				to: "2025-12-31",
 			});
 
-			expect(qs).toBe("q=x&branch=NL01&from=2025-12-01&to=2025-12-31");
+			expect(qs).toBe(
+				"q=x&branch=NL01&limit=200&from=2025-12-01&to=2025-12-31"
+			);
 		});
 	});
 });