Pārlūkot izejas kodu

RHL-025 fix(validation): update date range validation messages and add tests for edge cases

Code_Uwe 2 nedēļas atpakaļ
vecāks
revīzija
26c2820eef

+ 1 - 1
lib/frontend/search/errorMapping.js

@@ -46,7 +46,7 @@ const VALIDATION_MAP = Object.freeze({
 	},
 	VALIDATION_SEARCH_RANGE: {
 		title: "Ungültiger Zeitraum",
-		description: "Bitte prüfen Sie den Zeitraum und versuchen Sie es erneut.",
+		description: "Das Startdatum darf nicht nach dem Enddatum liegen.",
 	},
 });
 

+ 15 - 0
lib/frontend/search/errorMapping.test.js

@@ -74,4 +74,19 @@ describe("lib/frontend/search/errorMapping", () => {
 		const mapped = mapSearchError(new Error("boom"));
 		expect(mapped).toMatchObject({ kind: "generic" });
 	});
+
+	it("maps VALIDATION_SEARCH_RANGE to a specific German message", () => {
+		const err = new ApiClientError({
+			status: 400,
+			code: "VALIDATION_SEARCH_RANGE",
+			message: "Invalid date range",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped).toMatchObject({
+			kind: "validation",
+			title: "Ungültiger Zeitraum",
+		});
+		expect(mapped.description).toMatch(/Startdatum/i);
+	});
 });

+ 62 - 10
lib/frontend/search/searchApiInput.js

@@ -1,24 +1,36 @@
 import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import {
+	isValidIsoDateYmd,
+	isInvalidIsoDateRange,
+} from "@/lib/frontend/search/dateRange";
 
 function isNonEmptyString(value) {
 	return typeof value === "string" && value.trim().length > 0;
 }
 
+function toTrimmedOrNull(value) {
+	return isNonEmptyString(value) ? value.trim() : null;
+}
+
+function buildValidationError(code, message, details) {
+	return new ApiClientError({
+		status: 400,
+		code,
+		message,
+		details,
+	});
+}
+
 /**
  * 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 / not ready)
  * - error: ApiClientError or null (local validation / fast-fail)
  *
  * UX policy for MULTI without branches:
  * - Treat it as "not ready" (input=null, error=null) instead of an error.
- *   The UI should show a friendly hint (select at least one branch).
  *
  * @param {{
  *   urlState: {
@@ -45,14 +57,54 @@ export function buildSearchApiInput({
 }) {
 	const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
 
-	// No query => no search request. UI should show an "idle" empty state.
+	// UI policy (RHL-024): q is required to trigger a search.
 	if (!q) return { input: null, error: null };
 
+	// --- Date range validation (RHL-025) ------------------------------------
+	const from = toTrimmedOrNull(urlState?.from);
+	const to = toTrimmedOrNull(urlState?.to);
+
+	// If provided, dates must be valid YYYY-MM-DD.
+	if (from && !isValidIsoDateYmd(from)) {
+		return {
+			input: null,
+			error: buildValidationError(
+				"VALIDATION_SEARCH_DATE",
+				"Invalid from date",
+				{
+					from,
+				}
+			),
+		};
+	}
+
+	if (to && !isValidIsoDateYmd(to)) {
+		return {
+			input: null,
+			error: buildValidationError("VALIDATION_SEARCH_DATE", "Invalid to date", {
+				to,
+			}),
+		};
+	}
+
+	// Range order: from must not be after to.
+	// IMPORTANT: from === to is valid and represents a single-day search.
+	if (isInvalidIsoDateRange(from, to)) {
+		return {
+			input: null,
+			error: buildValidationError(
+				"VALIDATION_SEARCH_RANGE",
+				"Invalid date range",
+				{ from, to }
+			),
+		};
+	}
+
+	// --- Build input ---------------------------------------------------------
 	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 (from) input.from = from;
+	if (to) input.to = to;
 
 	if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
 

+ 88 - 1
lib/frontend/search/searchApiInput.test.js

@@ -1,8 +1,9 @@
 /* @vitest-environment node */
 
 import { describe, it, expect } from "vitest";
-import { buildSearchApiInput } from "./searchApiInput.js";
+import { ApiClientError } from "@/lib/frontend/apiClient";
 import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+import { buildSearchApiInput } from "./searchApiInput.js";
 
 describe("lib/frontend/search/searchApiInput", () => {
 	it("returns {input:null} when q is missing", () => {
@@ -118,4 +119,90 @@ describe("lib/frontend/search/searchApiInput", () => {
 
 		expect(input).toEqual({ q: "x", limit: 100, branch: "NL01" });
 	});
+
+	it("includes from/to when valid", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: "2025-12-01",
+				to: "2025-12-31",
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(error).toBe(null);
+		expect(input).toEqual({
+			q: "x",
+			limit: 100,
+			branch: "NL01",
+			from: "2025-12-01",
+			to: "2025-12-31",
+		});
+	});
+
+	it("accepts from===to as a valid single-day search", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: "2025-12-01",
+				to: "2025-12-01",
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(error).toBe(null);
+		expect(input).toEqual({
+			q: "x",
+			limit: 100,
+			branch: "NL01",
+			from: "2025-12-01",
+			to: "2025-12-01",
+		});
+	});
+
+	it("returns a local validation error when from > to (no request should be sent)", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: "2025-12-31",
+				to: "2025-12-01",
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toBe(null);
+		expect(error).toBeInstanceOf(ApiClientError);
+		expect(error.code).toBe("VALIDATION_SEARCH_RANGE");
+	});
+
+	it("returns a local validation error for invalid date format", () => {
+		const { input, error } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: "2025/12/01",
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "admin", branchId: null },
+		});
+
+		expect(input).toBe(null);
+		expect(error).toBeInstanceOf(ApiClientError);
+		expect(error.code).toBe("VALIDATION_SEARCH_DATE");
+	});
 });