135 lines
7.0 KiB
TypeScript
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();
|
|
});
|