Sfoglia il codice sorgente

RHL-009 feat(tests): add unit tests for toast notification functions

Code_Uwe 6 giorni fa
parent
commit
89f6a53b45
2 ha cambiato i file con 285 aggiunte e 0 eliminazioni
  1. 127 0
      lib/frontend/ui/toast.js
  2. 158 0
      lib/frontend/ui/toast.test.js

+ 127 - 0
lib/frontend/ui/toast.js

@@ -0,0 +1,127 @@
+import { toast } from "sonner";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+
+function normalizeText(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim();
+	return s ? s : null;
+}
+
+function buildToastOptions({ description, ...rest }) {
+	const opts = { ...rest };
+
+	const desc = normalizeText(description);
+	if (desc) opts.description = desc;
+
+	return opts;
+}
+
+export function notifySuccess({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.success(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyError({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.error(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyInfo({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.info(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyWarning({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.warning(t, buildToastOptions({ description, ...options }));
+}
+
+export function notifyLoading({ title, description, ...options } = {}) {
+	const t = normalizeText(title);
+	if (!t) return null;
+
+	return toast.loading(t, buildToastOptions({ description, ...options }));
+}
+
+export function dismissToast(toastId) {
+	return toast.dismiss(toastId);
+}
+
+export function mapApiErrorToToast(
+	err,
+	{
+		overrides = null,
+		fallbackTitle = "Fehler",
+		fallbackDescription = "Bitte versuchen Sie es erneut.",
+	} = {},
+) {
+	// Allow call sites (like Change Password) to override messages per error code.
+	if (err instanceof ApiClientError) {
+		const code = String(err.code || "");
+
+		if (overrides && overrides[code]) {
+			const o = overrides[code] || {};
+			return {
+				title: normalizeText(o.title) || fallbackTitle,
+				description: normalizeText(o.description) || null,
+			};
+		}
+
+		if (code === "CLIENT_NETWORK_ERROR") {
+			return {
+				title: "Netzwerkfehler",
+				description:
+					"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+			};
+		}
+
+		if (code === "AUTH_UNAUTHENTICATED") {
+			return {
+				title: "Sitzung abgelaufen",
+				description: "Bitte melden Sie sich erneut an.",
+			};
+		}
+
+		if (code.startsWith("VALIDATION_")) {
+			return {
+				title: "Ungültige Eingabe",
+				description:
+					"Bitte prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
+			};
+		}
+	}
+
+	return {
+		title: normalizeText(fallbackTitle) || "Fehler",
+		description: normalizeText(fallbackDescription) || null,
+	};
+}
+
+export function notifyApiError(
+	err,
+	{
+		overrides = null,
+		fallbackTitle = "Fehler",
+		fallbackDescription = "Bitte versuchen Sie es erneut.",
+		...toastOptions
+	} = {},
+) {
+	const mapped = mapApiErrorToToast(err, {
+		overrides,
+		fallbackTitle,
+		fallbackDescription,
+	});
+
+	return toast.error(
+		mapped.title,
+		buildToastOptions({ description: mapped.description, ...toastOptions }),
+	);
+}

+ 158 - 0
lib/frontend/ui/toast.test.js

@@ -0,0 +1,158 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("sonner", () => {
+	const api = {
+		success: vi.fn(() => "toast-success-id"),
+		error: vi.fn(() => "toast-error-id"),
+		info: vi.fn(() => "toast-info-id"),
+		warning: vi.fn(() => "toast-warning-id"),
+		loading: vi.fn(() => "toast-loading-id"),
+		dismiss: vi.fn(() => undefined),
+	};
+
+	return { toast: api };
+});
+
+import { toast } from "sonner";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+
+import {
+	notifySuccess,
+	notifyError,
+	notifyInfo,
+	notifyWarning,
+	notifyLoading,
+	dismissToast,
+	mapApiErrorToToast,
+	notifyApiError,
+} from "./toast.js";
+
+describe("lib/frontend/ui/toast", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+	});
+
+	it("notifySuccess calls toast.success with title + description", () => {
+		const id = notifySuccess({
+			title: "Gespeichert",
+			description: "Änderungen wurden übernommen.",
+		});
+
+		expect(id).toBe("toast-success-id");
+		expect(toast.success).toHaveBeenCalledWith("Gespeichert", {
+			description: "Änderungen wurden übernommen.",
+		});
+	});
+
+	it("notifyError calls toast.error", () => {
+		const id = notifyError({ title: "Fehler", description: "Oh nein." });
+
+		expect(id).toBe("toast-error-id");
+		expect(toast.error).toHaveBeenCalledWith("Fehler", {
+			description: "Oh nein.",
+		});
+	});
+
+	it("notifyInfo / notifyWarning / notifyLoading forward correctly", () => {
+		notifyInfo({ title: "Info", description: "Hinweis" });
+		expect(toast.info).toHaveBeenCalledWith("Info", { description: "Hinweis" });
+
+		notifyWarning({ title: "Warnung" });
+		expect(toast.warning).toHaveBeenCalledWith("Warnung", {});
+
+		notifyLoading({ title: "Lädt…" });
+		expect(toast.loading).toHaveBeenCalledWith("Lädt…", {});
+	});
+
+	it("dismissToast forwards to toast.dismiss", () => {
+		dismissToast("x");
+		expect(toast.dismiss).toHaveBeenCalledWith("x");
+	});
+
+	it("mapApiErrorToToast maps network errors", () => {
+		const err = new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Netzwerkfehler",
+			description:
+				"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+		});
+	});
+
+	it("mapApiErrorToToast maps unauthenticated", () => {
+		const err = new ApiClientError({
+			status: 401,
+			code: "AUTH_UNAUTHENTICATED",
+			message: "Unauthorized",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Sitzung abgelaufen",
+			description: "Bitte melden Sie sich erneut an.",
+		});
+	});
+
+	it("mapApiErrorToToast maps validation errors generically", () => {
+		const err = new ApiClientError({
+			status: 400,
+			code: "VALIDATION_WEAK_PASSWORD",
+			message: "Weak password",
+		});
+
+		expect(mapApiErrorToToast(err)).toEqual({
+			title: "Ungültige Eingabe",
+			description:
+				"Bitte prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
+		});
+	});
+
+	it("mapApiErrorToToast supports overrides by error code", () => {
+		const err = new ApiClientError({
+			status: 401,
+			code: "AUTH_INVALID_CREDENTIALS",
+			message: "Invalid credentials",
+		});
+
+		const mapped = mapApiErrorToToast(err, {
+			overrides: {
+				AUTH_INVALID_CREDENTIALS: {
+					title: "Aktuelles Passwort ist falsch.",
+					description: null,
+				},
+			},
+		});
+
+		expect(mapped).toEqual({
+			title: "Aktuelles Passwort ist falsch.",
+			description: null,
+		});
+	});
+
+	it("notifyApiError uses toast.error with mapped copy", () => {
+		const err = new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+		});
+
+		const id = notifyApiError(err);
+
+		expect(id).toBe("toast-error-id");
+		expect(toast.error).toHaveBeenCalledWith("Netzwerkfehler", {
+			description:
+				"Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
+		});
+	});
+
+	it("notifySuccess returns null and does not toast when title is missing", () => {
+		const id = notifySuccess({ title: "   " });
+		expect(id).toBe(null);
+		expect(toast.success).not.toHaveBeenCalled();
+	});
+});