diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx new file mode 100644 index 0000000..1e304df --- /dev/null +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { OrchestratorPage } from "./OrchestratorPage"; +import type { Job } from "../../shared/types"; + +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", + suitabilityScore: 90, + suitabilityReason: null, + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: 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 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], + stats: { + discovered: 0, + 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("./orchestrator/OrchestratorHeader", () => ({ + OrchestratorHeader: () =>
, +})); + +vi.mock("./orchestrator/OrchestratorSummary", () => ({ + OrchestratorSummary: () =>
, +})); + +vi.mock("./orchestrator/OrchestratorFilters", () => ({ + OrchestratorFilters: () =>
, +})); + +vi.mock("./orchestrator/JobDetailPanel", () => ({ + JobDetailPanel: () =>
, +})); + +vi.mock("./orchestrator/JobListPanel", () => ({ + JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => ( +
+
{selectedJobId ?? "none"}
+ +
+ ), +})); + +vi.mock("../components", () => ({ + ManualImportSheet: () =>
, +})); + +describe("OrchestratorPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("opens the detail drawer on mobile when a job is selected", () => { + window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia; + + render( + + + + ); + + expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /select job/i })); + + 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( + + + + ); + + expect(screen.getByTestId("detail-panel")).toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx new file mode 100644 index 0000000..9670b20 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -0,0 +1,274 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { JobDetailPanel } from "./JobDetailPanel"; +import type { Job } from "../../../shared/types"; +import * as api from "../../api"; + +vi.mock("@/components/ui/dropdown-menu", () => { + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +vi.mock("../../components", () => ({ + DiscoveredPanel: ({ job }: { job: Job | null }) => ( +
{job?.id ?? "no-job"}
+ ), +})); + +vi.mock("../../components/ReadyPanel", () => ({ + ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => ( +
+
+ +
+ ), +})); + +vi.mock("../../components/TailoringEditor", () => ({ + TailoringEditor: () =>
, +})); + +vi.mock("@client/lib/jobCopy", () => ({ + copyTextToClipboard: vi.fn().mockResolvedValue(undefined), + formatJobForWebhook: vi.fn(() => "payload"), +})); + +vi.mock("../../api", () => ({ + updateJob: vi.fn(), + processJob: vi.fn(), + generateJobPdf: vi.fn(), + markAsApplied: vi.fn(), + skipJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): 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: "https://example.com/apply", + disciplines: null, + deadline: "2025-02-01", + salary: "GBP 50k", + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Build APIs", + status: "ready", + suitabilityScore: 82, + suitabilityReason: "Strong fit", + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: 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", + ...overrides, +}); + +describe("JobDetailPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the discovered panel when active tab is discovered", () => { + const job = createJob({ id: "job-99", status: "discovered" }); + + render( + + ); + + expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); + }); + + it("wires ready panel edit actions back to the page", () => { + const onSetActiveTab = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /edit description/i })); + expect(onSetActiveTab).toHaveBeenCalledWith("discovered"); + }); + + it("shows an empty state when no job is selected", () => { + render( + + ); + + expect(screen.getByText("No job selected")).toBeInTheDocument(); + }); + + it("renders a stripped description preview for html content", () => { + render( + Hello world

" })} + onSelectJobId={vi.fn()} + onJobUpdated={vi.fn().mockResolvedValue(undefined)} + onSetActiveTab={vi.fn()} + /> + ); + + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("saves an edited description", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + vi.mocked(api.updateJob).mockResolvedValue(undefined as any); + + render( + + ); + + fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); + fireEvent.click(await screen.findByRole("button", { name: /^edit$/i })); + + fireEvent.change(screen.getByPlaceholderText("Enter job description..."), { + target: { value: "Updated description" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => + expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" }) + ); + expect(onJobUpdated).toHaveBeenCalled(); + }); + + it("marks a job as applied from the action button", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /applied/i })); + + await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1")); + expect(onJobUpdated).toHaveBeenCalled(); + }); + + it("skips a job from the menu", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + vi.mocked(api.skipJob).mockResolvedValue(undefined as any); + + render( + + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i })); + const skipItem = await screen.findByRole("menuitem", { name: /skip job/i }); + fireEvent.click(skipItem); + + await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1")); + expect(onJobUpdated).toHaveBeenCalled(); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx new file mode 100644 index 0000000..76a0ac2 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { JobListPanel } from "./JobListPanel"; +import type { Job } from "../../../shared/types"; + +const createJob = (overrides: Partial = {}): 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", + suitabilityScore: 72, + suitabilityReason: null, + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: 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", + ...overrides, +}); + +describe("JobListPanel", () => { + it("shows a loading state when fetching jobs", () => { + render( + + ); + + expect(screen.getByText("Loading jobs...")).toBeInTheDocument(); + }); + + it("shows the tab empty state copy when no jobs exist", () => { + render( + + ); + + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument(); + }); + + it("shows the query-specific empty state when searching", () => { + render( + + ); + + expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument(); + }); + + it("renders jobs and notifies when a job is selected", () => { + const onSelectJob = vi.fn(); + const jobs = [ + createJob({ id: "job-1", title: "Backend Engineer" }), + createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }), + ]; + + render( + + ); + + expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i })); + expect(onSelectJob).toHaveBeenCalledWith("job-2"); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx new file mode 100644 index 0000000..92f8bef --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import type { ComponentProps } from "react"; + +import { OrchestratorFilters } from "./OrchestratorFilters"; +import type { FilterTab, JobSort } from "./constants"; + +vi.mock("@/components/ui/dropdown-menu", () => { + const React = require("react") as typeof import("react"); + const RadioGroupContext = React.createContext<((value: string) => void) | null>(null); + + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuRadioGroup: ({ + children, + onValueChange, + }: { + children: React.ReactNode; + onValueChange?: (value: string) => void; + }) => ( + +
{children}
+
+ ), + DropdownMenuRadioItem: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => { + const onValueChange = React.useContext(RadioGroupContext); + return ( + + ); + }, + }; +}); + +const renderFilters = (overrides?: Partial>) => { + const props = { + activeTab: "ready" as FilterTab, + onTabChange: vi.fn(), + counts: { + ready: 2, + discovered: 1, + applied: 3, + all: 6, + }, + searchQuery: "", + onSearchQueryChange: vi.fn(), + sourceFilter: "all" as const, + onSourceFilterChange: vi.fn(), + sort: { key: "score", direction: "desc" } as JobSort, + onSortChange: vi.fn(), + ...overrides, + }; + + return { + props, + ...render(), + }; +}; + +describe("OrchestratorFilters", () => { + it("notifies when tabs and search are updated", () => { + const { props } = renderFilters(); + + fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i })); + expect(props.onTabChange).toHaveBeenCalledWith("applied"); + + fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } }); + expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design"); + }); + + it("updates source and sort selections", async () => { + const { props } = renderFilters(); + + fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i })); + expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin"); + + fireEvent.pointerDown(screen.getByRole("button", { name: /score/i })); + fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i })); + expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" }); + }); +}); diff --git a/orchestrator/src/setupTests.ts b/orchestrator/src/setupTests.ts index 8f2609b..d6f6f9c 100644 --- a/orchestrator/src/setupTests.ts +++ b/orchestrator/src/setupTests.ts @@ -3,3 +3,13 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; + +if (typeof globalThis.ResizeObserver === "undefined") { + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + globalThis.ResizeObserver = ResizeObserver; +}