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

262 lines
22 KiB
TypeScript

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 { I18nProvider } from "./i18n/I18nProvider";
import OperationsPage from "./views/OperationsPage";
import { translations } 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<typeof api>;
function renderPage() {
return render(<I18nProvider><OperationsPage /></I18nProvider>);
}
const operation = {
id: "11111111-1111-1111-1111-111111111111",
taskType: "strategy_snapshot",
status: "running",
createdAtUtc: "2026-08-02T12:00:00Z",
progressStage: "Analysing role",
progressPercent: 40,
canCancel: true,
canRetry: false,
};
const notification = {
id: "22222222-2222-2222-2222-222222222222",
operationId: operation.id,
kind: "operation_failed",
title: "Operation failed",
message: "A background operation failed. Review it for details.",
createdAtUtc: "2026-08-02T12:05:00Z",
readAtUtc: null,
};
let serverOperations: any[];
let serverNotifications: any[];
function deferred<T = any>() {
let resolve!: (value: T) => void;
let reject!: (cause: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function language(value: "en" | "nb") {
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: value })));
}
function response(url: string) { return { data: url.startsWith("/operations") ? serverOperations : serverNotifications } as any; }
function activity() { return screen.getByRole("region", { name: "Recent activity" }); }
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
document.documentElement.lang = "en";
serverOperations = [{ ...operation }]; serverNotifications = [{ ...notification }];
mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve(response(url)));
mockedApi.post.mockReset().mockImplementation((url: string) => {
if (url.startsWith("/operations")) {
serverOperations = serverOperations.map(item => ({ ...item, status: url.endsWith("/cancel") ? "cancelled" : "queued", canCancel: url.endsWith("/retry"), canRetry: url.endsWith("/cancel") }));
return Promise.resolve({ data: serverOperations[0] } as any);
}
serverNotifications = serverNotifications.map(item => ({ ...item, readAtUtc: "2026-08-02T12:06:00Z" }));
return Promise.resolve({ data: undefined } as any);
});
mockedApi.delete.mockReset().mockImplementation(() => { serverNotifications = []; return Promise.resolve({ data: undefined } as any); });
});
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); });
test("shows persistent operation and notification states with accessible actions", async () => {
renderPage();
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
const task = screen.getByRole("article", { name: operation.taskType });
expect(task).toHaveAccessibleName(operation.taskType);
expect(task).toHaveAttribute("data-operation-id", operation.id);
expect(screen.getByText("Analysing role")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "strategy_snapshot progress" })).toHaveAttribute("aria-valuenow", "40");
expect(screen.getByText("Operation failed")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/operations/${operation.id}/cancel`));
const markRead = screen.getByRole("button", { name: "Mark read" });
await waitFor(() => expect(markRead).toBeEnabled());
fireEvent.click(markRead);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`));
const dismiss = screen.getByRole("button", { name: "Dismiss" });
await waitFor(() => expect(dismiss).toBeEnabled());
fireEvent.click(dismiss);
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith(`/notifications/${notification.id}`));
});
test("shows honest empty and failure states", async () => {
mockedApi.get.mockResolvedValueOnce({ data: [] } as any).mockResolvedValueOnce({ data: [] } as any);
const { unmount } = renderPage();
expect(await screen.findByText("No notifications.")).toBeInTheDocument();
expect(screen.getByText("No background operations yet.")).toBeInTheDocument();
unmount();
mockedApi.get.mockRejectedValue(new Error("offline"));
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument();
expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
});
test("localizes operation controls and empty states in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockResolvedValue({ data: [] } as any);
renderPage();
expect(await screen.findByText("Ingen varsler.")).toBeInTheDocument();
expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument();
});
test("[P7-08 operations] initial failure offers single-flight GET Retry without false empty or writes", async () => {
mockedApi.get.mockRejectedValue(new Error("offline")); renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect((mockedApi.get.mock.calls[0][1]?.signal as AbortSignal).aborted).toBe(true);
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
const retry = screen.getByRole("button", { name: "Retry" }); act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
expect(mockedApi.get).toHaveBeenCalledTimes(4); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: [] })); expect(await screen.findByText("No notifications.")).toBeVisible();
expect(screen.getByRole("heading", { name: "Recent activity", level: 2 })).toHaveFocus(); expect(activity()).toHaveAttribute("aria-busy", "false");
expect(mockedApi.get).toHaveBeenCalledWith("/operations?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
test("[P7-08 operations] cached refresh failure preserves known rows and does not fabricate empty results", async () => {
renderPage(); expect(await screen.findByText(operation.taskType)).toBeVisible(); mockedApi.get.mockRejectedValue(new Error("offline"));
fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
expect(screen.getByText(operation.taskType)).toBeVisible(); expect(screen.getByText(notification.title)).toBeVisible();
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each(["Cancel", "Retry", "Mark read", "Dismiss"])("[P7-08 operations] same-render duplicate %s reserves exactly one mutation and blocks competing work", async label => {
if (label === "Retry") serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
const pending = deferred(); mockedApi.post.mockImplementation(() => pending.promise); mockedApi.delete.mockImplementation(() => pending.promise);
renderPage(); const button = await screen.findByRole("button", { name: label });
act(() => { fireEvent.click(button); fireEvent.click(button); fireEvent.click(screen.getByRole("button", { name: label === "Dismiss" ? "Mark read" : "Dismiss" })); });
expect(mockedApi.post.mock.calls.length + mockedApi.delete.mock.calls.length).toBe(1); expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
await act(async () => pending.resolve({ data: label === "Cancel" || label === "Retry" ? { ...serverOperations[0], canCancel: false, canRetry: false } : undefined }));
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
});
test("[P7-08 operations] language and ordinary rerenders reformat cached data without refetch or polling restart", async () => {
const intervals = jest.spyOn(window, "setInterval"); const cleared = jest.spyOn(window, "clearInterval");
serverOperations = [{ ...operation, taskType: "strategy.snapshot", status: "waiting_for_retry" }]; const { rerender } = renderPage();
expect(await screen.findByText("Strategy snapshot")).toBeVisible(); expect(screen.getByText("Waiting to retry")).toBeVisible(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
const starts = intervals.mock.calls.length; const pollId = intervals.mock.results[intervals.mock.calls.findIndex(([, delay]) => delay === 15000)].value; await language("nb");
expect(screen.getByText("Strategioversikt")).toBeVisible(); expect(screen.getByText("Venter på nytt forsøk")).toBeVisible();
expect(screen.getByText(`Opprettet ${new Date(operation.createdAtUtc).toLocaleString("nb-NO")}`)).toBeVisible();
rerender(<I18nProvider><OperationsPage /></I18nProvider>); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(intervals).toHaveBeenCalledTimes(starts); expect(cleared).not.toHaveBeenCalledWith(pollId);
});
test("[P7-08 operations] 15-second polling is single-flight and resumes after the held pair completes", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {}); expect(mockedApi.get).toHaveBeenCalledTimes(2);
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
await act(async () => jest.advanceTimersByTime(30000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
await act(async () => pending.resolve({ data: [] })); mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(6); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("[P7-08 operations] mutation invalidates a held old poll before adopting the returned same-ID DTO", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {}); const oldOperations = deferred(); const oldNotices = deferred();
mockedApi.get.mockImplementationOnce(() => oldOperations.promise).mockImplementationOnce(() => oldNotices.promise);
await act(async () => jest.advanceTimersByTime(15000)); const signal = mockedApi.get.mock.calls[2][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal);
const post = deferred(); const refresh = deferred(); mockedApi.post.mockImplementationOnce(() => post.promise); mockedApi.get.mockImplementation(() => refresh.promise);
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(signal.aborted).toBe(true);
const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
await act(async () => post.resolve({ data: cancelled })); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Cancelled");
await act(async () => { oldOperations.resolve({ data: [{ ...operation, progressStage: "Stale old poll" }] }); oldNotices.resolve({ data: [notification] }); });
expect(screen.queryByText("Stale old poll")).not.toBeInTheDocument();
await act(async () => refresh.resolve({ data: [] })); expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test("[P7-08 operations] committed cancel plus failed refresh announces truth and GET Retry cannot replay or unlock stale writes", async () => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
mockedApi.post.mockResolvedValueOnce({ data: cancelled } as any); mockedApi.get.mockRejectedValue(new Error("read offline")); act(() => cancel.focus()); fireEvent.click(cancel);
const failure = await screen.findByRole("alert"); expect(failure).toHaveTextContent("The action completed, but recent activity could not be refreshed. Refresh before taking another action.");
const item = screen.getByRole("article", { name: operation.taskType }); expect(within(item).getByRole("button", { name: "Retry" })).toBeDisabled(); expect(item).toHaveTextContent("Cancelled"); expect(changed).toHaveBeenCalledTimes(1);
expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
serverOperations = [cancelled]; mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
fireEvent.click(within(failure).getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(changed).toHaveBeenCalledTimes(1); expect(within(item).getByRole("button", { name: "Retry" })).toBeEnabled();
} finally { window.removeEventListener("notifications-changed", changed); }
});
test.each(["Mark read", "Dismiss"])("[P7-08 operations] committed204 %s updates notices locally even when refresh fails without completing a task", async label => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const action = await screen.findByRole("button", { name: label }); mockedApi.get.mockRejectedValue(new Error("offline")); fireEvent.click(action);
expect(await screen.findByRole("alert")).toHaveTextContent("The action completed, but recent activity could not be refreshed.");
expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); if (label === "Dismiss") expect(screen.queryByText(notification.title)).not.toBeInTheDocument(); else expect(screen.getByText(notification.title)).toBeVisible();
expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Running"); expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(mockedApi.post.mock.calls.filter(([url]) => String(url).startsWith("/operations"))).toEqual([]); expect(changed).toHaveBeenCalledTimes(1);
} finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] failed POST preserves eligibility and current-locale recovery without false commit or new GET", async () => {
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); act(() => cancel.focus()); fireEvent.click(cancel); await language("nb"); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await act(async () => pending.reject(new Error("offline"))); expect(await screen.findByRole("alert")).toHaveTextContent(translations.nb.operationsActionFailed); expect(screen.getByRole("button", { name: translations.nb.cancel })).toBeEnabled(); expect(screen.getByRole("button", { name: translations.nb.cancel })).toHaveFocus(); expect(changed).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await language("en"); expect(screen.getByRole("alert")).toHaveTextContent(translations.en.operationsActionFailed); expect(mockedApi.get).toHaveBeenCalledTimes(2);
} finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] server action admission overrides displayed status and unknown metadata remains honest", async () => {
serverOperations = [{ ...operation, taskType: "Unknown_Task_Type", status: "succeeded", progressStage: "arbitrary-stage", failureCategory: "unmapped_category", canCancel: true, canRetry: false }, { ...operation, id: "foreign-looking-local-fixture", taskType: "cv.process", status: "running", canCancel: false, canRetry: true }, { ...operation, id: "unmapped-prototype-code", taskType: "toString", status: "constructor", canCancel: false, canRetry: false }];
renderPage(); expect(await screen.findByText("Unknown_Task_Type")).toBeVisible(); const first = screen.getByRole("article", { name: "Unknown_Task_Type" }); expect(first).toHaveAttribute("data-operation-id", operation.id); expect(within(first).getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(within(first).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); expect(first).toHaveTextContent("arbitrary-stage"); expect(first).toHaveTextContent("unmapped category");
const second = screen.getByRole("article", { name: "CV processing" }); expect(second).toHaveAttribute("data-operation-id", "foreign-looking-local-fixture"); expect(within(second).queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(within(second).getByRole("button", { name: "Retry" })).toBeEnabled();
const unknown = screen.getByRole("article", { name: "toString" }); expect(unknown).toHaveAttribute("data-operation-id", "unmapped-prototype-code"); expect(unknown).toHaveTextContent("constructor"); expect(within(unknown).queryAllByRole("button")).toHaveLength(0);
});
test("[P7-08 operations] unmount aborts a pending read and late completion cannot fetch or publish", async () => {
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise); const { unmount } = renderPage(); const signal = mockedApi.get.mock.calls[0][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal); unmount(); expect(signal.aborted).toBe(true); await act(async () => pending.resolve({ data: [operation] })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("[P7-08 operations] unmount during a write prevents its late follow-up GET, event and focus effects", async () => {
const changed = jest.fn(); window.addEventListener("notifications-changed", changed); const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise);
try { const { unmount } = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); unmount(); await act(async () => pending.resolve({ data: { ...operation, status: "cancelled" } })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(changed).not.toHaveBeenCalled(); } finally { window.removeEventListener("notifications-changed", changed); }
});
test("[P7-08 operations] removing the initiating control restores meaningful focus while deliberate focus elsewhere stays put", async () => {
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); renderPage(); const mark = await screen.findByRole("button", { name: "Mark read" }); act(() => mark.focus()); fireEvent.click(mark); serverNotifications = [{ ...notification, readAtUtc: "2026-08-02T12:06:00Z" }]; await act(async () => pending.resolve({ data: undefined })); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
const next = deferred(); mockedApi.delete.mockImplementationOnce(() => next.promise); const dismiss = screen.getByRole("button", { name: "Dismiss" }); act(() => dismiss.focus()); fireEvent.click(dismiss); const external = document.createElement("button"); external.textContent = "Deliberate external target"; document.body.appendChild(external); act(() => external.focus()); serverNotifications = []; await act(async () => next.resolve({ data: undefined })); expect(external).toHaveFocus(); external.remove();
});
test("[P7-08 operations] accepted background polls preserve surviving and deliberate focus and recover removed focused controls", async () => {
jest.useFakeTimers(); renderPage(); await act(async () => {});
const cancel = screen.getByRole("button", { name: "Cancel" }); act(() => cancel.focus());
serverOperations = [{ ...operation, progressStage: "Fresh progress with the same action" }];
await act(async () => jest.advanceTimersByTime(15000)); expect(cancel).toHaveFocus(); expect(cancel.isConnected).toBe(true);
const external = document.createElement("button"); external.textContent = "Deliberate target outside recent activity"; document.body.appendChild(external);
try {
act(() => external.focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus(); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
serverOperations = [{ ...operation }]; await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus();
act(() => screen.getByRole("button", { name: "Cancel" }).focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Mark read" }).focus()); serverNotifications = [];
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(12); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
} finally { external.remove(); }
});