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; 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() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((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)[key]; } async function changeLanguage(locale: UiLanguage) { await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); }); } function renderPopover(overrides: Partial> = {}) { 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(); 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(); mockedApi.get.mockResolvedValue({ data: [] } as any); rerender(); 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(); 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(); 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(); 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(); 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(); 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) { 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(); const currentRead = deferred(); mockedApi.delete.mockImplementationOnce(() => oldDismiss.promise); mockedApi.post.mockImplementation(() => currentRead.promise); const { rerender, props, anchor } = renderPopover(); fireEvent.click(await screen.findByRole("button", { name: "Dismiss" })); rerender(); const current = { ...notification, id: "33333333-3333-4333-8333-333333333333", title: "Reopened panel notification", linkPath: "/jobs/42" }; mockedApi.get.mockResolvedValue({ data: [current] } as any); rerender(); 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(); 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(); 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(); 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(); 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(); 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(); });