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/AiWorkspacePanel", () => () =>
AI panel
); 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: () =>
Application drafts section
, })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () =>
Interview 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(() => { 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(() => 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("button", { name: "Match" })); 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("Match section")).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("button", { 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("button", { name: "Match" })); fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" })); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match")); }); 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, includeDeleted: true, sortBy: "company", sortDir: "asc", }), })); });