import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NotificationBell } from "./notification-bell"; import { fetchNotifications, markAllNotificationsRead, markNotificationRead, } from "@/lib/notifications/api"; import type { Notification } from "@/lib/notifications/types"; import { ApiError } from "@/lib/auth/api-client"; import { toast } from "sonner"; vi.mock("@/lib/notifications/api", () => ({ fetchNotifications: vi.fn(), markNotificationRead: vi.fn(), markAllNotificationsRead: vi.fn(), })); vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); function notification(overrides: Partial): Notification { return { id: "n-1", priority: "WARNUNG", category: "WITHDRAWAL", message: "Wartezeit-Konflikt bei B3", relatedType: null, relatedId: null, link: null, read: false, createdAt: "2026-08-01T10:00:00.000Z", ...overrides, }; } function renderBell() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); invalidate = vi.spyOn(client, "invalidateQueries"); return render( , ); } let invalidate: ReturnType; describe("NotificationBell", () => { beforeEach(() => { vi.mocked(markAllNotificationsRead).mockReset().mockResolvedValue({ updated: 2 }); vi.mocked(markNotificationRead).mockReset().mockResolvedValue({} as never); vi.mocked(toast.error).mockReset(); vi.mocked(toast.success).mockReset(); }); it("„Alle gelesen“ calls read-all and a notification with link navigates there", async () => { vi.mocked(fetchNotifications).mockResolvedValue([ notification({ id: "n-1", link: "/harvests/h-1" }), notification({ id: "n-2", message: "Ohne Ziel", relatedType: "Colony", relatedId: "c-9" }), ]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /^Benachrichtigungen/ })); const linked = await screen.findByText("Wartezeit-Konflikt bei B3"); expect(linked.closest("a")).toHaveAttribute("href", "/harvests/h-1"); expect(screen.getByText("Ohne Ziel").closest("a")).toHaveAttribute("href", "/colonies/c-9"); await userEvent.click(screen.getByRole("menuitem", { name: /Alle gelesen/ })); await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalledTimes(1)); }); it("ignores a link that is not an internal path", async () => { vi.mocked(fetchNotifications).mockResolvedValue([ notification({ link: "//evil.example/x" }), notification({ id: "n-2", message: "Schema", link: "https://evil.example" }), ]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /^Benachrichtigungen/ })); expect((await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a")).toBeNull(); expect(screen.getByText("Schema").closest("a")).toBeNull(); }); it("hides „Alle gelesen“ when nothing is unread and names the category in words", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({ read: true })]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /^Benachrichtigungen/ })); expect(await screen.findByText("Wartezeit")).toBeInTheDocument(); expect(screen.queryByRole("menuitem", { name: /Alle gelesen/ })).not.toBeInTheDocument(); }); it("clicking an unread notification marks it read and its link navigates", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({ id: "n-7", link: "/harvests/h-7" })]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); const text = await screen.findByText("Wartezeit-Konflikt bei B3"); expect(text.closest("a")).toHaveAttribute("href", "/harvests/h-7"); await userEvent.click(text); await waitFor(() => expect(markNotificationRead).toHaveBeenCalled()); expect(vi.mocked(markNotificationRead).mock.calls[0][0]).toBe("n-7"); }); it("a 404 on mark-read refreshes the list without an error toast", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(404, "Not found")); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3")); await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] })); expect(toast.error).not.toHaveBeenCalled(); }); it("another mark-read error shows one toast with a fixed id", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(500, "boom")); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3")); await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual({ id: "notification-read-error" }); }); it("the button name carries the visible badge text, the digit itself is hidden from readers", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({}), notification({ id: "n-2" })]); renderBell(); expect( await screen.findByRole("button", { name: "Benachrichtigungen, 2 ungelesen" }), ).toBeInTheDocument(); expect(screen.getByText("2", { selector: "[aria-hidden='true']" })).toBeInTheDocument(); }); it("read-all confirms with a toast and refreshes", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ })); await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Alle als gelesen markiert.")); expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }); }); it("read-all refreshes the list on failure too", async () => { vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); vi.mocked(markAllNotificationsRead).mockRejectedValue(new ApiError(500, "x")); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ })); await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] })); }); it.each([ ["/harvests/h-1?tab=a&x=1", true], ["/\t/x", false], ["//evil", false], ["/\\evil", false], ["https://evil.example", false], ["/a b", false], [`/${"a".repeat(300)}`, false], ])("link %j is internal: %s", async (link, internal) => { vi.mocked(fetchNotifications).mockResolvedValue([notification({ link })]); renderBell(); await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); const anchor = (await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a"); if (internal) expect(anchor).toHaveAttribute("href", link); else expect(anchor).toBeNull(); }); });