diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx new file mode 100644 index 0000000..1a32f7d --- /dev/null +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" + +import { SettingsPage } from "./SettingsPage" +import * as api from "../api" +import { toast } from "sonner" +import type { AppSettings } from "@shared/types" + +vi.mock("../api", () => ({ + getSettings: vi.fn(), + updateSettings: vi.fn(), + clearDatabase: vi.fn(), + deleteJobsByStatus: vi.fn(), +})) + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})) + +const baseSettings: AppSettings = { + model: "openai/gpt-4o-mini", + defaultModel: "openai/gpt-4o-mini", + overrideModel: null, + modelScorer: "openai/gpt-4o-mini", + overrideModelScorer: null, + modelTailoring: "openai/gpt-4o-mini", + overrideModelTailoring: null, + modelProjectSelection: "openai/gpt-4o-mini", + overrideModelProjectSelection: null, + pipelineWebhookUrl: "", + defaultPipelineWebhookUrl: "", + overridePipelineWebhookUrl: null, + jobCompleteWebhookUrl: "", + defaultJobCompleteWebhookUrl: "", + overrideJobCompleteWebhookUrl: null, + profileProjects: [ + { + id: "proj-1", + name: "Project One", + description: "Desc 1", + date: "2024", + isVisibleInBase: true, + }, + { + id: "proj-2", + name: "Project Two", + description: "Desc 2", + date: "2023", + isVisibleInBase: false, + }, + ], + resumeProjects: { + maxProjects: 2, + lockedProjectIds: [], + aiSelectableProjectIds: ["proj-1", "proj-2"], + }, + defaultResumeProjects: { + maxProjects: 2, + lockedProjectIds: [], + aiSelectableProjectIds: ["proj-1", "proj-2"], + }, + overrideResumeProjects: null, + ukvisajobsMaxJobs: 50, + defaultUkvisajobsMaxJobs: 50, + overrideUkvisajobsMaxJobs: null, + gradcrackerMaxJobsPerTerm: 50, + defaultGradcrackerMaxJobsPerTerm: 50, + overrideGradcrackerMaxJobsPerTerm: null, + searchTerms: ["engineer"], + defaultSearchTerms: ["engineer"], + overrideSearchTerms: null, + jobspyLocation: "UK", + defaultJobspyLocation: "UK", + overrideJobspyLocation: null, + jobspyResultsWanted: 200, + defaultJobspyResultsWanted: 200, + overrideJobspyResultsWanted: null, + jobspyHoursOld: 72, + defaultJobspyHoursOld: 72, + overrideJobspyHoursOld: null, + jobspyCountryIndeed: "UK", + defaultJobspyCountryIndeed: "UK", + overrideJobspyCountryIndeed: null, + jobspySites: ["indeed", "linkedin"], + defaultJobspySites: ["indeed", "linkedin"], + overrideJobspySites: null, + jobspyLinkedinFetchDescription: true, + defaultJobspyLinkedinFetchDescription: true, + overrideJobspyLinkedinFetchDescription: null, +} + +const renderPage = () => { + return render( + + + + ) +} + +describe("SettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("saves trimmed model overrides", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + vi.mocked(api.updateSettings).mockResolvedValue({ + ...baseSettings, + overrideModel: "gpt-4", + model: "gpt-4", + }) + + renderPage() + + const modelTrigger = await screen.findByRole("button", { name: /model/i }) + fireEvent.click(modelTrigger) + + const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main") + const modelInput = within(modelField).getByRole("textbox") + fireEvent.change(modelInput, { target: { value: " gpt-4 " } }) + + const saveButton = screen.getByRole("button", { name: /^save$/i }) + await waitFor(() => expect(saveButton).toBeEnabled()) + + fireEvent.click(saveButton) + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-4", + }) + ) + expect(toast.success).toHaveBeenCalledWith("Settings saved") + }) + + it("clears jobs by status and summarizes results", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 }) + + renderPage() + + const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i }) + fireEvent.click(dangerTrigger) + + const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i }) + fireEvent.click(clearSelectedButton) + + const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i }) + fireEvent.click(confirmButton) + + await waitFor(() => expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered")) + expect(toast.success).toHaveBeenCalledWith( + "Jobs cleared", + expect.objectContaining({ + description: "Deleted 2 jobs: 2 discovered", + }) + ) + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/DangerZoneSection.test.tsx b/orchestrator/src/client/pages/settings/components/DangerZoneSection.test.tsx new file mode 100644 index 0000000..c8c0a8a --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/DangerZoneSection.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { useState } from "react" + +import { Accordion } from "@/components/ui/accordion" +import { DangerZoneSection } from "./DangerZoneSection" +import type { JobStatus } from "@shared/types" + +const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => { + const [statusesToClear, setStatusesToClear] = useState(initialStatuses) + + const toggleStatusToClear = (status: JobStatus) => { + setStatusesToClear((prev) => + prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status] + ) + } + + return ( + + {})} + handleClearDatabase={() => {}} + isLoading={false} + isSaving={false} + /> + + ) +} + +describe("DangerZoneSection", () => { + it("disables clear when no statuses are selected", () => { + render() + + const clearButton = screen.getByRole("button", { name: /clear selected/i }) + expect(clearButton).toBeDisabled() + }) + + it("toggles status selection and confirms clear", async () => { + const onClear = vi.fn() + render() + + const appliedButton = screen.getByRole("button", { name: /applied/i }) + const clearButton = screen.getByRole("button", { name: /clear selected/i }) + + expect(clearButton).toBeEnabled() + + fireEvent.click(clearButton) + const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i }) + fireEvent.click(confirmButton) + + expect(onClear).toHaveBeenCalledTimes(1) + + fireEvent.click(appliedButton) + expect(clearButton).toBeDisabled() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx b/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx new file mode 100644 index 0000000..e31b1cc --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { useState } from "react" + +import { Accordion } from "@/components/ui/accordion" +import { JobspySection } from "./JobspySection" + +const JobspyHarness = () => { + const [jobspySitesDraft, setJobspySitesDraft] = useState(null) + const [jobspyLocationDraft, setJobspyLocationDraft] = useState(null) + const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState(null) + const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState(null) + const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState(null) + const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState(null) + + return ( + + + + ) +} + +describe("JobspySection", () => { + it("toggles scraped sites and keeps checkboxes in sync", () => { + render() + + const indeedCheckbox = screen.getByLabelText("Indeed") + const linkedinCheckbox = screen.getByLabelText("LinkedIn") + + expect(indeedCheckbox).toBeChecked() + expect(linkedinCheckbox).toBeChecked() + + fireEvent.click(indeedCheckbox) + expect(indeedCheckbox).not.toBeChecked() + expect(linkedinCheckbox).toBeChecked() + + fireEvent.click(indeedCheckbox) + expect(indeedCheckbox).toBeChecked() + }) + + it("clamps numeric inputs to allowed ranges", () => { + render() + + const numericInputs = screen.getAllByRole("spinbutton") + const resultsWantedInput = numericInputs[0] + const hoursOldInput = numericInputs[1] + + fireEvent.change(resultsWantedInput, { target: { value: "999" } }) + expect(resultsWantedInput).toHaveValue(500) + + fireEvent.change(hoursOldInput, { target: { value: "0" } }) + expect(hoursOldInput).toHaveValue(1) + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx new file mode 100644 index 0000000..1430b5a --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { useState } from "react" + +import { Accordion } from "@/components/ui/accordion" +import { ResumeProjectsSection } from "./ResumeProjectsSection" +import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" + +const profileProjects: ResumeProjectCatalogItem[] = [ + { + id: "proj-1", + name: "Project One", + description: "Desc 1", + date: "2024", + isVisibleInBase: true, + }, + { + id: "proj-2", + name: "Project Two", + description: "Desc 2", + date: "2023", + isVisibleInBase: false, + }, +] + +const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => { + const [draft, setDraft] = useState(initialDraft) + const lockedCount = draft?.lockedProjectIds.length ?? 0 + + return ( + + + + ) +} + +describe("ResumeProjectsSection", () => { + it("clamps max projects to the locked count", async () => { + render( + + ) + + const input = screen.getByRole("spinbutton") + fireEvent.change(input, { target: { value: "0" } }) + + await waitFor(() => expect(input).toHaveValue(1)) + }) + + it("locks projects and enforces maxProjects >= locked count", () => { + render( + + ) + + const checkboxes = screen.getAllByRole("checkbox") + const lockedCheckbox = checkboxes[0] + const aiSelectableCheckbox = checkboxes[1] + + fireEvent.click(lockedCheckbox) + + expect(lockedCheckbox).toBeChecked() + expect(aiSelectableCheckbox).toBeChecked() + expect(aiSelectableCheckbox).toBeDisabled() + + const input = screen.getByRole("spinbutton") + expect(input).toHaveValue(1) + }) +})