Files
ApiCore/ApiCore-Frontend/src/components/notification-bell.spec.tsx
T
Nick.RosenloecherandClaude Opus 5.5 d8efbc34fa
Backend Image / build-and-push (push) Successful in 5m4s
Frontend Image / build-and-push (push) Successful in 15m12s
fix(a11y): Name der Glocke enthält die sichtbare Zahl (Lighthouse label-content-name-mismatch)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:02:31 +02:00

167 lines
7.5 KiB
TypeScript

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>): 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(
<QueryClientProvider client={client}>
<NotificationBell />
</QueryClientProvider>,
);
}
let invalidate: ReturnType<typeof vi.spyOn>;
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();
});
});