shoc-frontend-new/src/test/components/notifications/notification-center.test.tsx
Codex Review Integration 75c2c1c1fa Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into HEAD
# Conflicts:
#	src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts
#	src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts
#	src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
#	src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx
#	src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
2026-09-18 14:35:17 -03:00

367 lines
13 KiB
TypeScript

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 }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "dispatcher-me" } }),
}));
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 };
workOrders?: { id: number; number: string }[];
};
function item(input: ItemInput) {
return {
rowType: "dismissable" as ItemInput["rowType"],
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
workOrders: [] as { id: number; number: string }[],
...input,
};
}
function section(reason: string, label: string, items: ReturnType<typeof item>[]) {
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<typeof section>[]) {
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 <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBell() {
return renderWithProviders(
<NotificationSessionProvider>
<NotificationBell />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false },
);
}
async function openPanel(user: ReturnType<typeof userEvent.setup>) {
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(url.searchParams.get("fromDashboard")).toBeNull();
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 and sends No Vendor straight to choosing a vendor", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ 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/edit/7?assignVendor=1",
);
});
it("links every work order in a vendor conflict", async () => {
apiGet.mockResolvedValue(
feed(
section("vendor-conflict", "Vendor Conflict", [
item({
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
title: "Acme Plumbing has overlapping appointments",
count: 2,
target: { kind: "workOrder", workOrderId: 501, tab: "info" },
workOrders: [
{ id: 501, number: "501" },
{ id: 502, number: "502" },
],
}),
]),
),
);
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" });
expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await user.click(within(conflicts).getByRole("link", { name: "WO #502" }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders/502",
);
const row = within(conflicts).getByRole("listitem");
expect(row.dataset.unread).toBe("false");
});
it("tells the user when the feed cannot load", async () => {
apiGet.mockRejectedValue(new Error("boom"));
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
expect(
await screen.findByText("Notifications could not be loaded. They will retry automatically."),
).toBeInTheDocument();
});
});