* initial implementation * onboarding doesn't pop until invalid values are present * link to job page * proactive inputs working slightly * onboarding gate reinstated * better proactive buttons * fully manual tracking for now. * edit and delete timeline events * status showing correctly * tests update * tests * Update orchestrator/src/server/services/applicationTracking.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/job/Timeline.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/JobPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add tests for application tracking routes and remove unused actionId from client API * remove unnecessary await from synchronous transitionStage calls and improve test isolation * relax externalUrl validation to allow non-URL metadata * add toast notifications for data loading and event logging in JobPage * comments * fix: resolve type error in sponsor-matching.test.ts * fix ci * tests fix for github * lint * github comments * build fix * dedupe * format * types fix * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting * title and group id are discrete fields * backfill * hide view button on page * show relevant dropdown options * confetti! * remove redundant * confirm delete is a custom element now * formatting * fix styling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
354 lines
9.3 KiB
TypeScript
354 lines
9.3 KiB
TypeScript
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 type { Job } from "../../shared/types";
|
|
import { OrchestratorPage } from "./OrchestratorPage";
|
|
import type { FilterTab } from "./orchestrator/constants";
|
|
|
|
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 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],
|
|
stats: {
|
|
discovered: 1,
|
|
processing: 0,
|
|
ready: 1,
|
|
applied: 0,
|
|
skipped: 0,
|
|
expired: 0,
|
|
},
|
|
isLoading: false,
|
|
isPipelineRunning: false,
|
|
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,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./orchestrator/OrchestratorHeader", () => ({
|
|
OrchestratorHeader: () => <div data-testid="header" />,
|
|
}));
|
|
|
|
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,
|
|
selectedJobId,
|
|
}: {
|
|
onSelectJob: (id: string) => void;
|
|
selectedJobId: string | null;
|
|
}) => (
|
|
<div>
|
|
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
|
<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>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
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();
|
|
});
|
|
|
|
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("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",
|
|
);
|
|
});
|
|
});
|
|
});
|