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", () => () =>
Documents section
); jest.mock("./components/Correspondence", () => () =>
Communication section
); jest.mock("./components/ApplicationChecklist", () => () =>
Checklist section
); jest.mock("./components/ApplicationIntelligence", () => ({ ApplicationAnalysis: () =>
Analysis section
, ApplicationMatch: () =>
Match section
, ApplicationTimeline: () =>
Timeline section
, })); jest.mock("./components/ApplicationAssets", () => ({ ApplicationCoverLetterSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => (
Cover letter section
), ApplicationCvSection: () =>
CV section
, ApplicationPackageDraftsSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) =>
Application drafts section
, SubmittedPackagesSection: () =>
Frozen packages section
, })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () =>
Interview section
})); jest.mock("./components/ApplicationWorkflowAssist", () => ({ ApplicationStatusSuggestion: () =>
Status suggestion
, ApplicationStrategySnapshot: () =>
Strategy section
, })); const mockedApi = api as jest.Mocked; // 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 ( <> {location.pathname}{location.search} ); } function renderTable(path = "/jobs") { const router = createMemoryRouter([ { path: "*", element: ( <> {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} /> } /> ), }, ], { initialEntries: [path] }); return render( , ); } 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"); });