feat(admin): migrate audit presentation and protect pending actions

This commit is contained in:
cesnimda
2026-10-20 15:26:49 +02:00
parent 4760fd102c
commit f9e1d06bf7
12 changed files with 636 additions and 48 deletions
+79 -2
View File
@@ -1,11 +1,17 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
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() },
@@ -21,6 +27,77 @@ function renderPage() {
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 () => {