import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useLocation } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; import NotificationsPage from "@/app/(protected)/notifications"; import { NotificationBell } from "@/components/notifications/notification-bell"; import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; import { renderWithProviders } from "@/test/test-utils"; const apiGet = vi.hoisted(() => vi.fn()); vi.mock("@/api/api", () => ({ apiGet })); type ItemInput = { id: string; reason: string; severity: "Critical" | "High" | "Medium" | "Low"; title: string; count?: number; rowType?: "dismissable" | "acknowledge"; target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string }; }; function item(input: ItemInput) { return { rowType: "dismissable" as ItemInput["rowType"], count: 1, triggeredAt: "2026-09-18T12:00:00Z", workOrders: [], ...input, }; } function section(reason: string, label: string, items: ReturnType[]) { const severity = items[0]?.severity ?? "Low"; return { reason, label, severity, count: items.reduce((sum, row) => sum + row.count, 0), items, }; } const unassigned = item({ id: "unassigned", reason: "unassigned", severity: "High", title: "3 work orders are unassigned", count: 3, target: { kind: "queue", queue: "unassigned" }, }); const avetaFirst = item({ id: "aveta-missing-41", reason: "aveta-missing", severity: "Medium", title: "WO #1041 is missing the Aveta document", target: { kind: "workOrder", workOrderId: 41, tab: "extras" }, }); const avetaSecond = item({ id: "aveta-missing-42", reason: "aveta-missing", severity: "Medium", title: "WO #1042 is missing the Aveta document", target: { kind: "workOrder", workOrderId: 42, tab: "extras" }, }); const noVendor = item({ id: "no-vendor-7", reason: "no-vendor", severity: "Medium", title: "WO #1007 starts within 48h with no vendor assigned", target: { kind: "workOrder", workOrderId: 7, tab: "info" }, }); const acknowledgeRow = item({ id: "sla-9", reason: "sla", severity: "Critical", rowType: "acknowledge", title: "WO #1009 missed its response deadline", target: { kind: "workOrder", workOrderId: 9, tab: "info" }, }); function feed(...sections: ReturnType[]) { return { generatedAt: "2026-09-18T12:00:00Z", sections }; } const standardFeed = feed( section("unassigned", "Unassigned", [unassigned]), section("no-vendor", "No Vendor", [noVendor]), section("aveta-missing", "Aveta Missing", [avetaFirst, avetaSecond]), ); function LocationProbe() { const location = useLocation(); return {`${location.pathname}${location.search}`}; } function renderBell() { return renderWithProviders( , { withAuth: false }, ); } async function openPanel(user: ReturnType) { await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ })); return screen.findByRole("dialog"); } function sectionLabels(panel: HTMLElement) { return within(panel) .getAllByRole("region") .map((region) => region.getAttribute("aria-label")); } describe("notification center", () => { beforeEach(() => { apiGet.mockReset(); apiGet.mockResolvedValue(standardFeed); }); it("requests the signed-in user's feed and counts every unread item on the bell", async () => { renderBell(); expect( await screen.findByRole("button", { name: "Notifications, 4 unread" }), ).toBeInTheDocument(); expect(apiGet).toHaveBeenCalledWith("notifications"); }); it("shows labeled sections with live counts in severity then reason order", async () => { const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); expect(sectionLabels(panel)).toEqual(["Unassigned, 3", "No Vendor, 1", "Aveta Missing, 2"]); const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" }); expect(within(aveta).getByRole("heading", { name: "Aveta Missing" })).toBeInTheDocument(); expect(within(aveta).getAllByRole("listitem")).toHaveLength(2); }); it("never renders an empty section and shows the empty state when nothing is left", async () => { apiGet.mockResolvedValue( feed(section("unassigned", "Unassigned", [unassigned]), section("due-soon", "Due Soon", [])), ); const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]); await user.click(within(panel).getByRole("button", { name: "Dismiss notification" })); expect(within(panel).queryAllByRole("region")).toHaveLength(0); expect(within(panel).getByText("Nothing needs your attention")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument(); }); it("styles unread rows apart from read ones and marks everything read on request", async () => { const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); const rows = within(panel).getAllByRole("listitem"); expect(rows.every((row) => row.dataset.unread === "true")).toBe(true); expect(within(panel).getAllByText("Unread:", { exact: false })).toHaveLength(4); await user.click(within(panel).getByRole("button", { name: "Mark all read" })); expect( within(panel) .getAllByRole("listitem") .every((row) => row.dataset.unread === "false"), ).toBe(true); expect(within(panel).queryByText("Unread:", { exact: false })).not.toBeInTheDocument(); expect(within(panel).getByRole("button", { name: "Mark all read" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument(); }); it("dismisses one item, lowering its section count, and keeps the dismissal to this session only", async () => { const user = userEvent.setup(); const { unmount } = renderBell(); const panel = await openPanel(user); const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" }); const firstRow = within(aveta).getByText(avetaFirst.title).closest("li"); expect(firstRow).not.toBeNull(); await user.click( within(firstRow as HTMLElement).getByRole("button", { name: "Dismiss notification" }), ); expect(within(panel).getByRole("region", { name: "Aveta Missing, 1" })).toBeInTheDocument(); expect(within(panel).queryByText(avetaFirst.title)).not.toBeInTheDocument(); expect(within(panel).getByText(avetaSecond.title)).toBeInTheDocument(); unmount(); renderBell(); const freshPanel = await openPanel(user); expect(within(freshPanel).getByText(avetaFirst.title)).toBeInTheDocument(); }); it("re-sorts sections after a dismissal changes which severity leads", async () => { apiGet.mockResolvedValue( feed( section("aveta-missing", "Aveta Missing", [ item({ ...avetaFirst, severity: "High", title: "WO #1041 urgent" }), avetaSecond, ]), section("no-vendor", "No Vendor", [noVendor]), ), ); const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); expect(sectionLabels(panel)).toEqual(["Aveta Missing, 2", "No Vendor, 1"]); const highRow = within(panel).getByText("WO #1041 urgent").closest("li") as HTMLElement; await user.click(within(highRow).getByRole("button", { name: "Dismiss notification" })); expect(sectionLabels(panel)).toEqual(["No Vendor, 1", "Aveta Missing, 1"]); }); it("clears every dismissable item but keeps rows that need acknowledging", async () => { apiGet.mockResolvedValue( feed( section("sla", "SLA at Risk", [acknowledgeRow]), section("unassigned", "Unassigned", [unassigned]), ), ); const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement; expect(within(slaRow).queryByRole("button", { name: "Dismiss notification" })).toBeNull(); await user.click(within(panel).getByRole("button", { name: "Clear all" })); expect(sectionLabels(panel)).toEqual(["SLA at Risk, 1"]); expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); }); it("opens the unassigned queue with the existing board filters and closes the panel", async () => { const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); await user.click(within(panel).getByRole("button", { name: /3 work orders are unassigned/ })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); const location = screen.getByRole("status", { name: "Current location" }).textContent ?? ""; const url = new URL(location, "http://localhost"); expect(url.pathname).toBe("/workorders"); expect(url.searchParams.get("dispatchers")).toBe("__unassigned"); expect(url.searchParams.get("dateFrom")).toBe("1970-01-01"); expect(url.searchParams.get("dateTo")).toBe("2099-12-31"); expect(url.searchParams.get("statuses")).toBeTruthy(); expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument(); }); it("opens an Aveta item on that work order's upload tab", async () => { const user = userEvent.setup(); renderBell(); const panel = await openPanel(user); await user.click(within(panel).getByRole("button", { name: new RegExp(avetaSecond.title) })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( "/workorders?wo=42&tab=extras", ); }); it("serves the same feed as a full page", async () => { const user = userEvent.setup(); renderWithProviders( , { withAuth: false, route: "/notifications" }, ); expect(await screen.findByRole("region", { name: "No Vendor, 1" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Notifications" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) })); expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( "/workorders?wo=7&tab=info", ); }); it("tells the user when the feed cannot load", async () => { apiGet.mockRejectedValue(new Error("boom")); renderWithProviders( , { withAuth: false, route: "/notifications" }, ); expect( await screen.findByText("Notifications could not be loaded. They will retry automatically."), ).toBeInTheDocument(); }); });