284 lines
9.1 KiB
TypeScript
284 lines
9.1 KiB
TypeScript
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: "google/gemini-3-flash-preview",
|
|
defaultModel: "google/gemini-3-flash-preview",
|
|
overrideModel: null,
|
|
modelScorer: "google/gemini-3-flash-preview",
|
|
overrideModelScorer: null,
|
|
modelTailoring: "google/gemini-3-flash-preview",
|
|
overrideModelTailoring: null,
|
|
modelProjectSelection: "google/gemini-3-flash-preview",
|
|
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,
|
|
showSponsorInfo: true,
|
|
defaultShowSponsorInfo: true,
|
|
overrideShowSponsorInfo: null,
|
|
openrouterApiKeyHint: null,
|
|
rxresumeEmail: "",
|
|
rxresumePasswordHint: null,
|
|
basicAuthUser: "",
|
|
basicAuthPasswordHint: null,
|
|
ukvisajobsEmail: "",
|
|
ukvisajobsPasswordHint: null,
|
|
webhookSecretHint: null,
|
|
basicAuthActive: false,
|
|
}
|
|
|
|
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("shows validation error for too long model override", async () => {
|
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
|
|
|
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")
|
|
|
|
// Change to > 200 chars
|
|
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
|
|
|
|
// Should see error message
|
|
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
|
|
|
|
// Save button should be disabled due to validation error (isValid will be false)
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
expect(saveButton).toBeDisabled()
|
|
})
|
|
|
|
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",
|
|
})
|
|
)
|
|
})
|
|
|
|
it("enables save button when model is changed", async () => {
|
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
|
renderPage()
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
expect(saveButton).toBeDisabled()
|
|
|
|
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
|
fireEvent.click(modelTrigger)
|
|
const modelInput = screen.getByLabelText(/override model/i)
|
|
fireEvent.change(modelInput, { target: { value: "new-model" } })
|
|
expect(saveButton).toBeEnabled()
|
|
})
|
|
|
|
it("enables save button when numeric setting is changed", async () => {
|
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
|
renderPage()
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
|
|
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
|
|
fireEvent.click(visaTrigger)
|
|
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
|
|
fireEvent.change(maxJobsInput, { target: { value: "100" } })
|
|
expect(saveButton).toBeEnabled()
|
|
})
|
|
|
|
it("enables save button when display setting is changed", async () => {
|
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
|
renderPage()
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
|
|
const displayTrigger = await screen.findByRole("button", { name: /display settings/i })
|
|
fireEvent.click(displayTrigger)
|
|
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i)
|
|
fireEvent.click(sponsorCheckbox)
|
|
expect(saveButton).toBeEnabled()
|
|
})
|
|
|
|
it("enables save button when basic auth toggle is changed", async () => {
|
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
|
renderPage()
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
|
|
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
|
fireEvent.click(envTrigger)
|
|
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
|
fireEvent.click(authCheckbox)
|
|
expect(saveButton).toBeEnabled()
|
|
})
|
|
|
|
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
|
|
// Initial state: Basic Auth is active
|
|
const activeSettings = {
|
|
...baseSettings,
|
|
basicAuthActive: true,
|
|
basicAuthUser: "admin",
|
|
basicAuthPasswordHint: "pass",
|
|
}
|
|
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
|
|
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
|
|
|
|
renderPage()
|
|
|
|
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
|
fireEvent.click(envTrigger)
|
|
|
|
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
|
expect(authCheckbox).toBeChecked()
|
|
|
|
// Disable it
|
|
fireEvent.click(authCheckbox)
|
|
expect(authCheckbox).not.toBeChecked()
|
|
|
|
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
|
expect(saveButton).toBeEnabled()
|
|
fireEvent.click(saveButton)
|
|
|
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
|
expect(api.updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
basicAuthUser: null,
|
|
basicAuthPassword: null,
|
|
})
|
|
)
|
|
})
|
|
})
|