mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 20:02:05 +00:00
Dispatchers see a one-line banner and a one-time toast while a Reactive/Emergency work order is past half of its SEV response window; the banner opens the board on exactly those work orders and dismisses for the session. Missed deadlines stay in the notification center until acknowledged, and Clear all skips them.
422 lines
15 KiB
TypeScript
422 lines
15 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());
|
|
const apiPostNoContent = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent }));
|
|
|
|
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);
|
|
apiPostNoContent.mockReset();
|
|
apiPostNoContent.mockResolvedValue(undefined);
|
|
});
|
|
|
|
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("keeps Clear all disabled when only rows that need acknowledging are left", async () => {
|
|
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
|
|
expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled();
|
|
});
|
|
|
|
it("acknowledges a missed deadline through the API and removes the row", 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;
|
|
const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" });
|
|
expect(acknowledge).toHaveAttribute("title", "Acknowledge");
|
|
expect(acknowledge).toHaveTextContent("Acknowledge");
|
|
|
|
apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned])));
|
|
await user.click(acknowledge);
|
|
|
|
expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge");
|
|
await waitFor(() =>
|
|
expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(),
|
|
);
|
|
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
|
|
});
|
|
|
|
it("keeps the row when the acknowledgement fails", async () => {
|
|
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
|
apiPostNoContent.mockRejectedValue(new Error("boom"));
|
|
const user = userEvent.setup();
|
|
renderBell();
|
|
|
|
const panel = await openPanel(user);
|
|
await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" }));
|
|
|
|
await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1));
|
|
await waitFor(() =>
|
|
expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(),
|
|
);
|
|
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();
|
|
});
|
|
});
|