fix(notifications): preserve action state and accessible recovery
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user