Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
167 lines
7.5 KiB
TypeScript
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();
|
|
});
|
|
});
|