Files
jobtrackingapp/job-tracker-ui/src/notifications-popover.test.tsx
T
2026-09-28 18:19:27 +02:00

118 lines
5.1 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { api } from "./api";
import NotificationsPopover from "./components/NotificationsPopover";
import { I18nProvider } from "./i18n/I18nProvider";
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 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(() => {
jest.clearAllMocks();
mockedApi.get.mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
});
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();
});