332 lines
19 KiB
TypeScript
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();
|
|
});
|