Files
jobtrackingapp/job-tracker-ui/src/application-workspace-overlay.test.tsx
T
2026-10-07 01:58:31 +02:00

386 lines
18 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { createMemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
import JobTable from "./components/JobTable";
import { ConfirmProvider } from "./confirm";
import { I18nProvider } from "./i18n/I18nProvider";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
import ApplicationWorkspacePage from "./views/ApplicationWorkspacePage";
jest.mock("./components/Attachments", () => () => <div>Documents section</div>);
jest.mock("./components/Correspondence", () => () => <div>Communication section</div>);
jest.mock("./components/ApplicationChecklist", () => () => <div>Checklist section</div>);
jest.mock("./components/ApplicationIntelligence", () => ({
ApplicationAnalysis: () => <div>Analysis section</div>,
ApplicationMatch: () => <div>Match section</div>,
ApplicationTimeline: () => <div>Timeline section</div>,
}));
jest.mock("./components/ApplicationAssets", () => ({
ApplicationCoverLetterSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => (
<div>
Cover letter section
<button onClick={() => onDirtyChange?.(true)}>Make cover letter dirty</button>
</div>
),
ApplicationCvSection: () => <div>CV section</div>,
ApplicationPackageDraftsSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
SubmittedPackagesSection: () => <div>Frozen packages section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
jest.mock("./components/ApplicationWorkflowAssist", () => ({
ApplicationStatusSuggestion: () => <div>Status suggestion</div>,
ApplicationStrategySnapshot: () => <div>Strategy section</div>,
}));
const mockedApi = api as jest.Mocked<typeof api>;
// React Router's data router builds a Request for in-memory navigations. JSDOM does not provide
// one, and these tests do not run loaders or inspect request bodies, so a small contract stub is
// enough to exercise blocker/history behavior.
class RouterTestRequest {
url: string;
method: string;
signal?: AbortSignal;
headers: Headers;
body: unknown;
constructor(url: string, init: RequestInit = {}) {
this.url = url;
this.method = init.method ?? "GET";
this.signal = init.signal ?? undefined;
this.headers = new Headers(init.headers);
this.body = init.body;
}
}
Object.assign(globalThis, { Request: RouterTestRequest });
const job = {
id: 42,
jobTitle: "Backend Developer",
company: { id: 1, name: "Acme" },
companyId: 1,
status: "Waiting",
dateApplied: "2026-08-01T00:00:00Z",
savedAt: "2026-08-01T00:00:00Z",
location: "Oslo",
description: "Build APIs",
daysSince: 9,
isDeleted: false,
needsFollowUp: false,
workflowSignal: null,
};
const overview = {
id: 42,
jobTitle: "Backend Developer",
company: "Acme",
location: "Oslo",
salary: null,
status: "Waiting",
stageGroup: "Applied",
stageOrder: 1,
dateApplied: "2026-08-01T00:00:00Z",
deadline: null,
followUpAt: null,
nextAction: null,
jobUrl: null,
savedAt: "2026-08-01T00:00:00Z",
description: "Build APIs",
translatedDescription: null,
descriptionLanguage: "en",
tags: [".NET", "SQL"],
notes: "Ask about the platform team.",
applicationAnswerDraft: "Saved answer",
recruiterMessageDraft: "Saved recruiter message",
source: "nav",
countryCode: "NO",
hasJobDescription: true,
cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null },
hasCoverLetter: false,
documentCount: 0,
hasPortfolio: false,
aiInteractionCount: 0,
lastAiAtUtc: null,
recentActivity: [],
nextStep: null,
checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 },
};
function LocationControls() {
const location = useLocation();
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
</>
);
}
function renderTable(path = "/jobs") {
const router = createMemoryRouter([
{
path: "*",
element: (
<>
<LocationControls />
<Routes>
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} />
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
</Routes>
</>
),
},
], { initialEntries: [path] });
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<RouterProvider router={router} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
window.localStorage.removeItem("uiLanguage");
mockedApi.get.mockImplementation((url: string) => {
if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any);
if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any);
if (url === "/jobapplications/42/workspace") return Promise.resolve({ data: overview } as any);
if (url === "/jobapplications/999/workspace") return Promise.reject({ response: { status: 404, data: { detail: "Application not found." } } });
return Promise.resolve({ data: [] } as any);
});
});
afterEach(() => {
window.localStorage.removeItem("uiLanguage");
jest.clearAllMocks();
});
test("opens the dedicated workspace from the whole row and preserves list state on return", async () => {
renderTable();
const search = await screen.findByRole("textbox", { name: /search/i });
fireEvent.change(search, { target: { value: "backend" } });
fireEvent.click(await screen.findByRole("row", { name: /open backend developer/i }));
expect(await screen.findByText("Backend Developer")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42");
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match");
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend"));
expect(await screen.findByRole("textbox", { name: /search/i })).toHaveValue("backend");
await waitFor(() => expect(screen.getByRole("row", { name: /open backend developer/i })).toHaveFocus());
});
test("opens a direct workspace URL and returns to applications", async () => {
renderTable("/jobs/42?section=match");
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Match" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText(/application progress: waiting/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
});
test("row controls do not trigger navigation", async () => {
renderTable();
const row = await screen.findByRole("row", { name: /open backend developer/i });
const checkbox = within(row).getByRole("checkbox");
fireEvent.click(checkbox);
expect(screen.getByTestId("location")).toHaveTextContent(/^\/jobs$/);
expect(checkbox).toBeChecked();
});
test("handles a deleted or inaccessible job without rendering a broken workspace", async () => {
renderTable("/jobs/999");
expect(await screen.findByRole("alert")).toHaveTextContent("Could not open this application");
expect(screen.getByRole("button", { name: /back to applications/i })).toBeInTheDocument();
});
test("warns before section navigation would discard application edits", async () => {
renderTable("/jobs/42?section=cover-letter");
fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" }));
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match"));
});
test("renders shared workspace navigation and progress in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockImplementation((url: string) => {
if (url === "/jobapplications/42/workspace") return Promise.resolve({ data: {
...overview,
nextStep: {
key: "prepare-cv",
label: "Prepare a CV for this role",
reason: "Attach a CV variant tailored to this application.",
section: "cv",
},
} } as any);
return Promise.resolve({ data: [] } as any);
});
renderTable("/jobs/42");
expect(await screen.findByRole("tab", { name: "Oversikt" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Samsvar" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Søknad" })).toBeInTheDocument();
expect(await screen.findByLabelText(/søknadsprogresjon/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /tilbake til søknader/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Forbered en CV for denne stillingen" })).toBeInTheDocument();
});
test("hydrates list filters, sort and page from a shareable URL", async () => {
renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2");
expect(await screen.findByRole("textbox", { name: /search/i })).toHaveValue("backend");
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", {
params: expect.objectContaining({
page: 2,
q: "backend",
status: "Interview",
companyId: 1,
location: "Oslo",
needsFollowUp: true,
readiness: "interview",
includeDeleted: true,
sortBy: "company",
sortDir: "asc",
}),
}));
});
test("names the progress scroll region and identifies the current stage without making stages actions", async () => {
renderTable("/jobs/42");
const progress = await screen.findByRole("region", { name: "Application progress: Waiting" });
expect(progress).toHaveAttribute("tabindex", "0");
expect(within(progress).getByText("Waiting").closest("[aria-current]")).toHaveAttribute("aria-current", "step");
expect(within(progress).queryByRole("button")).not.toBeInTheDocument();
});
test("keeps custom status literal without claiming a completed standard pipeline", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, status: "Faglig vurdering" } } as any);
renderTable("/jobs/42");
const progress = await screen.findByRole("region", { name: "Application progress: Faglig vurdering" });
expect(within(progress).queryByText("Offer")).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument();
});
test("retries a failed workspace read without creating or changing application content", async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } });
renderTable("/jobs/42");
fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("keeps the server-selected next action and preparation entry on the existing CV section", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, nextStep: { key: "prepare-cv", label: "Prepare a CV for this role", reason: "Attach the chosen variant.", section: "cv" } } } as any);
renderTable("/jobs/42");
fireEvent.click(await screen.findByRole("button", { name: "Prepare a CV for this role" }));
expect(await screen.findByText("CV section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cv");
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
fireEvent.click(await screen.findByRole("button", { name: "CV Not prepared" }));
expect(await screen.findByText("CV section")).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("uses recorded dates in the summary and keeps absent dates unknown", async () => {
renderTable("/jobs/42");
const summary = await screen.findByRole("region", { name: "Application information" });
expect(summary).toHaveTextContent(new Date(overview.dateApplied).toLocaleDateString("en"));
expect(within(summary).getAllByText("—")).toHaveLength(2);
expect(screen.queryByRole("link", { name: "Open original advert" })).not.toBeInTheDocument();
});
test("section changes retain follow-up query context and expose the selected labelled panel", async () => {
renderTable("/jobs/42?section=communication&followMode=waiting-update");
await screen.findByRole("heading", { name: "Backend Developer" });
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByRole("tabpanel", { name: "Match" })).toHaveTextContent("Match section");
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match&followMode=waiting-update");
const tab = screen.getByRole("tab", { name: "Match" });
expect(document.getElementById(tab.getAttribute("aria-controls")!)).toBe(screen.getByRole("tabpanel", { name: "Match" }));
});
test("accordion controls identify exactly one labelled content region", async () => {
renderTable("/jobs/42");
await screen.findByRole("heading", { name: "Backend Developer" });
const trigger = screen.getByRole("button", { name: "Job details" });
fireEvent.click(trigger);
const target = trigger.getAttribute("aria-controls")!;
expect(document.querySelectorAll(`[id='${target}']`)).toHaveLength(1);
expect(document.getElementById(target)).toHaveAttribute("role", "region");
expect(document.getElementById(target)).toHaveAttribute("aria-labelledby", trigger.id);
});
test("legacy document links reveal current attachments and frozen history in the named section", async () => {
renderTable("/jobs/42?section=documents");
expect(await screen.findByRole("tab", { name: "Documents" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "Documents" })).toHaveTextContent("Documents section");
expect(screen.getByRole("tabpanel", { name: "Documents" })).toHaveTextContent("Frozen packages section");
});
test("the missing-profile next task opens the existing canonical profile entry", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, nextStep: { key: "complete-career-profile", label: "Complete your career profile", reason: "Canonical data", section: "checklist" } } } as any);
renderTable("/jobs/42");
fireEvent.click(await screen.findByRole("button", { name: "Complete your career profile" }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/career?section=profile"));
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("application subsection navigation retains metadata and protects unsaved answers", async () => {
renderTable("/jobs/42?section=application&sub=answers&followMode=waiting-update");
fireEvent.click(await screen.findByRole("button", { name: "Make answers dirty" }));
fireEvent.click(screen.getByRole("tab", { name: "Submission" }));
fireEvent.click(await screen.findByRole("button", { name: "Keep editing" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("tabpanel", { name: "Answers & message draft" })).toHaveTextContent("Application drafts section");
fireEvent.click(screen.getByRole("tab", { name: "Submission" }));
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
expect(await screen.findByRole("tabpanel", { name: "Submission" })).toHaveTextContent("Frozen packages section");
expect(screen.getByTestId("location")).toHaveTextContent("section=application&sub=submission&followMode=waiting-update");
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("legacy correspondence and notes reveal distinct domain content", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, notes: "Human notes, not application answers" } } as any);
renderTable("/jobs/42?section=communication&followMode=waiting-update");
expect(await screen.findByRole("tabpanel", { name: "Communication" })).toHaveTextContent("Communication section");
fireEvent.click(screen.getByRole("tab", { name: "Notes & tasks" }));
expect(await screen.findByRole("region", { name: "Notes" })).toHaveTextContent("Human notes, not application answers");
expect(screen.getByRole("tabpanel", { name: "Notes & tasks" })).toHaveTextContent("Checklist section");
expect(screen.queryByText("Application drafts section")).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("followMode=waiting-update");
});