Files
jobtrackingapp/job-tracker-ui/src/career-evidence.test.tsx
T

183 lines
10 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import CareerEvidenceBank from "./views/career/CareerEvidenceBank";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { ConfirmProvider } from "./confirm";
import { api } from "./api";
jest.mock("./api", () => ({
api: { get: jest.fn(), post: jest.fn(), delete: jest.fn(), put: jest.fn(), patch: jest.fn() },
getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage,
}));
const mocked = api as jest.Mocked<typeof api>;
const evidence = { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" };
function open(language: "en" | "nb" = "en") {
window.localStorage.setItem("uiLanguage", language);
return render(<I18nProvider><ConfirmProvider><CareerEvidenceBank /></ConfirmProvider></I18nProvider>);
}
function fillDraft() {
fireEvent.change(screen.getByLabelText("Short title", { exact: false }), { target: { value: evidence.title } });
fireEvent.change(screen.getByLabelText("Evidence statement", { exact: false }), { target: { value: evidence.statement } });
fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability, C#" } });
fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: evidence.sourceReference } });
}
function expectNoWrites() {
for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) expect(method).not.toHaveBeenCalled();
}
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.removeItem("uiLanguage");
mocked.get.mockReset().mockResolvedValue({ data: [] } as any);
for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) method.mockReset().mockRejectedValue(new Error("Unexpected write"));
});
afterEach(() => window.localStorage.removeItem("uiLanguage"));
test("saves factual evidence with provenance and tags", async () => {
mocked.post.mockResolvedValue({ data: evidence } as any);
open();
await screen.findByText(translations.en.careerEvidenceEmpty);
fillDraft();
fireEvent.click(screen.getByRole("button", { name: "Save evidence" }));
await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", {
category: "achievement", title: evidence.title, statement: evidence.statement,
tags: ["reliability", " C#"], sourceType: "manual", sourceReference: evidence.sourceReference, isVerified: true,
}));
expect(await screen.findByText(evidence.statement)).toBeInTheDocument();
});
test("pending evidence reads announce loading without claiming an empty bank or allowing a raced save", async () => {
let finish!: (value: any) => void;
mocked.get.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
open();
expect(screen.getByRole("status")).toHaveTextContent(translations.en.loading);
expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument();
fillDraft();
const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave });
expect(save).toBeDisabled();
fireEvent.click(save);
expectNoWrites();
await act(async () => finish({ data: [] }));
expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible();
expect(save).toBeEnabled();
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
});
test("failed list reads remain distinct from empty state and retry only GET while preserving the draft", async () => {
mocked.get.mockRejectedValueOnce({ response: { status: 503, data: "Evidence list unavailable" } });
open();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Evidence list unavailable");
expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument();
fillDraft();
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible();
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
expect(mocked.get.mock.calls.map(([url]) => url)).toEqual(["/career/evidence", "/career/evidence"]);
expectNoWrites();
});
test("failed saves retain factual inputs and retry the same explicit save without reloading the list", async () => {
mocked.post.mockRejectedValueOnce({ response: { status: 503, data: "Evidence save unavailable" } }).mockResolvedValueOnce({ data: evidence } as any);
open();
await screen.findByText(translations.en.careerEvidenceEmpty);
fillDraft();
const form = screen.getByRole("region", { name: translations.en.careerEvidenceAdd });
fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave }));
expect(await within(form).findByRole("alert")).toHaveTextContent("Evidence save unavailable");
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
expect(screen.getByLabelText("Skills and keywords")).toHaveValue("reliability, C#");
expect(screen.getByLabelText("Source or provenance")).toHaveValue(evidence.sourceReference);
fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave }));
expect(await screen.findByRole("article", { name: evidence.title })).toHaveTextContent(evidence.statement);
expect(mocked.post).toHaveBeenCalledTimes(2);
expect(mocked.post.mock.calls[1]).toEqual(mocked.post.mock.calls[0]);
expect(mocked.get).toHaveBeenCalledTimes(1);
expect(mocked.delete).not.toHaveBeenCalled();
expect(mocked.put).not.toHaveBeenCalled();
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue("");
});
test("a pending save prevents duplicate writes and prevents edits being cleared by the response", async () => {
let finish!: (value: any) => void;
mocked.post.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
open();
await screen.findByText(translations.en.careerEvidenceEmpty);
fillDraft();
const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave });
fireEvent.click(save);
expect(save).toBeDisabled();
expect(screen.getByLabelText("Short title", { exact: false })).toBeDisabled();
expect(screen.getByLabelText("Evidence statement", { exact: false })).toBeDisabled();
fireEvent.click(save);
expect(mocked.post).toHaveBeenCalledTimes(1);
await act(async () => finish({ data: evidence }));
expect(await screen.findByRole("article", { name: evidence.title })).toBeVisible();
expect(save).toBeDisabled();
expect(screen.getByLabelText("Short title", { exact: false })).toBeEnabled();
});
test("contextual deletion is named and cancellation performs no writes or draft loss", async () => {
mocked.get.mockResolvedValue({ data: [evidence] } as any);
open();
const item = await screen.findByRole("article", { name: evidence.title });
fillDraft();
const deleteButton = within(item).getByRole("button", { name: /Reduced incidents/ });
deleteButton.focus();
fireEvent.click(deleteButton);
const dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
expect(dialog).toHaveTextContent(evidence.title);
expect(deleteButton).toBeEnabled();
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.cancel }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(deleteButton).toHaveFocus();
expect(item).toBeVisible();
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
expectNoWrites();
});
test("failed deletion keeps the row and draft, then reconfirms the same delete without list reload", async () => {
mocked.get.mockResolvedValue({ data: [evidence] } as any);
mocked.delete.mockRejectedValueOnce({ response: { status: 503, data: "Evidence deletion unavailable" } }).mockResolvedValueOnce({} as any);
open();
const item = await screen.findByRole("article", { name: evidence.title });
fillDraft();
fireEvent.click(within(item).getByRole("button", { name: /Reduced incidents/ }));
let dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction }));
expect(await within(item).findByRole("alert")).toHaveTextContent("Evidence deletion unavailable");
expect(mocked.delete).toHaveBeenCalledWith("/career/evidence/1");
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
fireEvent.click(within(item).getByRole("button", { name: translations.en.retry }));
dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction }));
await waitFor(() => expect(screen.queryByRole("article", { name: evidence.title })).not.toBeInTheDocument());
expect(mocked.delete.mock.calls.map(([url]) => url)).toEqual(["/career/evidence/1", "/career/evidence/1"]);
expect(mocked.get).toHaveBeenCalledTimes(1);
expect(mocked.post).not.toHaveBeenCalled();
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
await waitFor(() => expect(screen.getByLabelText("Short title", { exact: false })).toHaveFocus());
});
test("evidence sections and factual fields have semantic headings and required labels in Bokmål", async () => {
mocked.get.mockResolvedValue({ data: [evidence] } as any);
open("nb");
expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceTitle, level: 2 })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceAdd, level: 3 })).toBeInTheDocument();
expect(screen.getByLabelText(translations.nb.careerEvidenceItemTitle, { exact: false })).toBeRequired();
expect(screen.getByLabelText(translations.nb.careerEvidenceStatement, { exact: false })).toBeRequired();
const item = await screen.findByRole("article", { name: evidence.title });
expect(within(item).getByRole("heading", { name: evidence.title, level: 3 })).toBeInTheDocument();
expect(item).toHaveTextContent(translations.nb.careerEvidenceCategory_achievement);
expect(item).toHaveTextContent(evidence.sourceReference);
expectNoWrites();
});