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)
+ })
+})