settings page component tests

This commit is contained in:
DaKheera47 2026-01-20 06:31:45 +00:00
parent 94c3cc64ae
commit a4f52b923a
4 changed files with 390 additions and 0 deletions

View 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",
})
)
})
})

View File

@ -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()
})
})

View File

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

View File

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