import React, { useState } from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { api } from "./api"; import JobTable, { type JobTableColumns } from "./components/JobTable"; import { ConfirmProvider } from "./confirm"; import { PromptProvider } from "./prompt"; import { ToastProvider } from "./toast"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { getUserScopedStorageKey, setAuthUserKey } from "./auth"; jest.mock("./components/EditJobDialog", () => ({ open, onClose }: { open: boolean; onClose: () => void }) => open ?
: null); const mockedApi = api as jest.Mocked; const job = { id: 42, companyId: 1, company: { id: 1, name: "Østlandet" }, jobTitle: "Platform engineer", status: "Custom review", dateApplied: "2026-08-01", daysSince: 2, location: "Tromsø", jobUrl: "https://example.test/listing", isDeleted: false }; const t = translations.en; function Consumer({ mode }: { mode: "jobs" | "trash" }) { const location = useLocation(); const [pageSize, setPageSize] = useState<15 | 20 | 25>(15); const [columns, setColumns] = useState({ status: true, dateApplied: true, daysSince: true, jobUrl: true }); return <>{location.pathname}{location.search}; } function show(mode: "jobs" | "trash" = "jobs", query = "") { return render(); } const calls = () => mockedApi.get.mock.calls.filter(([url]) => url === "/jobapplications"); const lastParams = () => calls().at(-1)?.[1]?.params as Record; beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job], total: 31, page: 1, pageSize: 15 } : [] } as any)); }); test("row status/edit/delete controls retain selection and do not open the workspace", async () => { show(); const row = await screen.findByRole("row", { name: /platform engineer/i }); fireEvent.click(within(row).getByRole("checkbox")); fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableEdit}: ${job.jobTitle}` })); expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible(); fireEvent.click(screen.getByText("Close editor")); expect(within(row).getByRole("checkbox")).toBeChecked(); fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableSoftDelete}: ${job.jobTitle}` })); fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: /cancel/i })); expect(mockedApi.delete).not.toHaveBeenCalled(); fireEvent.click(await within(row).findByRole("button", { name: `${t.jobTableQuickStatus}: ${job.jobTitle}` })); fireEvent.click(await screen.findByRole("menuitem", { name: /Interview/ })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Interview" })); expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs$/); }); test("table sort, page-size and optional columns keep their existing independent contracts", async () => { show("jobs", "?q=platform&location=Troms%C3%B8"); await screen.findByRole("row", { name: /open platform engineer/i }); fireEvent.click(screen.getByRole("button", { name: t.jobTableCompany })); await waitFor(() => expect(lastParams()).toMatchObject({ q: "platform", location: "Tromsø", sortBy: "company", sortDir: "asc" })); fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await waitFor(() => expect(lastParams().page).toBe(2)); fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i })); fireEvent.click(await screen.findByRole("option", { name: "20" })); await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 20 })); fireEvent.mouseDown(screen.getByRole("combobox", { name: /rows per page/i })); fireEvent.click(await screen.findByRole("option", { name: "25" })); await waitFor(() => expect(lastParams()).toMatchObject({ page: 1, pageSize: 25 })); fireEvent.click(screen.getByRole("button", { name: t.jobTableColumns })); fireEvent.click(await screen.findByRole("menuitem", { name: t.settingsColumnJobUrl })); expect(screen.queryByRole("link", { name: t.jobTableLink })).not.toBeInTheDocument(); }); test("Trash select-all and bulk restore use the original IDs without workspace navigation", async () => { show("trash"); await screen.findByText(job.jobTitle); fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll })); fireEvent.click(screen.getByRole("button", { name: t.jobTableRestoreSelected })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/42/restore")); expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/trash$/); }); async function compact() { fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity })); fireEvent.click(await screen.findByRole("option", { name: t.densityCompact })); } test("density preserves selected rows, query, page, custom status, columns and listing link without refetch", async () => { show("jobs", "?q=platform&page=2&sortBy=company&sortDir=asc"); const row = await screen.findByRole("row", { name: /open platform engineer/i }); const columns = within(row).getAllByRole("cell").length; fireEvent.click(within(row).getByRole("checkbox")); const before = calls().length; expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityComfortable); await compact(); expect(within(row).getByRole("checkbox")).toBeChecked(); expect(within(row).getAllByRole("cell")).toHaveLength(columns); expect(within(row).getByText("Custom review")).toBeVisible(); expect(within(row).getByRole("link", { name: t.jobTableLink })).toHaveAttribute("href", job.jobUrl); expect(screen.getByText("16–30 of 31")).toBeVisible(); expect(screen.getByLabelText("Current route")).toHaveTextContent("/jobs?q=platform&page=2&sortBy=company&sortDir=asc"); expect(calls()).toHaveLength(before); fireEvent.click(screen.getByRole("button", { name: "Offer" })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" })); }); test("density is local and resets on unmount without writing settings or a saved view", async () => { const view = show(); await screen.findByText(job.jobTitle); const stored = { ...localStorage }; await compact(); expect({ ...localStorage }).toEqual(stored); expect(mockedApi.put).not.toHaveBeenCalled(); view.unmount(); show(); await screen.findByText(job.jobTitle); expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityComfortable); }); test("density survives an empty result and a failed refresh with the existing retry action", async () => { show(); await screen.findByText(job.jobTitle); await compact(); mockedApi.get.mockImplementation((url: string) => url === "/jobapplications" ? Promise.reject({ message: "Synthetic unavailable", response: { status: 503 } }) : Promise.resolve({ data: [] } as any)); fireEvent.click(screen.getByRole("button", { name: t.tableNextPage })); expect(await screen.findByRole("alert")).toHaveTextContent("Synthetic unavailable"); expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityCompact); mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 15 } } as any); fireEvent.click(screen.getByRole("button", { name: /retry/i })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent(t.densityCompact); expect(screen.queryByText(job.jobTitle)).not.toBeInTheDocument(); }); test("Bokmål display localizes standard statuses without changing API values or custom status filters", async () => { localStorage.setItem("uiLanguage", "nb"); const standardJob = { ...job, status: "Interview", dateApplied: "2026-08-01", deadline: "2026-08-10" }; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [standardJob], total: 1, page: 1, pageSize: 15 } : [] } as any)); show("jobs", "?status=Custom%20review"); const row = await screen.findByRole("row", { name: /platform engineer/i }); expect(lastParams().status).toBe("Custom review"); expect(within(row).getByText(translations.nb.statusInterview)).toBeVisible(); expect(within(row).getByText(new Date(standardJob.dateApplied).toLocaleDateString("nb-NO"))).toBeVisible(); expect(within(row).getByText(new Date(standardJob.deadline).toLocaleDateString("nb-NO"))).toBeVisible(); expect(screen.getByRole("combobox", { name: translations.nb.jobTableStatus })).toHaveTextContent("Custom review"); fireEvent.click(within(row).getByRole("button", { name: `${translations.nb.jobTableQuickStatus}: ${job.jobTitle}` })); fireEvent.click(await screen.findByRole("menuitem", { name: translations.nb.jobTableSetStatus.replace("{status}", translations.nb.statusOffer) })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" })); }); test("partial bulk failure refreshes successful rows and retains only failed visible IDs", async () => { const other = { ...job, id: 43, jobTitle: "Second role" }; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job, other], total: 2, page: 1, pageSize: 15 } : [] } as any)); mockedApi.patch.mockImplementation((url: string) => url.includes("/43/") ? Promise.reject(new Error("Synthetic failure")) : Promise.resolve({ status: 204 } as any)); show(); const first = await screen.findByRole("row", { name: /open platform engineer/i }); const second = screen.getByRole("row", { name: /open second role/i }); fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll })); fireEvent.click(screen.getByRole("button", { name: "Offer" })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2)); await waitFor(() => expect(within(first).getByRole("checkbox")).not.toBeChecked()); expect(within(second).getByRole("checkbox")).toBeChecked(); expect(calls().length).toBeGreaterThan(1); }); test("saved view create/apply/delete stays scoped to the signed-in account", async () => { setAuthUserKey("account-a"); show("jobs", "?q=platform&status=Interview"); await screen.findByRole("row", { name: /platform engineer/i }); fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); fireEvent.change(screen.getByRole("textbox", { name: t.savedViewsName }), { target: { value: "Interview search" } }); fireEvent.click(screen.getByRole("button", { name: t.savedViewsSaveCurrent })); expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).toContain("Interview search"); fireEvent.click(screen.getByRole("menuitem", { name: /Interview search/ })); expect(screen.getByLabelText("Current route")).toHaveTextContent("status=Interview"); act(() => setAuthUserKey("account-b")); fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); expect(screen.getByText(t.savedViewsEmpty)).toBeVisible(); act(() => setAuthUserKey("account-a")); await waitFor(() => expect(screen.queryByText(t.savedViewsEmpty)).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); expect(screen.getByRole("menuitem", { name: /Interview search/ })).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: `${t.attachmentsDelete}: Interview search` })); expect(localStorage.getItem(getUserScopedStorageKey("jt_saved_views_v1"))).not.toContain("Interview search"); }); test("changing the result set clears selection before an old row can be bulk-updated", async () => { show(); const row = await screen.findByRole("row", { name: /platform engineer/i }); fireEvent.click(within(row).getByRole("checkbox")); expect(screen.getByText(t.jobTableSelected.replace("{count}", "1"))).toBeVisible(); fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "different" } }); expect(within(row).getByRole("checkbox")).not.toBeChecked(); expect(screen.queryByRole("button", { name: "Offer" })).not.toBeInTheDocument(); expect(mockedApi.patch).not.toHaveBeenCalled(); });