Просмотр исходного кода

RHL-024 feat(search): add centralized error mapping for search UI with user-friendly messages

Code_Uwe 3 недель назад
Родитель
Сommit
ad027f6638
2 измененных файлов с 191 добавлено и 0 удалено
  1. 114 0
      lib/frontend/search/errorMapping.js
  2. 77 0
      lib/frontend/search/errorMapping.test.js

+ 114 - 0
lib/frontend/search/errorMapping.js

@@ -0,0 +1,114 @@
+import { ApiClientError } from "@/lib/frontend/apiClient";
+
+const DEFAULT_GENERIC = Object.freeze({
+	kind: "generic",
+	title: "Fehler",
+	description:
+		"Beim Laden der Suche ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
+});
+
+const DEFAULT_VALIDATION = Object.freeze({
+	kind: "validation",
+	title: "Ungültige Eingabe",
+	description: "Bitte prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
+});
+
+const VALIDATION_MAP = Object.freeze({
+	VALIDATION_SEARCH_MISSING_FILTER: {
+		title: "Kein Suchbegriff",
+		description: "Bitte geben Sie einen Suchbegriff ein.",
+	},
+	VALIDATION_SEARCH_BRANCH: {
+		title: "Niederlassung fehlt",
+		description: "Bitte wählen Sie eine Niederlassung aus.",
+	},
+	VALIDATION_SEARCH_BRANCHES: {
+		title: "Niederlassungen fehlen",
+		description: "Bitte wählen Sie mindestens eine Niederlassung aus.",
+	},
+	VALIDATION_SEARCH_SCOPE: {
+		title: "Ungültiger Suchbereich",
+		description: "Bitte wählen Sie einen gültigen Suchbereich aus.",
+	},
+	VALIDATION_SEARCH_LIMIT: {
+		title: "Ungültige Seitengröße",
+		description:
+			"Die Anzahl der Ergebnisse pro Seite ist ungültig. Bitte versuchen Sie es erneut.",
+	},
+	VALIDATION_SEARCH_CURSOR: {
+		title: "Ungültige Paginierung",
+		description:
+			"Die nächsten Ergebnisse konnten nicht geladen werden. Bitte starten Sie die Suche erneut.",
+	},
+	VALIDATION_SEARCH_DATE: {
+		title: "Ungültiger Zeitraum",
+		description: "Bitte prüfen Sie den Zeitraum und versuchen Sie es erneut.",
+	},
+	VALIDATION_SEARCH_RANGE: {
+		title: "Ungültiger Zeitraum",
+		description: "Bitte prüfen Sie den Zeitraum und versuchen Sie es erneut.",
+	},
+});
+
+/**
+ * mapSearchError
+ *
+ * Centralized error-to-UI mapping for Search UI (RHL-024).
+ *
+ * Important:
+ * - All returned strings are user-facing => German.
+ * - We intentionally do NOT expose raw backend messages to the UI.
+ *
+ * @param {unknown} err
+ * @returns {null | {
+ *   kind: "unauthenticated"|"forbidden"|"validation"|"generic",
+ *   title: string,
+ *   description: string
+ * }}
+ */
+export function mapSearchError(err) {
+	if (!err) return null;
+
+	if (err instanceof ApiClientError) {
+		if (err.code === "AUTH_UNAUTHENTICATED") {
+			return {
+				kind: "unauthenticated",
+				title: "Sitzung abgelaufen",
+				description:
+					"Ihre Sitzung ist abgelaufen. Sie werden zum Login weitergeleitet.",
+			};
+		}
+
+		if (err.code === "AUTH_FORBIDDEN_BRANCH") {
+			return {
+				kind: "forbidden",
+				title: "Kein Zugriff",
+				description:
+					"Sie haben keine Berechtigung, diesen Suchbereich zu verwenden.",
+			};
+		}
+
+		if (String(err.code || "").startsWith("VALIDATION_")) {
+			const mapped = VALIDATION_MAP[err.code];
+
+			return {
+				kind: "validation",
+				title: mapped?.title || DEFAULT_VALIDATION.title,
+				description: mapped?.description || DEFAULT_VALIDATION.description,
+			};
+		}
+
+		if (err.code === "CLIENT_NETWORK_ERROR") {
+			return {
+				kind: "generic",
+				title: "Netzwerkfehler",
+				description:
+					"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+			};
+		}
+
+		return DEFAULT_GENERIC;
+	}
+
+	return DEFAULT_GENERIC;
+}

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

@@ -0,0 +1,77 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import { mapSearchError } from "./errorMapping.js";
+
+describe("lib/frontend/search/errorMapping", () => {
+	it("returns null for missing error", () => {
+		expect(mapSearchError(null)).toBe(null);
+		expect(mapSearchError(undefined)).toBe(null);
+	});
+
+	it("maps unauthenticated", () => {
+		const err = new ApiClientError({
+			status: 401,
+			code: "AUTH_UNAUTHENTICATED",
+			message: "Unauthorized",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped).toMatchObject({ kind: "unauthenticated" });
+	});
+
+	it("maps forbidden", () => {
+		const err = new ApiClientError({
+			status: 403,
+			code: "AUTH_FORBIDDEN_BRANCH",
+			message: "Forbidden",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped).toMatchObject({ kind: "forbidden" });
+	});
+
+	it("maps known validation errors to friendly German copy", () => {
+		const err = new ApiClientError({
+			status: 400,
+			code: "VALIDATION_SEARCH_MISSING_FILTER",
+			message: "At least one of q or date range must be provided",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped).toEqual({
+			kind: "validation",
+			title: "Kein Suchbegriff",
+			description: "Bitte geben Sie einen Suchbegriff ein.",
+		});
+	});
+
+	it("maps unknown validation errors to a generic validation message", () => {
+		const err = new ApiClientError({
+			status: 400,
+			code: "VALIDATION_SOMETHING_NEW",
+			message: "nope",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped?.kind).toBe("validation");
+		expect(mapped?.title).toBe("Ungültige Eingabe");
+	});
+
+	it("maps network errors", () => {
+		const err = new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+		});
+
+		const mapped = mapSearchError(err);
+		expect(mapped).toMatchObject({ kind: "generic", title: "Netzwerkfehler" });
+	});
+
+	it("maps unknown errors to generic", () => {
+		const mapped = mapSearchError(new Error("boom"));
+		expect(mapped).toMatchObject({ kind: "generic" });
+	});
+});