Files
jobtrackingapp/job-tracker-ui/src/notifications-popover.test.tsx
T

332 lines
19 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import NotificationsPopover from "./components/NotificationsPopover";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_error: unknown, fallback?: string) => fallback || "Request failed.",
}));
const mockedApi = api as jest.Mocked<typeof api>;
const notification = {
id: "22222222-2222-2222-2222-222222222222",
operationId: null,
kind: "operation_failed",
title: "CV export failed",
message: "Review the operation and try again.",
linkPath: "/operations",
createdAtUtc: "2026-08-15T12:00:00Z",
readAtUtc: null,
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function copy(key: string, locale: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
return (translations[locale] as Record<string, string>)[key];
}
async function changeLanguage(locale: UiLanguage) {
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); });
}
function renderPopover(overrides: Partial<React.ComponentProps<typeof NotificationsPopover>> = {}) {
const anchor = document.createElement("button");
document.body.appendChild(anchor);
const props = {
anchorEl: anchor,
onClose: jest.fn(),
onNavigate: jest.fn(),
onChanged: jest.fn(),
...overrides,
};
const result = render(<I18nProvider><NotificationsPopover {...props} /></I18nProvider>);
return { ...result, props, anchor };
}
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockReset().mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockReset().mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockReset().mockResolvedValue({ data: {} } as any);
});
afterEach(() => jest.restoreAllMocks());
test("shows notifications and updates read and dismissed state in place", async () => {
const { props } = renderPopover();
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
fireEvent.click(screen.getByRole("button", { name: "Mark read" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`));
expect(props.onChanged).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith(`/notifications/${notification.id}`));
await waitFor(() => expect(screen.queryByText("CV export failed")).not.toBeInTheDocument());
expect(props.onChanged).toHaveBeenCalledTimes(2);
});
test("opens a notification destination instead of routing the bell itself", async () => {
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() => expect(props.onNavigate).toHaveBeenCalledWith("/operations"));
expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
test("shows an honest empty state", async () => {
mockedApi.get.mockResolvedValue({ data: [] } as any);
renderPopover();
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
});
test("[P5-02 PRES-E09] a closed notification request cannot populate a later account's panel", async () => {
let resolveOld!: (value: unknown) => void;
mockedApi.get.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }) as never);
const { rerender, props, anchor } = renderPopover();
await waitFor(() => expect(resolveOld).toBeDefined());
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
mockedApi.get.mockResolvedValue({ data: [] } as any);
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
await act(async () => resolveOld({ data: [notification] }));
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
expect(screen.queryByText("CV export failed")).not.toBeInTheDocument();
});
test("[P5-02 PRES-E09] a delayed read cannot navigate from a closed earlier-account panel", async () => {
let resolveRead!: (value: unknown) => void;
mockedApi.post.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }) as never);
const { rerender, props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await waitFor(() => expect(resolveRead).toBeDefined());
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
await act(async () => resolveRead({ data: {} }));
expect(props.onNavigate).not.toHaveBeenCalled();
});
test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an empty inbox", async () => {
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent("Notifications could not be loaded");
expect(screen.queryByText("You're all caught up")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
});
test.each([
["Open", "post"],
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] same-render duplicate %s starts one mutation", async (label, method) => {
const pending = deferred<any>();
mockedApi[method].mockImplementation(() => pending.promise);
const { props } = renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => { fireEvent.click(action); fireEvent.click(action); });
expect(mockedApi[method]).toHaveBeenCalledTimes(1);
expect(method === "post" ? mockedApi.delete : mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
if (label === "Open") {
expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations");
expect(props.onClose).toHaveBeenCalledTimes(1);
} else {
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
}
});
test("[P7-08 notifications] Open cannot navigate while another notification mutation is pending", async () => {
const pending = deferred<any>(); mockedApi.delete.mockImplementation(() => pending.promise);
const { props } = renderPopover();
const dismiss = await screen.findByRole("button", { name: "Dismiss" });
const open = screen.getByRole("button", { name: "Open" });
act(() => { fireEvent.click(dismiss); fireEvent.click(open); });
expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
});
test("[P7-08 notifications] failed Open retains its unread notification and GET Retry cannot repeat the write or navigate", async () => {
mockedApi.post.mockRejectedValueOnce(new Error("offline"));
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead") })).toBeEnabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onChanged).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("notificationsOpen") }));
await waitFor(() => expect(props.onNavigate).toHaveBeenCalledWith("/operations"));
expect(mockedApi.post).toHaveBeenCalledTimes(2); expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onClose).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] changing language during a held read does not reload or suppress its current successful Open", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByText(notification.title)).toBeVisible();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations"); expect(props.onClose).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] a held action failure uses the current language and preserves unread recovery without a locale refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Mark read" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => pending.reject(new Error("offline")));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "nb"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread", "nb"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead", "nb") })).toBeEnabled();
await changeLanguage("en");
expect(screen.getByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "en"));
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test("[P7-08 notifications] Bokmål notification dates follow selected UI language rather than browser default", async () => {
localStorage.setItem("uiLanguage", "nb");
const originalFormat = Date.prototype.toLocaleString;
const expected = originalFormat.call(new Date(notification.createdAtUtc), "nb-NO");
jest.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Date, ...args: Parameters<Date["toLocaleString"]>) {
return originalFormat.call(this, args[0] ?? "en-US", args[1]);
});
renderPopover();
expect(await screen.findByText(expected)).toBeVisible();
expect(screen.getByRole("heading", { name: copy("notifications", "nb") })).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] an older closed-panel write cannot mutate or unlock a reopened panel's pending Open", async () => {
const oldDismiss = deferred<any>(); const currentRead = deferred<any>();
mockedApi.delete.mockImplementationOnce(() => oldDismiss.promise); mockedApi.post.mockImplementation(() => currentRead.promise);
const { rerender, props, anchor } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Dismiss" }));
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
const current = { ...notification, id: "33333333-3333-4333-8333-333333333333", title: "Reopened panel notification", linkPath: "/jobs/42" };
mockedApi.get.mockResolvedValue({ data: [current] } as any);
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${current.id}/read`);
await act(async () => oldDismiss.resolve({ data: {} }));
expect(screen.getByText(current.title)).toBeVisible();
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await act(async () => currentRead.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/jobs/42");
expect(props.onClose).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test("[P7-08 notifications] an unmounted pending Open cannot publish changes, close, navigate or refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { unmount, props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
unmount();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("[P7-08 notifications] same-render GET Retry is single-flight and cannot issue a notification write", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
const { props } = renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test.each([
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] removing the focused %s action leaves focus on a meaningful panel target", async (label, method) => {
const pending = deferred<any>(); mockedApi[method].mockImplementation(() => pending.promise);
renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => action.focus()); expect(action).toHaveFocus();
fireEvent.click(action);
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument());
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(action);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
});
test("[P7-08 notifications] completing an action preserves deliberate focus on Close", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
renderPopover();
const markRead = await screen.findByRole("button", { name: "Mark read" });
act(() => markRead.focus()); fireEvent.click(markRead);
const close = screen.getByRole("button", { name: copy("close") });
act(() => close.focus()); expect(close).toHaveFocus();
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument());
expect(close).toHaveFocus(); expect(screen.getByText(notification.title)).toBeVisible();
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] successful focused GET Retry leaves focus on a meaningful panel target after its notice disappears", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => retry.focus()); expect(retry).toHaveFocus();
fireEvent.click(retry);
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(retry);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});