fix(notifications): preserve action state and accessible recovery

This commit is contained in:
cesnimda
2026-10-16 23:14:33 +02:00
parent 9dfea1226f
commit 40cfe04df5
22 changed files with 5963 additions and 66 deletions
@@ -1,10 +1,11 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
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: {
@@ -28,6 +29,21 @@ const notification = {
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);
@@ -43,12 +59,16 @@ function renderPopover(overrides: Partial<React.ComponentProps<typeof Notificati
}
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
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();
@@ -115,3 +135,197 @@ test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an
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();
});