195 lines
13 KiB
TypeScript
195 lines
13 KiB
TypeScript
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 ? <div role="dialog" aria-label="Edit job"><button onClick={onClose}>Close editor</button></div> : null);
|
||
|
||
const mockedApi = api as jest.Mocked<typeof api>;
|
||
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<JobTableColumns>({ status: true, dateApplied: true, daysSince: true, jobUrl: true });
|
||
return <><output aria-label="Current route">{location.pathname}{location.search}</output><JobTable mode={mode} refreshToken={0} pageSize={pageSize} onPageSizeChange={setPageSize} columns={columns} onColumnsChange={setColumns} /></>;
|
||
}
|
||
function show(mode: "jobs" | "trash" = "jobs", query = "") {
|
||
return render(<MemoryRouter initialEntries={[`/${mode}${query}`]}><I18nProvider><ToastProvider><ConfirmProvider><PromptProvider><Consumer mode={mode} /></PromptProvider></ConfirmProvider></ToastProvider></I18nProvider></MemoryRouter>);
|
||
}
|
||
const calls = () => mockedApi.get.mock.calls.filter(([url]) => url === "/jobapplications");
|
||
const lastParams = () => calls().at(-1)?.[1]?.params as Record<string, unknown>;
|
||
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();
|
||
});
|