Jobber/orchestrator/src/client/pages/OrchestratorPage.test.tsx
Shaheer Sarfaraz 2fe0dc2c2f
Recalculate match in batch actions (#102)
* rescore in batch

* more tests!

* formatting!
2026-02-07 22:51:42 +00:00

537 lines
14 KiB
TypeScript

import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants";
vi.mock("../api", () => ({
updateSettings: vi.fn().mockResolvedValue({}),
runPipeline: vi.fn().mockResolvedValue({ message: "ok" }),
cancelPipeline: vi.fn().mockResolvedValue({
message: "Pipeline cancellation requested",
pipelineRunId: "run-1",
alreadyRequested: false,
}),
getPipelineStatus: vi.fn().mockResolvedValue({
isRunning: false,
lastRun: null,
nextScheduledRun: null,
}),
}));
let mockIsPipelineRunning = false;
const jobFixture: Job = {
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
};
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
const processingJob: Job = { ...jobFixture, id: "job-3", status: "processing" };
const createMatchMedia = (matches: boolean) =>
vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.mock("./orchestrator/useOrchestratorData", () => ({
useOrchestratorData: () => ({
jobs: [jobFixture, job2, processingJob],
stats: {
discovered: 1,
processing: 1,
ready: 1,
applied: 0,
skipped: 0,
expired: 0,
},
isLoading: false,
isPipelineRunning: mockIsPipelineRunning,
setIsPipelineRunning: vi.fn(),
loadJobs: vi.fn(),
}),
}));
vi.mock("./orchestrator/usePipelineSources", () => ({
usePipelineSources: () => ({
pipelineSources: ["linkedin"],
setPipelineSources: vi.fn(),
toggleSource: vi.fn(),
}),
}));
vi.mock("../hooks/useSettings", () => ({
useSettings: () => ({
settings: {
jobspySites: ["indeed", "linkedin"],
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
},
refreshSettings: vi.fn(),
}),
}));
vi.mock("./orchestrator/OrchestratorHeader", () => ({
OrchestratorHeader: ({
onCancelPipeline,
}: {
onCancelPipeline: () => void;
}) => (
<div data-testid="header">
<button type="button" onClick={onCancelPipeline}>
Cancel Pipeline
</button>
</div>
),
}));
vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () => <div data-testid="summary" />,
}));
vi.mock("./orchestrator/OrchestratorFilters", () => ({
OrchestratorFilters: ({
onTabChange,
onSearchQueryChange,
onSortChange,
sourcesWithJobs,
}: {
onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void;
onSortChange: (s: any) => void;
sourcesWithJobs: string[];
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
<button type="button" onClick={() => onSearchQueryChange("test search")}>
Set Search
</button>
<button
type="button"
onClick={() => onSortChange({ key: "title", direction: "asc" })}
>
Set Sort
</button>
</div>
),
}));
vi.mock("./orchestrator/JobDetailPanel", () => ({
JobDetailPanel: () => <div data-testid="detail-panel" />,
}));
vi.mock("./orchestrator/JobListPanel", () => ({
JobListPanel: ({
onSelectJob,
onToggleSelectJob,
onToggleSelectAll,
selectedJobId,
}: {
onSelectJob: (id: string) => void;
onToggleSelectJob: (id: string) => void;
onToggleSelectAll: (checked: boolean) => void;
selectedJobId: string | null;
}) => (
<div>
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
<button
data-testid="toggle-select-all-on"
type="button"
onClick={() => onToggleSelectAll(true)}
>
Toggle all on
</button>
<button
data-testid="toggle-select-all-off"
type="button"
onClick={() => onToggleSelectAll(false)}
>
Toggle all off
</button>
<button
data-testid="toggle-select-job-1"
type="button"
onClick={() => onToggleSelectJob("job-1")}
>
Toggle job 1
</button>
<button
data-testid="toggle-select-job-3"
type="button"
onClick={() => onToggleSelectJob("job-3")}
>
Toggle job 3
</button>
<button
data-testid="select-job-1"
type="button"
onClick={() => onSelectJob("job-1")}
>
Select job 1
</button>
<button
data-testid="select-job-2"
type="button"
onClick={() => onSelectJob("job-2")}
>
Select job 2
</button>
<button
data-testid="select-job-3"
type="button"
onClick={() => onSelectJob("job-3")}
>
Select job 3
</button>
</div>
),
}));
vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({
onSaveAndRunAutomatic,
}: {
onSaveAndRunAutomatic: (values: {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
}) => Promise<void>;
}) => (
<button
type="button"
data-testid="run-automatic"
onClick={() =>
void onSaveAndRunAutomatic({
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
})
}
>
Run automatic
</button>
),
}));
vi.mock("../components", () => ({
ManualImportSheet: () => <div data-testid="manual-import" />,
}));
const LocationWatcher = () => {
const location = useLocation();
return (
<div data-testid="location">{location.pathname + location.search}</div>
);
};
describe("OrchestratorPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPipelineRunning = false;
});
it("syncs tab selection to the URL", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("To Discovered"));
expect(screen.getByTestId("location").textContent).toContain("/discovered");
});
it("requests pipeline cancellation when running", async () => {
mockIsPipelineRunning = true;
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Cancel Pipeline"));
await waitFor(() => {
expect(api.cancelPipeline).toHaveBeenCalledTimes(1);
});
});
it("syncs job selection to the URL", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
// Initial load will auto-select the first matching job (job-1 for all tab)
const locationText = () => screen.getByTestId("location").textContent;
expect(locationText()).toContain("/all/job-1");
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");
});
});
it("syncs search query to URL as a parameter", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Set Search"));
expect(screen.getByTestId("location").textContent).toContain(
"q=test+search",
);
});
it("syncs sorting to URL and removes it when default", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Set Sort"));
expect(screen.getByTestId("location").textContent).toContain(
"sort=title-asc",
);
});
it("opens the detail drawer on mobile when a job is selected", () => {
window.matchMedia = createMatchMedia(
false,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("select-job-1"));
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("renders the detail panel inline on desktop", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("clears source filter when no jobs exist for it", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByTestId("location").textContent).not.toContain(
"source=ukvisajobs",
);
});
});
it("saves automatic settings from modal", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
const setIntervalSpy = vi
.spyOn(globalThis, "setInterval")
.mockReturnValue(0 as unknown as NodeJS.Timeout);
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith({
searchTerms: ["backend"],
jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150,
});
});
setIntervalSpy.mockRestore();
});
it("shows and hides bulk Recalculate match based on selected statuses", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("toggle-select-all-on"));
await waitFor(() => {
expect(
screen.queryByRole("button", { name: "Recalculate match" }),
).not.toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("toggle-select-all-off"));
fireEvent.click(screen.getByTestId("toggle-select-job-1"));
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Recalculate match" }),
).toBeInTheDocument();
});
});
});