Files
jobtrackingapp/job-tracker-ui/src/admin-audit-page.test.tsx
T

135 lines
7.0 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api, getApiErrorMessage } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import AdminAuditPage from "./views/AdminAuditPage";
import { translations } from "./i18n/translations";
const mockToast = jest.fn();
let mockSession = "one";
jest.mock("./sessionBoundary", () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSession, isSessionInvalidated: () => false }));
jest.mock("./toast", () => ({ ...jest.requireActual("./toast"), useToast: () => ({ toast: mockToast }) }));
jest.mock("./api", () => ({
api: { get: jest.fn(), post: jest.fn() },
getApiErrorMessage: jest.fn((_error, fallback) => fallback),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(<ToastProvider><I18nProvider><AdminAuditPage /></I18nProvider></ToastProvider>);
}
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
mockSession = "one";
});
const row = { id: 7, type: "Deleted", at: "2026-10-20T12:00:00Z", jobApplicationId: 42, note: "Owner authored text" };
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
test("[P7-10 audit] retains shared heading hierarchy and a named keyboard scroll region", async () => {
mockedApi.get.mockResolvedValue({ data: [row] } as any);
renderPage();
await screen.findByRole("button", { name: "Undo" });
expect(screen.getByRole("heading", { name: "Audit log", level: 2 })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: translations.en.adminAuditEvents, level: 3 })).toBeInTheDocument();
const region = screen.getByRole("region", { name: "Audit log" });
expect(region).toHaveAttribute("tabindex", "0");
expect(within(region).getByRole("table")).toHaveAccessibleName("Audit log");
});
test("[P7-10 audit] exact Undo allowlist and deleted-only Restore retain endpoint contracts", async () => {
const types = ["StatusChanged", "FollowUpSet", "ResponseUpdated", "Deleted", "Restored", "Created", "NoteAdded"];
mockedApi.get.mockResolvedValue({ data: types.map((type, index) => ({ ...row, type, id: index + 1 })) } as any);
renderPage();
expect(await screen.findAllByRole("button", { name: "Undo" })).toHaveLength(5);
expect(screen.getAllByRole("button", { name: "Restore" })).toHaveLength(1);
});
test("[P7-10 audit] Undo and Restore serialize across rows, retaining focus on pending table", async () => {
const held = deferred<any>();
mockedApi.get.mockResolvedValue({ data: [row, { ...row, id: 8 }] } as any);
mockedApi.post.mockReturnValue(held.promise);
renderPage();
const undo = (await screen.findAllByRole("button", { name: "Undo" }))[0];
act(() => undo.focus());
act(() => { fireEvent.click(undo); fireEvent.click(undo); fireEvent.click(screen.getAllByRole("button", { name: "Restore" })[1]); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/admin/audit/7/undo", {});
expect(screen.getByRole("region", { name: "Audit log" })).toHaveFocus();
expect(screen.getByRole("region", { name: "Audit log" })).toHaveAttribute("aria-busy", "true");
expect(screen.getAllByRole("button", { name: "Undo" })[1]).toBeDisabled();
await act(async () => held.resolve({ data: { ok: true } }));
});
test("[P7-10 audit] a committed Restore with failed refresh recovers through GET only", async () => {
mockedApi.get.mockResolvedValueOnce({ data: [row] } as any).mockRejectedValueOnce(new Error("offline")).mockResolvedValue({ data: [] } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Restore" }));
expect(await screen.findByText(translations.en.adminAuditLoadFailed)).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/42/restore", {});
const retry = screen.getByRole("button", { name: "Retry" }); act(() => retry.focus()); fireEvent.click(retry);
expect(await screen.findByText("No events.")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Audit log" })).toHaveFocus();
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(3);
});
test.each(["unmount", "session"])("[P7-10 audit] late Undo after %s cannot notify or refresh", async boundary => {
const held = deferred<any>(); mockedApi.post.mockReturnValue(held.promise); mockedApi.get.mockResolvedValue({ data: [row] } as any);
const view = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
if (boundary === "unmount") view.unmount(); else mockSession = "two";
await act(async () => held.resolve({ data: { message: "Undone" } }));
expect(mockToast).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-10 audit] pending failure uses safe current-language feedback without a locale GET", async () => {
const held = deferred<any>(); mockedApi.post.mockReturnValue(held.promise); mockedApi.get.mockResolvedValue({ data: [row] } as any);
renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
const error = { response: { data: { title: "<html>proxy error</html>" } } };
await act(async () => held.resolve(Promise.reject(error)));
await waitFor(() => expect(mockToast).toHaveBeenCalledWith(translations.nb.adminAuditUndoFailed, "error"));
expect(getApiErrorMessage).toHaveBeenCalledWith(error, translations.nb.adminAuditUndoFailed);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("distinguishes an unavailable audit log from an empty audit log and retries", async () => {
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
renderPage();
expect(screen.getByRole("heading", { name: "Audit log", level: 2 })).toBeInTheDocument();
expect(await screen.findByText("Unable to load audit events")).toBeInTheDocument();
expect(screen.queryByText("No events.")).not.toBeInTheDocument();
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("No events.")).toBeInTheDocument();
});
test("localizes audit actions in Bokmål without translating stored event data", async () => {
window.localStorage.setItem("uiLanguage", "nb-NO");
mockedApi.get.mockResolvedValueOnce({ data: [{
id: 7,
type: "Deleted",
at: "2026-08-29T12:00:00Z",
jobApplicationId: 42,
companyName: "Example AS",
note: "User-authored note",
}] } as any);
renderPage();
expect(await screen.findByRole("button", { name: "Angre" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Gjenopprett" })).toBeInTheDocument();
expect(screen.getByText("Example AS - Jobb #42")).toBeInTheDocument();
expect(screen.getByText("User-authored note")).toBeInTheDocument();
});