settings page component tests
This commit is contained in:
parent
94c3cc64ae
commit
a4f52b923a
164
orchestrator/src/client/pages/SettingsPage.test.tsx
Normal file
164
orchestrator/src/client/pages/SettingsPage.test.tsx
Normal file
@ -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(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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<JobStatus[]>(initialStatuses)
|
||||
|
||||
const toggleStatusToClear = (status: JobStatus) => {
|
||||
setStatusesToClear((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["danger-zone"]}>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
handleClearByStatuses={onClear ?? (() => {})}
|
||||
handleClearDatabase={() => {}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("DangerZoneSection", () => {
|
||||
it("disables clear when no statuses are selected", () => {
|
||||
render(<DangerZoneHarness initialStatuses={[]} />)
|
||||
|
||||
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(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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<string[] | null>(null)
|
||||
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
|
||||
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
|
||||
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
|
||||
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
|
||||
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={["indeed", "linkedin"]}
|
||||
effectiveJobspySites={["indeed", "linkedin"]}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation="UK"
|
||||
effectiveJobspyLocation="UK"
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={200}
|
||||
effectiveJobspyResultsWanted={200}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={72}
|
||||
effectiveJobspyHoursOld={72}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed="UK"
|
||||
effectiveJobspyCountryIndeed="UK"
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={true}
|
||||
effectiveJobspyLinkedinFetchDescription={true}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("JobspySection", () => {
|
||||
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
||||
render(<JobspyHarness />)
|
||||
|
||||
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(<JobspyHarness />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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<ResumeProjectsSettings | null>(initialDraft)
|
||||
const lockedCount = draft?.lockedProjectIds.length ?? 0
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["resume-projects"]}>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={draft}
|
||||
setResumeProjectsDraft={setDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={profileProjects.length}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("ResumeProjectsSection", () => {
|
||||
it("clamps max projects to the locked count", async () => {
|
||||
render(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: ["proj-1"],
|
||||
aiSelectableProjectIds: ["proj-2"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 0,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: ["proj-1"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user