241 lines
9.0 KiB
TypeScript
241 lines
9.0 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/AiWorkspacePanel", () => () => <div>AI panel</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: () => <div>Application drafts section</div>,
|
|
}));
|
|
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview 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(() => {
|
|
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",
|
|
}),
|
|
}));
|
|
});
|