Browse Source

RHL-025 feat(date-filter): implement date validation functions and corresponding tests

Code_Uwe 2 weeks ago
parent
commit
4b91fa0ef4

+ 64 - 0
lib/frontend/search/dateFilterValidation.js

@@ -0,0 +1,64 @@
+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 a local validation error for date filters.
+ *
+ * This is intentionally independent from the search query "q" so the UI can
+ * validate immediately while the user edits the date range.
+ *
+ * @param {{ from?: string|null, to?: string|null }} input
+ * @returns {ApiClientError|null}
+ */
+export function buildDateFilterValidationError({
+	from = null,
+	to = null,
+} = {}) {
+	const f = toTrimmedOrNull(from);
+	const t = toTrimmedOrNull(to);
+
+	if (f && !isValidIsoDateYmd(f)) {
+		return buildValidationError("VALIDATION_SEARCH_DATE", "Invalid from date", {
+			from: f,
+		});
+	}
+
+	if (t && !isValidIsoDateYmd(t)) {
+		return buildValidationError("VALIDATION_SEARCH_DATE", "Invalid to date", {
+			to: t,
+		});
+	}
+
+	// IMPORTANT:
+	// - from === to is valid and represents a single-day search.
+	// - Only from > to is invalid.
+	if (isInvalidIsoDateRange(f, t)) {
+		return buildValidationError(
+			"VALIDATION_SEARCH_RANGE",
+			"Invalid date range",
+			{ from: f, to: t }
+		);
+	}
+
+	return null;
+}

+ 63 - 0
lib/frontend/search/dateFilterValidation.test.js

@@ -0,0 +1,63 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import { buildDateFilterValidationError } from "./dateFilterValidation.js";
+
+describe("lib/frontend/search/dateFilterValidation", () => {
+	it("returns null when no dates are set", () => {
+		expect(buildDateFilterValidationError({ from: null, to: null })).toBe(null);
+		expect(buildDateFilterValidationError({})).toBe(null);
+	});
+
+	it("returns null for a valid open range", () => {
+		expect(
+			buildDateFilterValidationError({ from: "2026-01-01", to: null })
+		).toBe(null);
+		expect(
+			buildDateFilterValidationError({ from: null, to: "2026-01-31" })
+		).toBe(null);
+	});
+
+	it("returns null for a valid range", () => {
+		expect(
+			buildDateFilterValidationError({ from: "2026-01-01", to: "2026-01-31" })
+		).toBe(null);
+	});
+
+	it("accepts from===to (single-day)", () => {
+		expect(
+			buildDateFilterValidationError({ from: "2026-01-14", to: "2026-01-14" })
+		).toBe(null);
+	});
+
+	it("returns VALIDATION_SEARCH_RANGE when from > to", () => {
+		const err = buildDateFilterValidationError({
+			from: "2026-01-14",
+			to: "2026-01-13",
+		});
+
+		expect(err).toBeInstanceOf(ApiClientError);
+		expect(err.code).toBe("VALIDATION_SEARCH_RANGE");
+	});
+
+	it("returns VALIDATION_SEARCH_DATE for invalid from", () => {
+		const err = buildDateFilterValidationError({
+			from: "2026/01/01",
+			to: null,
+		});
+
+		expect(err).toBeInstanceOf(ApiClientError);
+		expect(err.code).toBe("VALIDATION_SEARCH_DATE");
+	});
+
+	it("returns VALIDATION_SEARCH_DATE for invalid to", () => {
+		const err = buildDateFilterValidationError({
+			from: null,
+			to: "2026-99-01",
+		});
+
+		expect(err).toBeInstanceOf(ApiClientError);
+		expect(err.code).toBe("VALIDATION_SEARCH_DATE");
+	});
+});

+ 110 - 0
lib/frontend/search/dateRange.js

@@ -0,0 +1,110 @@
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+export function isValidIsoDateYmd(value) {
+	if (typeof value !== "string") return false;
+	const s = value.trim();
+
+	if (!ISO_DATE_RE.test(s)) return false;
+
+	const [y, m, d] = s.split("-").map((x) => Number(x));
+
+	if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) {
+		return false;
+	}
+
+	// Keep it predictable (same policy as backend):
+	// - month: 1..12
+	// - day: 1..31
+	// We intentionally do NOT validate month-length precisely (e.g. Feb 30),
+	// because the backend currently behaves the same way.
+	if (m < 1 || m > 12) return false;
+	if (d < 1 || d > 31) return false;
+
+	return true;
+}
+
+export function normalizeIsoDateYmdOrNull(value) {
+	if (typeof value !== "string") return null;
+
+	const s = value.trim();
+	if (!s) return null;
+
+	return isValidIsoDateYmd(s) ? s : null;
+}
+
+/**
+ * Compare ISO dates (YYYY-MM-DD).
+ *
+ * Lexicographic compare is correct for this format.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @returns {number} -1 | 0 | 1
+ */
+export function compareIsoDatesYmd(a, b) {
+	const aa = String(a || "");
+	const bb = String(b || "");
+
+	if (aa === bb) return 0;
+	return aa < bb ? -1 : 1;
+}
+
+/**
+ * Returns true when both dates exist and the range is invalid (from > to).
+ *
+ * IMPORTANT:
+ * - from === to is valid and represents a single day.
+ */
+export function isInvalidIsoDateRange(from, to) {
+	const f = normalizeIsoDateYmdOrNull(from);
+	const t = normalizeIsoDateYmdOrNull(to);
+
+	if (!f || !t) return false;
+	return f > t;
+}
+
+/**
+ * Format ISO date (YYYY-MM-DD) as German UI date: DD.MM.YYYY
+ *
+ * @param {string|null} ymd
+ * @returns {string|null}
+ */
+export function formatIsoDateDe(ymd) {
+	const s = normalizeIsoDateYmdOrNull(ymd);
+	if (!s) return null;
+
+	const [y, m, d] = s.split("-");
+	return `${d}.${m}.${y}`;
+}
+
+/**
+ * Build a compact German label for the active date filter.
+ *
+ * Rules:
+ * - from + to:
+ *   - same day => "DD.MM.YYYY"
+ *   - range    => "DD.MM.YYYY – DD.MM.YYYY"
+ * - only from => "ab DD.MM.YYYY"
+ * - only to   => "bis DD.MM.YYYY"
+ * - none      => null
+ *
+ * @param {{ from?: string|null, to?: string|null }} input
+ * @returns {string|null}
+ */
+export function formatIsoDateRangeLabelDe({ from = null, to = null } = {}) {
+	const f = formatIsoDateDe(from);
+	const t = formatIsoDateDe(to);
+
+	if (f && t) {
+		// Single day
+		if (normalizeIsoDateYmdOrNull(from) === normalizeIsoDateYmdOrNull(to)) {
+			return f;
+		}
+		return `${f} – ${t}`;
+	}
+
+	if (f) return `ab ${f}`;
+	if (t) return `bis ${t}`;
+
+	return null;
+}

+ 88 - 0
lib/frontend/search/dateRange.test.js

@@ -0,0 +1,88 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	isValidIsoDateYmd,
+	normalizeIsoDateYmdOrNull,
+	compareIsoDatesYmd,
+	isInvalidIsoDateRange,
+	formatIsoDateDe,
+	formatIsoDateRangeLabelDe,
+} from "./dateRange.js";
+
+describe("lib/frontend/search/dateRange", () => {
+	describe("isValidIsoDateYmd", () => {
+		it("accepts strict YYYY-MM-DD", () => {
+			expect(isValidIsoDateYmd("2025-12-01")).toBe(true);
+			expect(isValidIsoDateYmd("2025-01-31")).toBe(true);
+		});
+
+		it("rejects invalid formats and obvious invalid ranges", () => {
+			expect(isValidIsoDateYmd("2025/12/01")).toBe(false);
+			expect(isValidIsoDateYmd("2025-1-01")).toBe(false);
+			expect(isValidIsoDateYmd("2025-01-1")).toBe(false);
+
+			expect(isValidIsoDateYmd("2025-13-01")).toBe(false);
+			expect(isValidIsoDateYmd("2025-00-01")).toBe(false);
+			expect(isValidIsoDateYmd("2025-12-00")).toBe(false);
+			expect(isValidIsoDateYmd("2025-12-32")).toBe(false);
+		});
+	});
+
+	describe("normalizeIsoDateYmdOrNull", () => {
+		it("trims and returns null for invalid values", () => {
+			expect(normalizeIsoDateYmdOrNull(" 2025-12-01 ")).toBe("2025-12-01");
+			expect(normalizeIsoDateYmdOrNull("")).toBe(null);
+			expect(normalizeIsoDateYmdOrNull("2025/12/01")).toBe(null);
+			expect(normalizeIsoDateYmdOrNull(null)).toBe(null);
+		});
+	});
+
+	describe("compareIsoDatesYmd", () => {
+		it("compares lexicographically", () => {
+			expect(compareIsoDatesYmd("2025-01-01", "2025-01-01")).toBe(0);
+			expect(compareIsoDatesYmd("2025-01-01", "2025-01-02")).toBe(-1);
+			expect(compareIsoDatesYmd("2025-12-31", "2025-01-01")).toBe(1);
+		});
+	});
+
+	describe("isInvalidIsoDateRange", () => {
+		it("is false for open ranges and equal dates", () => {
+			expect(isInvalidIsoDateRange("2025-12-01", null)).toBe(false);
+			expect(isInvalidIsoDateRange(null, "2025-12-31")).toBe(false);
+			expect(isInvalidIsoDateRange("2025-12-01", "2025-12-01")).toBe(false);
+		});
+
+		it("is true when from > to", () => {
+			expect(isInvalidIsoDateRange("2025-12-31", "2025-12-01")).toBe(true);
+		});
+	});
+
+	describe("formatIsoDateDe / formatIsoDateRangeLabelDe", () => {
+		it("formats single dates as DD.MM.YYYY", () => {
+			expect(formatIsoDateDe("2025-12-01")).toBe("01.12.2025");
+			expect(formatIsoDateDe("invalid")).toBe(null);
+		});
+
+		it("formats date range labels", () => {
+			expect(
+				formatIsoDateRangeLabelDe({ from: "2025-12-01", to: "2025-12-31" })
+			).toBe("01.12.2025 – 31.12.2025");
+
+			// Same day => single day label (and this must be treated as a valid one-day search)
+			expect(
+				formatIsoDateRangeLabelDe({ from: "2025-12-01", to: "2025-12-01" })
+			).toBe("01.12.2025");
+
+			expect(formatIsoDateRangeLabelDe({ from: "2025-12-01", to: null })).toBe(
+				"ab 01.12.2025"
+			);
+
+			expect(formatIsoDateRangeLabelDe({ from: null, to: "2025-12-31" })).toBe(
+				"bis 31.12.2025"
+			);
+
+			expect(formatIsoDateRangeLabelDe({ from: null, to: null })).toBe(null);
+		});
+	});
+});