Small bits and bobs, codebase quality (#129)
* initial change * nav highlighting * icon change * deeeedoooop * text * show version number on all pages * icon * remove unused code * add knip * formatting * remove unused code * types fix * remove notion completely from the codebase. * update test for new url structure * clean up the fucking shop boys * make a "create job" factory and use that * moar factories * formatting
This commit is contained in:
parent
2962e0c2ae
commit
fe0aebe01a
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: design-principles
|
||||
description: Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.
|
||||
description: Enforce a precise, minimal design system inspired by Linear and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.
|
||||
---
|
||||
|
||||
# Design Principles
|
||||
@ -24,7 +24,7 @@ Enterprise/SaaS UI has more range than you think. Consider these directions:
|
||||
|
||||
**Precision & Density** — Tight spacing, monochrome, information-forward. For power users who live in the tool. Think Linear, Raycast, terminal aesthetics.
|
||||
|
||||
**Warmth & Approachability** — Generous spacing, soft shadows, friendly colors. For products that want to feel human. Think Notion, Coda, collaborative tools.
|
||||
**Warmth & Approachability** — Generous spacing, soft shadows, friendly colors. For products that want to feel human. Think Coda, collaborative tools.
|
||||
|
||||
**Sophistication & Trust** — Cool tones, layered depth, financial gravitas. For products handling money or sensitive data. Think Stripe, Mercury, enterprise B2B.
|
||||
|
||||
@ -234,4 +234,4 @@ Every interface should look designed by a team that obsesses over 1-pixel differ
|
||||
|
||||
Different products want different things. A developer tool wants precision and density. A collaborative product wants warmth and space. A financial product wants trust and sophistication. Let the product context guide the aesthetic.
|
||||
|
||||
The goal: intricate minimalism with appropriate personality. Same quality bar, context-driven execution.
|
||||
The goal: intricate minimalism with appropriate personality. Same quality bar, context-driven execution.
|
||||
|
||||
@ -7,7 +7,7 @@ This doc explains how the orchestrator thinks about job states, how the "Ready"
|
||||
- `discovered`: The job was found by a crawler/import. It has not been processed into a tailored resume yet.
|
||||
- `processing`: The system is currently generating tailoring data and/or the PDF.
|
||||
- `ready`: A tailored PDF has been generated and the job is ready for you to apply.
|
||||
- `applied`: You marked it as applied. If Notion is configured, a page is created and linked.
|
||||
- `applied`: You marked it as applied.
|
||||
- `skipped`: You explicitly skipped it (so it stays out of your active queue).
|
||||
- `expired`: Deadline has passed. This is a terminal state used for cleanup/triage.
|
||||
|
||||
@ -31,7 +31,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane":
|
||||
|
||||
- View/download the PDF.
|
||||
- Open the job listing.
|
||||
- Mark Applied (moves to `applied` and syncs to Notion if configured).
|
||||
- Mark Applied (moves to `applied`).
|
||||
- Optional: edit tailoring, edit the JD, or regenerate the PDF.
|
||||
|
||||
## Generating PDFs (first time)
|
||||
|
||||
7
knip.json
Normal file
7
knip.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"tags": ["-lintignore"],
|
||||
"workspaces": {
|
||||
".": {}
|
||||
}
|
||||
}
|
||||
@ -40,7 +40,7 @@ orchestrator/
|
||||
|
||||
OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported.
|
||||
|
||||
Deprecated: `OPENROUTER_API_KEY` / `openrouterApiKey`. Use `LLM_API_KEY` / `llmApiKey` instead (legacy values are auto-migrated/copied for compatibility).
|
||||
Use `LLM_API_KEY` / `llmApiKey` to configure providers that require an API key.
|
||||
|
||||
3. **Initialize database:**
|
||||
```bash
|
||||
@ -66,7 +66,7 @@ orchestrator/
|
||||
| GET | `/api/jobs/:id` | Get single job |
|
||||
| PATCH | `/api/jobs/:id` | Update job |
|
||||
| POST | `/api/jobs/:id/process` | Generate resume for job |
|
||||
| POST | `/api/jobs/:id/apply` | Mark as applied + sync to Notion |
|
||||
| POST | `/api/jobs/:id/apply` | Mark as applied |
|
||||
| POST | `/api/jobs/:id/skip` | Mark as skipped |
|
||||
|
||||
### Pipeline
|
||||
@ -92,7 +92,7 @@ orchestrator/
|
||||
- Use command bar search (`Cmd/Ctrl+K`) to quickly find and open jobs
|
||||
- Click "View Job" to open application
|
||||
- Download PDF and apply manually
|
||||
- Click "Mark Applied" → syncs to Notion
|
||||
- Click "Mark Applied" to mark application status
|
||||
|
||||
## n8n Setup
|
||||
|
||||
|
||||
@ -16,6 +16,20 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
|
||||
/** Backwards-compatibility redirects: old URL paths -> new URL paths */
|
||||
const REDIRECTS: Array<{ from: string; to: string }> = [
|
||||
{ from: "/", to: "/jobs/ready" },
|
||||
{ from: "/home", to: "/overview" },
|
||||
{ from: "/ready", to: "/jobs/ready" },
|
||||
{ from: "/ready/:jobId", to: "/jobs/ready/:jobId" },
|
||||
{ from: "/discovered", to: "/jobs/discovered" },
|
||||
{ from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" },
|
||||
{ from: "/applied", to: "/jobs/applied" },
|
||||
{ from: "/applied/:jobId", to: "/jobs/applied/:jobId" },
|
||||
{ from: "/all", to: "/jobs/all" },
|
||||
{ from: "/all/:jobId", to: "/jobs/all/:jobId" },
|
||||
];
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
@ -23,8 +37,8 @@ export const App: React.FC = () => {
|
||||
|
||||
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
|
||||
const pageKey = React.useMemo(() => {
|
||||
const firstSegment = location.pathname.split("/")[1] || "ready";
|
||||
if (["ready", "discovered", "applied", "all"].includes(firstSegment)) {
|
||||
const firstSegment = location.pathname.split("/")[1] || "jobs";
|
||||
if (firstSegment === "jobs") {
|
||||
return "orchestrator";
|
||||
}
|
||||
return firstSegment;
|
||||
@ -51,13 +65,25 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div ref={nodeRef}>
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/ready" replace />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
{/* Backwards-compatibility redirects */}
|
||||
{REDIRECTS.map(({ from, to }) => (
|
||||
<Route
|
||||
key={from}
|
||||
path={from}
|
||||
element={<Navigate to={to} replace />}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Application routes */}
|
||||
<Route path="/overview" element={<HomePage />} />
|
||||
<Route path="/job/:id" element={<JobPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route
|
||||
path="/jobs/:tab/:jobId"
|
||||
element={<OrchestratorPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
* API client for the orchestrator backend.
|
||||
*/
|
||||
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||
import type {
|
||||
ApiResponse,
|
||||
ApplicationStage,
|
||||
@ -24,7 +25,6 @@ import type {
|
||||
ProfileStatusResponse,
|
||||
ResumeProfile,
|
||||
ResumeProjectCatalogItem,
|
||||
ResumeProjectsSettings,
|
||||
StageEvent,
|
||||
StageEventMetadata,
|
||||
StageTransitionTarget,
|
||||
@ -646,37 +646,9 @@ export async function validateResumeConfig(): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>("/onboarding/validate/resume");
|
||||
}
|
||||
|
||||
export async function updateSettings(update: {
|
||||
model?: string | null;
|
||||
modelScorer?: string | null;
|
||||
modelTailoring?: string | null;
|
||||
modelProjectSelection?: string | null;
|
||||
llmProvider?: string | null;
|
||||
llmBaseUrl?: string | null;
|
||||
llmApiKey?: string | null;
|
||||
pipelineWebhookUrl?: string | null;
|
||||
jobCompleteWebhookUrl?: string | null;
|
||||
resumeProjects?: ResumeProjectsSettings | null;
|
||||
ukvisajobsMaxJobs?: number | null;
|
||||
gradcrackerMaxJobsPerTerm?: number | null;
|
||||
searchTerms?: string[] | null;
|
||||
jobspyLocation?: string | null;
|
||||
jobspyResultsWanted?: number | null;
|
||||
jobspyHoursOld?: number | null;
|
||||
jobspyCountryIndeed?: string | null;
|
||||
jobspySites?: string[] | null;
|
||||
jobspyLinkedinFetchDescription?: boolean | null;
|
||||
showSponsorInfo?: boolean | null;
|
||||
openrouterApiKey?: string | null;
|
||||
rxresumeEmail?: string | null;
|
||||
rxresumePassword?: string | null;
|
||||
basicAuthUser?: string | null;
|
||||
basicAuthPassword?: string | null;
|
||||
ukvisajobsEmail?: string | null;
|
||||
ukvisajobsPassword?: string | null;
|
||||
webhookSecret?: string | null;
|
||||
rxresumeBaseResumeId?: string | null;
|
||||
}): Promise<AppSettings> {
|
||||
export async function updateSettings(
|
||||
update: Partial<UpdateSettingsInput>,
|
||||
): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>("/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(update),
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Header component with logo and pipeline trigger.
|
||||
*/
|
||||
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { ChevronDown, Loader2, Menu, Play, RefreshCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn, sourceLabel } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
onRunPipeline: () => void;
|
||||
onRefresh: () => void;
|
||||
isPipelineRunning: boolean;
|
||||
isLoading: boolean;
|
||||
pipelineSources: JobSource[];
|
||||
onPipelineSourcesChange: (sources: JobSource[]) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
onRunPipeline,
|
||||
onRefresh,
|
||||
isPipelineRunning,
|
||||
isLoading,
|
||||
pipelineSources,
|
||||
onPipelineSourcesChange,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
|
||||
const orderedSources: JobSource[] = [
|
||||
"gradcracker",
|
||||
"indeed",
|
||||
"linkedin",
|
||||
"ukvisajobs",
|
||||
];
|
||||
|
||||
const toggleSource = (source: JobSource, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...pipelineSources, source]))
|
||||
: pipelineSources.filter((s) => s !== source);
|
||||
|
||||
if (next.length === 0) return;
|
||||
onPipelineSourcesChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm">
|
||||
<img
|
||||
src="/favicon.png"
|
||||
alt="Job Ops Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
Job Ops
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onRunPipeline}
|
||||
disabled={isPipelineRunning}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{isPipelineRunning ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Run Pipeline
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isPipelineRunning}
|
||||
className="rounded-l-none border-l border-primary-foreground/20"
|
||||
aria-label="Select pipeline sources"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{orderedSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSource(source, Boolean(checked))
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onPipelineSourcesChange(orderedSources);
|
||||
}}
|
||||
>
|
||||
All sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onPipelineSourcesChange(["gradcracker"]);
|
||||
}}
|
||||
>
|
||||
Gradcracker only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onPipelineSourcesChange(["indeed", "linkedin"]);
|
||||
}}
|
||||
>
|
||||
Indeed + LinkedIn only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
@ -35,20 +36,6 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
id: "job-1",
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
location: "London",
|
||||
salary: null,
|
||||
deadline: null,
|
||||
jobDescription: "Build APIs",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
|
||||
describe("JobDetailsEditDrawer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
@ -30,7 +30,7 @@ vi.mock("@/components/ui/tooltip", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const mockJob: Job = {
|
||||
const mockJob = createJob({
|
||||
id: "job-1",
|
||||
title: "Software Engineer",
|
||||
employer: "Tech Corp",
|
||||
@ -38,15 +38,10 @@ const mockJob: Job = {
|
||||
salary: "£60,000",
|
||||
deadline: "2025-12-31",
|
||||
status: "discovered",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
source: "linkedin",
|
||||
suitabilityScore: 85,
|
||||
suitabilityReason: "Strong match",
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
// Other fields...
|
||||
} as Job;
|
||||
});
|
||||
|
||||
describe("JobHeader", () => {
|
||||
const renderWithRouter = (ui: React.ReactElement) =>
|
||||
|
||||
@ -90,7 +90,6 @@ const settingsResponse = {
|
||||
settings: {
|
||||
llmProvider: "openrouter",
|
||||
llmApiKeyHint: null,
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
LLM_PROVIDERS,
|
||||
normalizeLlmProvider,
|
||||
} from "@client/pages/settings/utils";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { ValidationResult } from "@shared/types.js";
|
||||
import { Check } from "lucide-react";
|
||||
import type React from "react";
|
||||
@ -182,8 +183,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
requiresApiKey: requiresLlmKey,
|
||||
} = providerConfig;
|
||||
|
||||
const llmKeyHint =
|
||||
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null;
|
||||
const llmKeyHint = settings?.llmApiKeyHint ?? null;
|
||||
const hasLlmKey = Boolean(llmKeyHint);
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||
@ -351,11 +351,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const update: {
|
||||
llmProvider?: string;
|
||||
llmBaseUrl?: string | null;
|
||||
llmApiKey?: string;
|
||||
} = {
|
||||
const update: Partial<UpdateSettingsInput> = {
|
||||
llmProvider: normalizedProvider,
|
||||
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
@ -90,69 +91,6 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: "https://example.com/apply",
|
||||
disciplines: null,
|
||||
deadline: "2025-02-01",
|
||||
salary: "GBP 50k",
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
suitabilityScore: 82,
|
||||
suitabilityReason: "Strong fit",
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ReadyPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@ -19,7 +20,7 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
createBaseJob({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
tailoredHeadline: "Saved headline",
|
||||
@ -29,7 +30,7 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
});
|
||||
|
||||
const ensureAccordionOpen = (name: string) => {
|
||||
const trigger = screen.getByRole("button", { name });
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
* Tests real-world edge cases for conversion funnel and analytics
|
||||
*/
|
||||
|
||||
import type { ApplicationStage, StageEvent } from "@shared/types.js";
|
||||
import {
|
||||
createJob as createBaseJob,
|
||||
createStageEvent,
|
||||
} from "@shared/testing/factories.js";
|
||||
import type { ApplicationStage, Job, StageEvent } from "@shared/types.js";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@ -76,28 +80,27 @@ describe("ConversionAnalytics - Edge Cases", () => {
|
||||
id: string,
|
||||
appliedAt: string | null,
|
||||
events: StageEvent[] = [],
|
||||
) => ({
|
||||
id,
|
||||
datePosted: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
appliedAt,
|
||||
events,
|
||||
});
|
||||
) =>
|
||||
createBaseJob({
|
||||
id,
|
||||
datePosted: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
appliedAt,
|
||||
...({ events } as any),
|
||||
}) as Job & { events: StageEvent[] };
|
||||
|
||||
const createEvent = (
|
||||
toStage: ApplicationStage,
|
||||
occurredAt: number,
|
||||
): StageEvent => ({
|
||||
id: `event-${toStage}`,
|
||||
applicationId: "job-1",
|
||||
title: `Moved to ${toStage}`,
|
||||
groupId: null,
|
||||
fromStage: "applied",
|
||||
toStage,
|
||||
occurredAt,
|
||||
metadata: null,
|
||||
outcome: null,
|
||||
});
|
||||
): StageEvent =>
|
||||
createStageEvent({
|
||||
id: `event-${toStage}`,
|
||||
applicationId: "job-1",
|
||||
title: `Moved to ${toStage}`,
|
||||
fromStage: "applied",
|
||||
toStage,
|
||||
occurredAt,
|
||||
});
|
||||
|
||||
describe("Empty and Null Data", () => {
|
||||
it("handles empty jobsWithEvents array - shows 0% conversion", () => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
@ -83,69 +84,6 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-2",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: "https://example.com/apply",
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "discovered",
|
||||
suitabilityScore: 55,
|
||||
suitabilityReason: "Ok fit",
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("DiscoveredPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -153,7 +91,7 @@ describe("DiscoveredPanel", () => {
|
||||
|
||||
it("re-runs the fit assessment from the menu", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
const job = createJob();
|
||||
const job = createJob({ id: "job-2" });
|
||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||
|
||||
render(
|
||||
@ -177,7 +115,7 @@ describe("DiscoveredPanel", () => {
|
||||
|
||||
it("opens edit details drawer from more actions", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
const job = createJob();
|
||||
const job = createJob({ id: "job-2" });
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@ -18,7 +19,7 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
createBaseJob({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
tailoredHeadline: "Saved headline",
|
||||
@ -28,7 +29,7 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
});
|
||||
|
||||
const ensureAccordionOpen = (name: string) => {
|
||||
const trigger = screen.getByRole("button", { name });
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { DiscoveredPanel } from "./DiscoveredPanel";
|
||||
@ -1,6 +1,5 @@
|
||||
export { DiscoveredPanel } from "./discovered-panel";
|
||||
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
|
||||
export { FitAssessment } from "./FitAssessment";
|
||||
export { Header } from "./Header";
|
||||
export { JobHeader } from "./JobHeader";
|
||||
export * from "./layout";
|
||||
export { ManualImportSheet } from "./ManualImportSheet";
|
||||
|
||||
@ -31,12 +31,15 @@ import { isNavActive, NAV_LINKS } from "./navigation";
|
||||
// ============================================================================
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
icon: LucideIcon | React.FC<{ className?: string }>;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge?: string;
|
||||
statusIndicator?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
showVersionFooter?: boolean;
|
||||
navOpen?: boolean;
|
||||
onNavOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
@ -46,10 +49,15 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
badge,
|
||||
statusIndicator,
|
||||
actions,
|
||||
showVersionFooter = true,
|
||||
navOpen: controlledNavOpen,
|
||||
onNavOpenChange,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [internalNavOpen, setInternalNavOpen] = useState(false);
|
||||
const navOpen = controlledNavOpen ?? internalNavOpen;
|
||||
const setNavOpen = onNavOpenChange ?? setInternalNavOpen;
|
||||
const { version, updateAvailable } = useVersionCheck();
|
||||
|
||||
const handleNavClick = (to: string, activePaths?: string[]) => {
|
||||
@ -94,28 +102,30 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto pt-6 pb-2">
|
||||
<TooltipProvider>
|
||||
<a
|
||||
href="https://github.com/DaKheera47/job-ops/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Version {version}</span>
|
||||
{updateAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Update available</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</a>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{showVersionFooter && (
|
||||
<div className="mt-auto pt-6 pb-2">
|
||||
<TooltipProvider>
|
||||
<a
|
||||
href="https://github.com/DaKheera47/job-ops/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Version {version}</span>
|
||||
{updateAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Update available</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</a>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@ -8,12 +8,17 @@ export type NavLink = {
|
||||
};
|
||||
|
||||
export const NAV_LINKS: NavLink[] = [
|
||||
{ to: "/home", label: "Home", icon: Home },
|
||||
{ to: "/overview", label: "Overview", icon: Home },
|
||||
{
|
||||
to: "/ready",
|
||||
label: "Dashboard",
|
||||
to: "/jobs/ready",
|
||||
label: "Jobs",
|
||||
icon: LayoutDashboard,
|
||||
activePaths: ["/ready", "/discovered", "/applied", "/all"],
|
||||
activePaths: [
|
||||
"/jobs/ready",
|
||||
"/jobs/discovered",
|
||||
"/jobs/applied",
|
||||
"/jobs/all",
|
||||
],
|
||||
},
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
@ -23,4 +28,10 @@ export const isNavActive = (
|
||||
pathname: string,
|
||||
to: string,
|
||||
activePaths?: string[],
|
||||
) => pathname === to || (activePaths ? activePaths.includes(pathname) : false);
|
||||
) => {
|
||||
if (pathname === to) return true;
|
||||
if (!activePaths) return false;
|
||||
return activePaths.some(
|
||||
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,22 +5,12 @@ import {
|
||||
DurationSelector,
|
||||
type DurationValue,
|
||||
} from "@client/components/charts";
|
||||
import { PageMain } from "@client/components/layout";
|
||||
import { PageHeader, PageMain } from "@client/components/layout";
|
||||
import type { StageEvent } from "@shared/types.js";
|
||||
import { Home, Menu } from "lucide-react";
|
||||
import { ChartColumn } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isNavActive, NAV_LINKS } from "../components/navigation";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
type JobWithEvents = {
|
||||
id: string;
|
||||
@ -34,10 +24,7 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const;
|
||||
const DEFAULT_DURATION = 30;
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
|
||||
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -139,74 +126,16 @@ export const HomePage: React.FC = () => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const handleNavClick = (to: string, activePaths?: string[]) => {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
setNavOpen(false);
|
||||
return;
|
||||
}
|
||||
setNavOpen(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Custom Header with Duration Selector */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{NAV_LINKS.map(
|
||||
({ to, label, icon: NavIcon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => handleNavClick(to, activePaths)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<NavIcon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Home</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Applications over the last {duration} days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DurationSelector
|
||||
value={duration}
|
||||
onChange={handleDurationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
icon={ChartColumn}
|
||||
title="Overview"
|
||||
subtitle="Analytics & Insights"
|
||||
actions={
|
||||
<DurationSelector value={duration} onChange={handleDurationChange} />
|
||||
}
|
||||
/>
|
||||
|
||||
<PageMain>
|
||||
<ApplicationsPerDayChart
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -47,70 +47,35 @@ let mockAutomaticRunValues = {
|
||||
country: "united kingdom",
|
||||
};
|
||||
|
||||
const jobFixture: Job = {
|
||||
const jobFixture = createJob({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
};
|
||||
});
|
||||
|
||||
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
|
||||
const processingJob: Job = { ...jobFixture, id: "job-3", status: "processing" };
|
||||
const job2 = createJob({
|
||||
id: "job-2",
|
||||
source: "linkedin",
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
location: "London",
|
||||
jobDescription: "Build APIs",
|
||||
status: "discovered",
|
||||
});
|
||||
|
||||
const processingJob = createJob({
|
||||
id: "job-3",
|
||||
source: "linkedin",
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
location: "London",
|
||||
jobDescription: "Build APIs",
|
||||
status: "processing",
|
||||
});
|
||||
|
||||
const createMatchMedia = (matches: boolean) =>
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
@ -155,7 +120,6 @@ vi.mock("./orchestrator/usePipelineSources", () => ({
|
||||
vi.mock("../hooks/useSettings", () => ({
|
||||
useSettings: () => ({
|
||||
settings: {
|
||||
jobspySites: ["indeed", "linkedin"],
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPasswordHint: null,
|
||||
},
|
||||
@ -397,11 +361,11 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -417,10 +381,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -438,11 +402,11 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/all"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/all"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -467,10 +431,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -497,13 +461,13 @@ describe("OrchestratorPage", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
|
||||
"/jobs/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
|
||||
]}
|
||||
>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -536,11 +500,11 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready?q=backend&sort=title-asc"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready?q=backend&sort=title-asc"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -558,11 +522,11 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -579,11 +543,11 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -630,10 +594,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -651,10 +615,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -668,10 +632,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready?source=ukvisajobs"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -692,10 +656,10 @@ describe("OrchestratorPage", () => {
|
||||
.mockReturnValue(0 as unknown as NodeJS.Timeout);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -733,10 +697,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -757,10 +721,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -781,10 +745,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -808,10 +772,10 @@ describe("OrchestratorPage", () => {
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@ -830,10 +794,10 @@ describe("OrchestratorPage", () => {
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/all"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/all"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Orchestrator layout with a split list/detail experience.
|
||||
*/
|
||||
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
@ -32,15 +28,13 @@ import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
||||
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
||||
import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters";
|
||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
|
||||
import {
|
||||
getEnabledSources,
|
||||
getJobCounts,
|
||||
getSourcesWithJobs,
|
||||
} from "./orchestrator/utils";
|
||||
|
||||
const escapeCssAttributeValue = (value: string) =>
|
||||
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
|
||||
export const OrchestratorPage: React.FC = () => {
|
||||
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@ -71,8 +65,8 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const search = searchParams.toString();
|
||||
const suffix = search ? `?${search}` : "";
|
||||
const path = newJobId
|
||||
? `/${newTab}/${newJobId}${suffix}`
|
||||
: `/${newTab}${suffix}`;
|
||||
? `/jobs/${newTab}/${newJobId}${suffix}`
|
||||
: `/jobs/${newTab}${suffix}`;
|
||||
navigate(path, { replace: isReplace });
|
||||
},
|
||||
[navigate, searchParams],
|
||||
@ -93,9 +87,6 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const [runMode, setRunMode] = useState<RunMode>("automatic");
|
||||
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [pendingCommandScrollJobId, setPendingCommandScrollJobId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
@ -287,9 +278,16 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { requestScrollToJob } = useScrollToJobItem({
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
isDesktop,
|
||||
onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true),
|
||||
});
|
||||
|
||||
const handleCommandSelectJob = useCallback(
|
||||
(targetTab: FilterTab, id: string) => {
|
||||
setPendingCommandScrollJobId(id);
|
||||
requestScrollToJob(id, { ensureSelected: true });
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
for (const key of [
|
||||
"source",
|
||||
@ -302,36 +300,14 @@ export const OrchestratorPage: React.FC = () => {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
const query = nextParams.toString();
|
||||
navigate(`/${targetTab}/${id}${query ? `?${query}` : ""}`);
|
||||
navigate(`/jobs/${targetTab}/${id}${query ? `?${query}` : ""}`);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
},
|
||||
[isDesktop, navigate, searchParams],
|
||||
[isDesktop, navigate, requestScrollToJob, searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCommandScrollJobId) return;
|
||||
if (selectedJobId !== pendingCommandScrollJobId) return;
|
||||
const hasPendingTargetInList = activeJobs.some(
|
||||
(job) => job.id === pendingCommandScrollJobId,
|
||||
);
|
||||
if (!hasPendingTargetInList) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const selector = `[data-job-id="${escapeCssAttributeValue(
|
||||
pendingCommandScrollJobId,
|
||||
)}"]`;
|
||||
const target = document.querySelector<HTMLElement>(selector);
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({
|
||||
behavior: isDesktop ? "smooth" : "auto",
|
||||
block: "center",
|
||||
});
|
||||
setPendingCommandScrollJobId(null);
|
||||
}, [activeJobs, isDesktop, pendingCommandScrollJobId, selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeJobs.length === 0) {
|
||||
if (selectedJobId) handleSelectJobId(null);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AppSettings } from "@shared/types.js";
|
||||
import { createAppSettings } from "@shared/testing/factories.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -21,28 +21,16 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
const baseSettings = createAppSettings({
|
||||
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,
|
||||
llmProvider: "openrouter",
|
||||
defaultLlmProvider: "openrouter",
|
||||
overrideLlmProvider: null,
|
||||
llmBaseUrl: "https://openrouter.ai",
|
||||
defaultLlmBaseUrl: "https://openrouter.ai",
|
||||
overrideLlmBaseUrl: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
overridePipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: "",
|
||||
defaultJobCompleteWebhookUrl: "",
|
||||
overrideJobCompleteWebhookUrl: null,
|
||||
profileProjects: [
|
||||
{
|
||||
id: "proj-1",
|
||||
@ -69,72 +57,15 @@ const baseSettings: AppSettings = {
|
||||
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,
|
||||
jobspyIsRemote: false,
|
||||
defaultJobspyIsRemote: false,
|
||||
overrideJobspyIsRemote: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
llmApiKeyHint: null,
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: "",
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPasswordHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
rxresumeBaseResumeId: null,
|
||||
// Backup settings
|
||||
backupEnabled: false,
|
||||
defaultBackupEnabled: false,
|
||||
overrideBackupEnabled: null,
|
||||
backupHour: 2,
|
||||
defaultBackupHour: 2,
|
||||
overrideBackupHour: null,
|
||||
backupMaxCount: 5,
|
||||
defaultBackupMaxCount: 5,
|
||||
overrideBackupMaxCount: null,
|
||||
// Scoring settings
|
||||
penalizeMissingSalary: false,
|
||||
defaultPenalizeMissingSalary: false,
|
||||
overridePenalizeMissingSalary: null,
|
||||
missingSalaryPenalty: 10,
|
||||
defaultMissingSalaryPenalty: 10,
|
||||
overrideMissingSalaryPenalty: null,
|
||||
autoSkipScoreThreshold: null,
|
||||
defaultAutoSkipScoreThreshold: null,
|
||||
overrideAutoSkipScoreThreshold: null,
|
||||
};
|
||||
jobspyLocation: "UK",
|
||||
defaultJobspyLocation: "UK",
|
||||
searchTerms: ["engineer"],
|
||||
defaultSearchTerms: ["engineer"],
|
||||
});
|
||||
|
||||
const renderPage = () => {
|
||||
return render(
|
||||
|
||||
@ -32,7 +32,6 @@ import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { arraysEqual } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
model: "",
|
||||
@ -46,18 +45,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
jobCompleteWebhookUrl: "",
|
||||
resumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
jobspyLocation: null,
|
||||
jobspyResultsWanted: null,
|
||||
jobspyHoursOld: null,
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
jobspyIsRemote: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: "",
|
||||
@ -92,18 +80,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
jobspyLocation: null,
|
||||
jobspyResultsWanted: null,
|
||||
jobspyHoursOld: null,
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
jobspyIsRemote: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
basicAuthUser: null,
|
||||
@ -132,18 +109,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||
resumeProjects: data.resumeProjects,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
||||
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms: data.overrideSearchTerms,
|
||||
jobspyLocation: data.overrideJobspyLocation,
|
||||
jobspyResultsWanted: data.overrideJobspyResultsWanted,
|
||||
jobspyHoursOld: data.overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
|
||||
jobspySites: data.overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
||||
jobspyIsRemote: data.overrideJobspyIsRemote,
|
||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
@ -171,32 +137,9 @@ const normalizePrivateInput = (value: string | null | undefined) => {
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const isSameStringList = (
|
||||
left: string[] | null | undefined,
|
||||
right: string[] | null | undefined,
|
||||
) => {
|
||||
if (!left && !right) return true;
|
||||
if (!left || !right) return false;
|
||||
return arraysEqual(left, right);
|
||||
};
|
||||
|
||||
const isSameSortedStringList = (
|
||||
left: string[] | null | undefined,
|
||||
right: string[] | null | undefined,
|
||||
) => {
|
||||
if (!left && !right) return true;
|
||||
if (!left || !right) return false;
|
||||
return arraysEqual(left.slice().sort(), right.slice().sort());
|
||||
};
|
||||
|
||||
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||
value === defaultValue ? null : (value ?? null);
|
||||
|
||||
const nullIfSameList = (
|
||||
value: string[] | null | undefined,
|
||||
defaultValue: string[],
|
||||
) => (isSameStringList(value, defaultValue) ? null : (value ?? null));
|
||||
|
||||
const normalizeResumeProjectsForCatalog = (
|
||||
catalog: ResumeProjectCatalogItem[],
|
||||
current: ResumeProjectsSettings | null,
|
||||
@ -231,19 +174,6 @@ const normalizeResumeProjectsForCatalog = (
|
||||
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
|
||||
};
|
||||
|
||||
const nullIfSameSortedList = (
|
||||
value: string[] | null | undefined,
|
||||
defaultValue: string[],
|
||||
) => (isSameSortedStringList(value, defaultValue) ? null : (value ?? null));
|
||||
|
||||
const withAlwaysOnGlassdoor = (
|
||||
sites: string[] | null | undefined,
|
||||
): string[] => {
|
||||
const unique = new Set((sites ?? []).filter(Boolean));
|
||||
unique.add("glassdoor");
|
||||
return Array.from(unique);
|
||||
};
|
||||
|
||||
const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
const profileProjects = settings?.profileProjects ?? [];
|
||||
|
||||
@ -256,8 +186,7 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
projectSelection: settings?.modelProjectSelection ?? "",
|
||||
llmProvider: settings?.llmProvider ?? "",
|
||||
llmBaseUrl: settings?.llmBaseUrl ?? "",
|
||||
llmApiKeyHint:
|
||||
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null,
|
||||
llmApiKeyHint: settings?.llmApiKeyHint ?? null,
|
||||
},
|
||||
pipelineWebhook: {
|
||||
effective: settings?.pipelineWebhookUrl ?? "",
|
||||
@ -267,52 +196,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
effective: settings?.jobCompleteWebhookUrl ?? "",
|
||||
default: settings?.defaultJobCompleteWebhookUrl ?? "",
|
||||
},
|
||||
ukvisajobs: {
|
||||
effective: settings?.ukvisajobsMaxJobs ?? 50,
|
||||
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
|
||||
},
|
||||
gradcracker: {
|
||||
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
|
||||
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
|
||||
},
|
||||
searchTerms: {
|
||||
effective: settings?.searchTerms ?? [],
|
||||
default: settings?.defaultSearchTerms ?? [],
|
||||
},
|
||||
jobspy: {
|
||||
location: {
|
||||
effective: settings?.jobspyLocation ?? "",
|
||||
default: settings?.defaultJobspyLocation ?? "",
|
||||
},
|
||||
resultsWanted: {
|
||||
effective: settings?.jobspyResultsWanted ?? 200,
|
||||
default: settings?.defaultJobspyResultsWanted ?? 200,
|
||||
},
|
||||
hoursOld: {
|
||||
effective: settings?.jobspyHoursOld ?? 72,
|
||||
default: settings?.defaultJobspyHoursOld ?? 72,
|
||||
},
|
||||
countryIndeed: {
|
||||
effective: settings?.jobspyCountryIndeed ?? "",
|
||||
default: settings?.defaultJobspyCountryIndeed ?? "",
|
||||
},
|
||||
sites: {
|
||||
effective: withAlwaysOnGlassdoor(
|
||||
settings?.jobspySites ?? ["indeed", "linkedin", "glassdoor"],
|
||||
),
|
||||
default: withAlwaysOnGlassdoor(
|
||||
settings?.defaultJobspySites ?? ["indeed", "linkedin", "glassdoor"],
|
||||
),
|
||||
},
|
||||
linkedinFetchDescription: {
|
||||
effective: settings?.jobspyLinkedinFetchDescription ?? true,
|
||||
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
|
||||
},
|
||||
isRemote: {
|
||||
effective: settings?.jobspyIsRemote ?? false,
|
||||
default: settings?.defaultJobspyIsRemote ?? false,
|
||||
},
|
||||
},
|
||||
display: {
|
||||
effective: settings?.showSponsorInfo ?? true,
|
||||
default: settings?.defaultShowSponsorInfo ?? true,
|
||||
@ -324,7 +207,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
basicAuthUser: settings?.basicAuthUser ?? "",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
|
||||
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
||||
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
||||
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
||||
@ -503,10 +385,6 @@ export const SettingsPage: React.FC = () => {
|
||||
model,
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
ukvisajobs,
|
||||
gradcracker,
|
||||
searchTerms,
|
||||
jobspy,
|
||||
display,
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
@ -635,11 +513,6 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyFields.openrouterApiKey) {
|
||||
const value = normalizePrivateInput(data.openrouterApiKey);
|
||||
if (value !== undefined) envPayload.openrouterApiKey = value;
|
||||
}
|
||||
|
||||
if (dirtyFields.llmProvider) {
|
||||
envPayload.llmProvider = data.llmProvider ?? null;
|
||||
}
|
||||
@ -677,43 +550,6 @@ export const SettingsPage: React.FC = () => {
|
||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
||||
ukvisajobsMaxJobs: nullIfSame(
|
||||
data.ukvisajobsMaxJobs,
|
||||
ukvisajobs.default,
|
||||
),
|
||||
gradcrackerMaxJobsPerTerm: nullIfSame(
|
||||
data.gradcrackerMaxJobsPerTerm,
|
||||
gradcracker.default,
|
||||
),
|
||||
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
||||
jobspyLocation: nullIfSame(
|
||||
data.jobspyLocation,
|
||||
jobspy.location.default,
|
||||
),
|
||||
jobspyResultsWanted: nullIfSame(
|
||||
data.jobspyResultsWanted,
|
||||
jobspy.resultsWanted.default,
|
||||
),
|
||||
jobspyHoursOld: nullIfSame(
|
||||
data.jobspyHoursOld,
|
||||
jobspy.hoursOld.default,
|
||||
),
|
||||
jobspyCountryIndeed: nullIfSame(
|
||||
data.jobspyCountryIndeed,
|
||||
jobspy.countryIndeed.default,
|
||||
),
|
||||
jobspySites: nullIfSameSortedList(
|
||||
withAlwaysOnGlassdoor(data.jobspySites),
|
||||
jobspy.sites.default,
|
||||
),
|
||||
jobspyLinkedinFetchDescription: nullIfSame(
|
||||
data.jobspyLinkedinFetchDescription,
|
||||
jobspy.linkedinFetchDescription.default,
|
||||
),
|
||||
jobspyIsRemote: nullIfSame(
|
||||
data.jobspyIsRemote,
|
||||
jobspy.isRemote.default,
|
||||
),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
backupEnabled: nullIfSame(
|
||||
data.backupEnabled,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AppSettings } from "@shared/types";
|
||||
import { createAppSettings } from "@shared/testing/factories.js";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@ -22,12 +22,11 @@ describe("AutomaticRunTab", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
} as AppSettings
|
||||
}
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
jobspyLocation: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
@ -48,12 +47,11 @@ describe("AutomaticRunTab", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as AppSettings
|
||||
}
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
onToggleSource={vi.fn()}
|
||||
@ -71,16 +69,15 @@ describe("AutomaticRunTab", () => {
|
||||
expect(screen.getByRole("button", { name: "UK Visa Jobs" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows disabled source guidance copy for UK-only source", () => {
|
||||
it("shows disabled source guidance copy for UK-only source", async () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as AppSettings
|
||||
}
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
@ -103,12 +100,11 @@ describe("AutomaticRunTab", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "japan",
|
||||
} as AppSettings
|
||||
}
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "japan",
|
||||
jobspyLocation: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
onToggleSource={vi.fn()}
|
||||
@ -135,13 +131,11 @@ describe("AutomaticRunTab", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
} as AppSettings
|
||||
}
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
onToggleSource={vi.fn()}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@ -19,69 +20,6 @@ afterAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const createJob = (overrides: Partial<Job>): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "California",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobCommandBar", () => {
|
||||
const openWithKeyboard = () => {
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
|
||||
@ -1,73 +1,10 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeJobMatchScore,
|
||||
groupJobsForCommandBar,
|
||||
} from "./JobCommandBar.utils";
|
||||
|
||||
const createJob = (overrides: Partial<Job>): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "California",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobCommandBar score helpers", () => {
|
||||
it("returns zero when no title, employer, or location matches", () => {
|
||||
const score = computeJobMatchScore(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import {
|
||||
act,
|
||||
@ -134,69 +135,6 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: "https://example.com/apply",
|
||||
disciplines: null,
|
||||
deadline: "2025-02-01",
|
||||
salary: "GBP 50k",
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 82,
|
||||
suitabilityReason: "Strong fit",
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderJobDetailPanel = async (
|
||||
props: React.ComponentProps<typeof JobDetailPanel>,
|
||||
) => {
|
||||
|
||||
@ -1,71 +1,8 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { JobListPanel } from "./JobListPanel";
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 72,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobListPanel", () => {
|
||||
it("shows a loading state when fetching jobs", () => {
|
||||
render(
|
||||
|
||||
@ -1,17 +1,8 @@
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import { PageHeader, StatusIndicator } from "@client/components/layout";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { Loader2, Menu, Play, Sparkles, Square } from "lucide-react";
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrchestratorHeaderProps {
|
||||
navOpen: boolean;
|
||||
@ -32,99 +23,45 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
onOpenAutomaticRun,
|
||||
onCancelPipeline,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const actions = isPipelineRunning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCancelPipeline}
|
||||
disabled={isCancelling}
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isCancelling ? `Cancelling (${pipelineSources.length})` : `Cancel run`}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onOpenAutomaticRun} className="gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Run pipeline</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet open={navOpen} onOpenChange={onNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
onNavOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onNavOpenChange(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
Job Ops
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<span className="hidden sm:inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Pipeline running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isPipelineRunning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCancelPipeline}
|
||||
disabled={isCancelling}
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isCancelling
|
||||
? `Cancelling (${pipelineSources.length})`
|
||||
: `Cancel run`}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={onOpenAutomaticRun} className="gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Run pipeline</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
icon={() => (
|
||||
<img src="/favicon.png" alt="" className="size-8 rounded-lg" />
|
||||
)}
|
||||
title="Job Ops"
|
||||
subtitle="Orchestrator"
|
||||
navOpen={navOpen}
|
||||
onNavOpenChange={onNavOpenChange}
|
||||
statusIndicator={
|
||||
isPipelineRunning ? (
|
||||
<StatusIndicator label="Pipeline running" variant="amber" />
|
||||
) : undefined
|
||||
}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { BulkJobActionResponse } from "@shared/types.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canBulkMoveToReady,
|
||||
@ -7,96 +8,42 @@ import {
|
||||
getFailedJobIds,
|
||||
} from "./bulkActions";
|
||||
|
||||
function createJob(id: string, status: JobStatus): Job {
|
||||
return {
|
||||
id,
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Role",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: `https://example.com/${id}`,
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status,
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("bulkActions", () => {
|
||||
it("computes eligibility for skip, move-to-ready, and rescore", () => {
|
||||
expect(
|
||||
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
|
||||
canBulkSkip([
|
||||
createJob({ id: "1", status: "discovered" }),
|
||||
createJob({ id: "2", status: "ready" }),
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(canBulkSkip([createJob("1", "applied")])).toBe(false);
|
||||
expect(canBulkSkip([createJob({ id: "1", status: "applied" })])).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
canBulkMoveToReady([
|
||||
createJob("1", "discovered"),
|
||||
createJob("2", "discovered"),
|
||||
createJob({ id: "1", status: "discovered" }),
|
||||
createJob({ id: "2", status: "discovered" }),
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false);
|
||||
expect(canBulkMoveToReady([createJob({ id: "1", status: "ready" })])).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
canBulkRescore([
|
||||
createJob("1", "discovered"),
|
||||
createJob("2", "ready"),
|
||||
createJob("3", "applied"),
|
||||
createJob("4", "skipped"),
|
||||
createJob("5", "expired"),
|
||||
createJob({ id: "1", status: "discovered" }),
|
||||
createJob({ id: "2", status: "ready" }),
|
||||
createJob({ id: "3", status: "applied" }),
|
||||
createJob({ id: "4", status: "skipped" }),
|
||||
createJob({ id: "5", status: "expired" }),
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canBulkRescore([createJob("1", "ready"), createJob("2", "processing")]),
|
||||
canBulkRescore([
|
||||
createJob({ id: "1", status: "ready" }),
|
||||
createJob({ id: "2", status: "processing" }),
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@ -107,7 +54,11 @@ describe("bulkActions", () => {
|
||||
succeeded: 1,
|
||||
failed: 2,
|
||||
results: [
|
||||
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
|
||||
{
|
||||
jobId: "job-1",
|
||||
ok: true,
|
||||
job: createJob({ id: "job-1", status: "skipped" }),
|
||||
},
|
||||
{
|
||||
jobId: "job-2",
|
||||
ok: false,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { BulkJobActionResponse } from "@shared/types.js";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@ -16,70 +17,6 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function createJob(id: string, status: JobStatus): Job {
|
||||
return {
|
||||
id,
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: `Role ${id}`,
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: `https://example.com/${id}`,
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status,
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
@ -100,7 +37,7 @@ describe("useBulkJobSelection", () => {
|
||||
|
||||
it("caps select-all to the API max", () => {
|
||||
const activeJobs = Array.from({ length: 101 }, (_, index) =>
|
||||
createJob(`job-${index + 1}`, "discovered"),
|
||||
createJob({ id: `job-${index + 1}`, status: "discovered" }),
|
||||
);
|
||||
const loadJobs = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
@ -120,7 +57,7 @@ describe("useBulkJobSelection", () => {
|
||||
|
||||
it("does not send bulk requests above the max selection size", async () => {
|
||||
const activeJobs = Array.from({ length: 101 }, (_, index) =>
|
||||
createJob(`job-${index + 1}`, "discovered"),
|
||||
createJob({ id: `job-${index + 1}`, status: "discovered" }),
|
||||
);
|
||||
const loadJobs = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
@ -146,9 +83,9 @@ describe("useBulkJobSelection", () => {
|
||||
|
||||
it("reconciles failures with selection changes made during in-flight action", async () => {
|
||||
const activeJobs = [
|
||||
createJob("job-1", "discovered"),
|
||||
createJob("job-2", "discovered"),
|
||||
createJob("job-3", "discovered"),
|
||||
createJob({ id: "job-1", status: "discovered" }),
|
||||
createJob({ id: "job-2", status: "discovered" }),
|
||||
createJob({ id: "job-3", status: "discovered" }),
|
||||
];
|
||||
const loadJobs = vi.fn().mockResolvedValue(undefined);
|
||||
const pending = deferred<BulkJobActionResponse>();
|
||||
@ -184,7 +121,11 @@ describe("useBulkJobSelection", () => {
|
||||
succeeded: 1,
|
||||
failed: 1,
|
||||
results: [
|
||||
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
|
||||
{
|
||||
jobId: "job-1",
|
||||
ok: true,
|
||||
job: createJob({ id: "job-1", status: "skipped" }),
|
||||
},
|
||||
{
|
||||
jobId: "job-2",
|
||||
ok: false,
|
||||
@ -202,8 +143,8 @@ describe("useBulkJobSelection", () => {
|
||||
|
||||
it("runs bulk rescore and reports success copy", async () => {
|
||||
const activeJobs = [
|
||||
createJob("job-1", "ready"),
|
||||
createJob("job-2", "ready"),
|
||||
createJob({ id: "job-1", status: "ready" }),
|
||||
createJob({ id: "job-2", status: "ready" }),
|
||||
];
|
||||
const loadJobs = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.bulkJobAction).mockResolvedValue({
|
||||
@ -212,8 +153,16 @@ describe("useBulkJobSelection", () => {
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [
|
||||
{ jobId: "job-1", ok: true, job: createJob("job-1", "ready") },
|
||||
{ jobId: "job-2", ok: true, job: createJob("job-2", "ready") },
|
||||
{
|
||||
jobId: "job-1",
|
||||
ok: true,
|
||||
job: createJob({ id: "job-1", status: "ready" }),
|
||||
},
|
||||
{
|
||||
jobId: "job-2",
|
||||
ok: true,
|
||||
job: createJob({ id: "job-2", status: "ready" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -1,69 +1,18 @@
|
||||
import { createJob } from "@shared/testing/factories";
|
||||
import type { Job } from "@shared/types";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useFilteredJobs } from "./useFilteredJobs";
|
||||
|
||||
const baseJob: Job = {
|
||||
const baseJob = createJob({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Desc",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
});
|
||||
|
||||
describe("useFilteredJobs", () => {
|
||||
it("filters by sponsor status categories", () => {
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import type { JobListItem } from "@shared/types.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const escapeCssAttributeValue = (value: string) =>
|
||||
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
|
||||
type PendingScrollTarget = {
|
||||
jobId: string;
|
||||
ensureSelected: boolean;
|
||||
selectionRequested: boolean;
|
||||
};
|
||||
|
||||
type UseScrollToJobItemParams = {
|
||||
activeJobs: JobListItem[];
|
||||
selectedJobId: string | null;
|
||||
isDesktop: boolean;
|
||||
onEnsureJobSelected: (jobId: string) => void;
|
||||
};
|
||||
|
||||
export const useScrollToJobItem = ({
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
isDesktop,
|
||||
onEnsureJobSelected,
|
||||
}: UseScrollToJobItemParams) => {
|
||||
const [pendingTarget, setPendingTarget] =
|
||||
useState<PendingScrollTarget | null>(null);
|
||||
|
||||
const requestScrollToJob = useCallback(
|
||||
(jobId: string, options?: { ensureSelected?: boolean }) => {
|
||||
setPendingTarget({
|
||||
jobId,
|
||||
ensureSelected: options?.ensureSelected ?? false,
|
||||
selectionRequested: false,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTarget) return;
|
||||
if (!activeJobs.some((job) => job.id === pendingTarget.jobId)) return;
|
||||
|
||||
if (selectedJobId !== pendingTarget.jobId) {
|
||||
if (!pendingTarget.ensureSelected || pendingTarget.selectionRequested)
|
||||
return;
|
||||
onEnsureJobSelected(pendingTarget.jobId);
|
||||
setPendingTarget((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
selectionRequested: true,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return;
|
||||
const selector = `[data-job-id="${escapeCssAttributeValue(pendingTarget.jobId)}"]`;
|
||||
const target = document.querySelector<HTMLElement>(selector);
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({
|
||||
behavior: isDesktop ? "smooth" : "auto",
|
||||
block: "center",
|
||||
});
|
||||
setPendingTarget(null);
|
||||
}, [
|
||||
activeJobs,
|
||||
isDesktop,
|
||||
onEnsureJobSelected,
|
||||
pendingTarget,
|
||||
selectedJobId,
|
||||
]);
|
||||
|
||||
return { requestScrollToJob };
|
||||
};
|
||||
@ -166,7 +166,6 @@ export const getEnabledSources = (
|
||||
if (!settings) return [...DEFAULT_PIPELINE_SOURCES, "glassdoor"];
|
||||
|
||||
const enabled: JobSource[] = [];
|
||||
const jobspySites = settings.jobspySites ?? [];
|
||||
const hasUkVisaJobsAuth = Boolean(
|
||||
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint,
|
||||
);
|
||||
@ -185,9 +184,7 @@ export const getEnabledSources = (
|
||||
source === "linkedin" ||
|
||||
source === "glassdoor"
|
||||
) {
|
||||
if (source === "glassdoor" || jobspySites.includes(source)) {
|
||||
enabled.push(source);
|
||||
}
|
||||
enabled.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ const EnvironmentSettingsHarness = () => {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
openrouterApiKey: "",
|
||||
rxresumePassword: "",
|
||||
ukvisajobsPassword: "",
|
||||
basicAuthPassword: "",
|
||||
@ -30,7 +29,6 @@ const EnvironmentSettingsHarness = () => {
|
||||
basicAuthUser: "admin",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: "sk-1",
|
||||
rxresumePasswordHint: null,
|
||||
ukvisajobsPasswordHint: "pass",
|
||||
basicAuthPasswordHint: "abcd",
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type React from "react";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
values: NumericSettingValues;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<NumericSettingSection
|
||||
accordionValue="gradcracker"
|
||||
title="Gradcracker Extractor"
|
||||
fieldName="gradcrackerMaxJobsPerTerm"
|
||||
label="Max jobs per search term"
|
||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${values.default}. Range: 1-1000.`}
|
||||
values={values}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,77 +0,0 @@
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
import { JobspySection } from "./JobspySection";
|
||||
|
||||
const JobspyHarness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
jobspySites: ["indeed", "linkedin", "glassdoor"],
|
||||
jobspyLocation: "UK",
|
||||
jobspyResultsWanted: 200,
|
||||
jobspyHoursOld: 72,
|
||||
jobspyCountryIndeed: "UK",
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
jobspyIsRemote: false,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||
<JobspySection
|
||||
values={{
|
||||
sites: {
|
||||
default: ["indeed", "linkedin", "glassdoor"],
|
||||
effective: ["indeed", "linkedin", "glassdoor"],
|
||||
},
|
||||
location: { default: "UK", effective: "UK" },
|
||||
resultsWanted: { default: 200, effective: 200 },
|
||||
hoursOld: { default: 72, effective: 72 },
|
||||
countryIndeed: { default: "UK", effective: "UK" },
|
||||
linkedinFetchDescription: { default: true, effective: true },
|
||||
isRemote: { default: false, effective: false },
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
expect(screen.queryByLabelText(/glassdoor/i)).not.toBeInTheDocument();
|
||||
|
||||
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: "1001" } });
|
||||
expect(resultsWantedInput).toHaveValue(1000);
|
||||
|
||||
fireEvent.change(hoursOldInput, { target: { value: "0" } });
|
||||
expect(hoursOldInput).toHaveValue(1);
|
||||
});
|
||||
});
|
||||
@ -1,360 +0,0 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { JobspyValues } from "@client/pages/settings/types";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support.js";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type JobspySectionProps = {
|
||||
values: JobspyValues;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
sites,
|
||||
location,
|
||||
resultsWanted,
|
||||
hoursOld,
|
||||
countryIndeed,
|
||||
linkedinFetchDescription,
|
||||
isRemote,
|
||||
} = values;
|
||||
const configurableDefaultSites = sites.default.filter(
|
||||
(site) => site !== "glassdoor",
|
||||
);
|
||||
const configurableEffectiveSites = sites.effective.filter(
|
||||
(site) => site !== "glassdoor",
|
||||
);
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">JobSpy Scraper</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="jobspySites"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={
|
||||
field.value?.includes("indeed") ??
|
||||
sites.default.includes("indeed")
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default;
|
||||
let next = [...current];
|
||||
if (checked) {
|
||||
if (!next.includes("indeed")) next.push("indeed");
|
||||
} else {
|
||||
next = next.filter((s) => s !== "indeed");
|
||||
}
|
||||
field.onChange(next);
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="site-indeed"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Indeed
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="jobspySites"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={
|
||||
field.value?.includes("linkedin") ??
|
||||
sites.default.includes("linkedin")
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default;
|
||||
let next = [...current];
|
||||
if (checked) {
|
||||
if (!next.includes("linkedin")) next.push("linkedin");
|
||||
} else {
|
||||
next = next.filter((s) => s !== "linkedin");
|
||||
}
|
||||
field.onChange(next);
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="site-linkedin"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
LinkedIn
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{errors.jobspySites && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.jobspySites.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select configurable sites JobSpy should scrape.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Effective: {configurableEffectiveSites.join(", ") || "None"}
|
||||
</span>
|
||||
<span>Default: {configurableDefaultSites.join(", ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Location"
|
||||
inputProps={register("jobspyLocation")}
|
||||
placeholder={location.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyLocation?.message as string | undefined}
|
||||
helper={
|
||||
'Location to search for jobs (e.g. "UK", "London", "Remote").'
|
||||
}
|
||||
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="jobspyResultsWanted"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Results Wanted"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? resultsWanted.default,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={
|
||||
errors.jobspyResultsWanted?.message as string | undefined
|
||||
}
|
||||
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
|
||||
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="jobspyHoursOld"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Hours Old"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 720,
|
||||
value: field.value ?? hoursOld.default,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
field.onChange(Math.min(720, Math.max(1, value)));
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyHoursOld?.message as string | undefined}
|
||||
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
|
||||
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="jobspyCountryIndeed"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const currentValue = (
|
||||
field.value ??
|
||||
countryIndeed.default ??
|
||||
""
|
||||
).toLowerCase();
|
||||
const normalizedValue = normalizeCountryKey(currentValue);
|
||||
const displayValue = SUPPORTED_COUNTRY_KEYS.includes(
|
||||
normalizedValue,
|
||||
)
|
||||
? normalizedValue
|
||||
: "__default__";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="jobspyCountryIndeed"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Indeed Country
|
||||
</label>
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__default__") {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
>
|
||||
<SelectTrigger id="jobspyCountryIndeed">
|
||||
<SelectValue placeholder="Select a country..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{`Use default (${countryIndeed.default || "UK"})`}
|
||||
</SelectItem>
|
||||
{SUPPORTED_COUNTRY_KEYS.map((country) => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{formatCountryLabel(country)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.jobspyCountryIndeed && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.jobspyCountryIndeed.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select one of JobSpy's supported Indeed country values.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="jobspyLinkedinFetchDescription"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={field.value ?? linkedinFetchDescription.default}
|
||||
onCheckedChange={(checked) => field.onChange(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="linkedin-desc"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Fetch LinkedIn Description
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If enabled, JobSpy will make extra requests to fetch full
|
||||
descriptions. Slower but better data.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}
|
||||
</span>
|
||||
<span>
|
||||
Default: {linkedinFetchDescription.default ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
name="jobspyIsRemote"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="jobspy-remote"
|
||||
checked={field.value ?? isRemote.default}
|
||||
onCheckedChange={(checked) => field.onChange(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="jobspy-remote"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Remote Jobs?
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only search for remote job listings
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {isRemote.effective ? "Yes" : "No"}</span>
|
||||
<span>Default: {isRemote.default ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
const Harness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
ukvisajobsMaxJobs: 50,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["ukvisajobs"]}>
|
||||
<NumericSettingSection
|
||||
accordionValue="ukvisajobs"
|
||||
title="UKVisaJobs Extractor"
|
||||
fieldName="ukvisajobsMaxJobs"
|
||||
label="Max jobs to fetch"
|
||||
helper="Maximum jobs per run."
|
||||
values={{ default: 50, effective: 50 }}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("NumericSettingSection", () => {
|
||||
it("clamps out-of-range values and clears invalid number input", () => {
|
||||
render(<Harness />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "1001" } });
|
||||
expect(input).toHaveValue(1000);
|
||||
|
||||
fireEvent.change(input, { target: { value: "0" } });
|
||||
expect(input).toHaveValue(1);
|
||||
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(input).toHaveValue(50);
|
||||
});
|
||||
});
|
||||
@ -1,91 +0,0 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
type NumericFieldName =
|
||||
| "ukvisajobsMaxJobs"
|
||||
| "gradcrackerMaxJobsPerTerm"
|
||||
| "jobspyResultsWanted"
|
||||
| "jobspyHoursOld"
|
||||
| "backupHour"
|
||||
| "backupMaxCount";
|
||||
|
||||
type NumericSettingSectionProps = {
|
||||
accordionValue: string;
|
||||
title: string;
|
||||
fieldName: NumericFieldName;
|
||||
label: string;
|
||||
helper: string;
|
||||
values: NumericSettingValues;
|
||||
min: number;
|
||||
max: number;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const NumericSettingSection: React.FC<NumericSettingSectionProps> = ({
|
||||
accordionValue,
|
||||
title,
|
||||
fieldName,
|
||||
label,
|
||||
helper,
|
||||
values,
|
||||
min,
|
||||
max,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective, default: defaultValue } = values;
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value={accordionValue} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">{title}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
name={fieldName}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label={label}
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min,
|
||||
max,
|
||||
value: field.value ?? defaultValue,
|
||||
onChange: (event) => {
|
||||
const parsed = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
field.onChange(Math.min(max, Math.max(min, parsed)));
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors[fieldName]?.message as string | undefined}
|
||||
helper={helper}
|
||||
current={String(effective)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@ -1,99 +0,0 @@
|
||||
import type { SearchTermsValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type SearchTermsSectionProps = {
|
||||
values: SearchTermsValues;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { default: defaultSearchTerms, effective: effectiveSearchTerms } =
|
||||
values;
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Search Terms</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<Controller
|
||||
name="searchTerms"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={
|
||||
field.value
|
||||
? field.value.join("\n")
|
||||
: (defaultSearchTerms ?? []).join("\n")
|
||||
}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value;
|
||||
const terms = text.split("\n");
|
||||
field.onChange(terms);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (field.value) {
|
||||
field.onChange(
|
||||
field.value.map((t) => t.trim()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.searchTerms && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.searchTerms.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported
|
||||
extractors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{(effectiveSearchTerms || []).join(", ") || "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{(defaultSearchTerms || []).join(", ") || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type React from "react";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
values: NumericSettingValues;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<NumericSettingSection
|
||||
accordionValue="ukvisajobs"
|
||||
title="UKVisaJobs Extractor"
|
||||
fieldName="ukvisajobsMaxJobs"
|
||||
label="Max jobs to fetch"
|
||||
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${values.default}. Range: 1-1000.`}
|
||||
values={values}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -13,20 +13,8 @@ export type ModelValues = EffectiveDefault<string> & {
|
||||
};
|
||||
|
||||
export type WebhookValues = EffectiveDefault<string>;
|
||||
export type NumericSettingValues = EffectiveDefault<number>;
|
||||
export type SearchTermsValues = EffectiveDefault<string[]>;
|
||||
export type DisplayValues = EffectiveDefault<boolean>;
|
||||
|
||||
export type JobspyValues = {
|
||||
sites: EffectiveDefault<string[]>;
|
||||
location: EffectiveDefault<string>;
|
||||
resultsWanted: EffectiveDefault<number>;
|
||||
hoursOld: EffectiveDefault<number>;
|
||||
countryIndeed: EffectiveDefault<string>;
|
||||
linkedinFetchDescription: EffectiveDefault<boolean>;
|
||||
isRemote: EffectiveDefault<boolean>;
|
||||
};
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
readable: {
|
||||
rxresumeEmail: string;
|
||||
@ -34,7 +22,6 @@ export type EnvSettingsValues = {
|
||||
basicAuthUser: string;
|
||||
};
|
||||
private: {
|
||||
openrouterApiKeyHint: string | null;
|
||||
rxresumePasswordHint: string | null;
|
||||
ukvisajobsPasswordHint: string | null;
|
||||
basicAuthPasswordHint: string | null;
|
||||
|
||||
@ -93,7 +93,7 @@ describe.sequential("Demo mode API behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("simulates apply and does not call Notion in demo mode", async () => {
|
||||
it("simulates apply in demo mode", async () => {
|
||||
const { server, baseUrl, closeDb, tempDir } = await startServer({
|
||||
env: { DEMO_MODE: "true" },
|
||||
});
|
||||
@ -124,7 +124,6 @@ describe.sequential("Demo mode API behavior", () => {
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.meta?.simulated).toBe(true);
|
||||
expect(body.data.status).toBe("applied");
|
||||
expect(String(body.data.notionPageId)).toMatch(/^demo-notion-/);
|
||||
} finally {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
}
|
||||
|
||||
@ -451,13 +451,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
expect(body.meta.requestId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies a job and syncs to Notion", async () => {
|
||||
const { createNotionEntry } = await import("../../services/notion");
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({
|
||||
success: true,
|
||||
pageId: "page-123",
|
||||
});
|
||||
|
||||
it("applies a job", async () => {
|
||||
const { createJob } = await import("../../repositories/jobs");
|
||||
const job = await createJob({
|
||||
source: "manual",
|
||||
@ -473,15 +467,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.status).toBe("applied");
|
||||
expect(body.data.notionPageId).toBe("page-123");
|
||||
expect(body.data.appliedAt).toBeTruthy();
|
||||
expect(createNotionEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
employer: job.employer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rescoring a job updates the suitability fields", async () => {
|
||||
|
||||
@ -39,7 +39,6 @@ import {
|
||||
simulateRescoreJob,
|
||||
simulateSummarizeJob,
|
||||
} from "../../services/demo-simulator";
|
||||
import { createNotionEntry } from "../../services/notion";
|
||||
import { getProfile } from "../../services/profile";
|
||||
import { scoreJobSuitability } from "../../services/scorer";
|
||||
import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||
@ -906,7 +905,7 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
|
||||
* POST /api/jobs/:id/apply - Mark a job as applied
|
||||
*/
|
||||
jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -924,19 +923,6 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
const appliedAtDate = new Date();
|
||||
const appliedAt = appliedAtDate.toISOString();
|
||||
|
||||
// Sync to Notion
|
||||
const notionResult = await createNotionEntry({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
employer: job.employer,
|
||||
applicationLink: job.applicationLink,
|
||||
deadline: job.deadline,
|
||||
salary: job.salary,
|
||||
location: job.location,
|
||||
pdfPath: job.pdfPath,
|
||||
appliedAt,
|
||||
});
|
||||
|
||||
transitionStage(
|
||||
job.id,
|
||||
"applied",
|
||||
@ -948,11 +934,9 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
null,
|
||||
);
|
||||
|
||||
// Update job status + Notion metadata
|
||||
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||
status: "applied",
|
||||
appliedAt,
|
||||
notionPageId: notionResult.pageId,
|
||||
});
|
||||
|
||||
if (updatedJob) {
|
||||
|
||||
@ -11,7 +11,7 @@ describe.sequential("Settings API routes", () => {
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
||||
env: {
|
||||
OPENROUTER_API_KEY: "secret-key",
|
||||
LLM_API_KEY: "secret-key",
|
||||
RXRESUME_EMAIL: "resume@example.com",
|
||||
},
|
||||
}));
|
||||
@ -29,7 +29,6 @@ describe.sequential("Settings API routes", () => {
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe("resume@example.com");
|
||||
expect(body.data.llmApiKeyHint).toBe("secr");
|
||||
expect(body.data.openrouterApiKeyHint).toBe("secr");
|
||||
expect(body.data.basicAuthActive).toBe(false);
|
||||
});
|
||||
|
||||
@ -47,7 +46,7 @@ describe.sequential("Settings API routes", () => {
|
||||
body: JSON.stringify({
|
||||
searchTerms: ["engineer"],
|
||||
rxresumeEmail: "updated@example.com",
|
||||
openrouterApiKey: "updated-secret",
|
||||
llmApiKey: "updated-secret",
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
@ -56,7 +55,6 @@ describe.sequential("Settings API routes", () => {
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
|
||||
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
||||
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
||||
expect(patchBody.data.openrouterApiKeyHint).toBe("upda");
|
||||
});
|
||||
|
||||
it("validates basic auth requirements", async () => {
|
||||
|
||||
@ -51,10 +51,6 @@ vi.mock("../../pipeline/index", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../services/notion", () => ({
|
||||
createNotionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../services/manualJob", () => ({
|
||||
inferManualJobDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -26,11 +26,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
|
||||
backupMaxCount: "5",
|
||||
jobspyLocation: "United States",
|
||||
jobspyResultsWanted: "25",
|
||||
jobspyHoursOld: "72",
|
||||
jobspyCountryIndeed: "US",
|
||||
jobspySites: JSON.stringify(["linkedin", "indeed", "glassdoor"]),
|
||||
jobspyLinkedinFetchDescription: "1",
|
||||
jobspyIsRemote: "0",
|
||||
resumeProjects: JSON.stringify({
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: ["demo-project-1"],
|
||||
@ -279,7 +275,6 @@ export interface DemoDefaultJob {
|
||||
tailoredSkills?: string[];
|
||||
selectedProjectIds?: string;
|
||||
pdfPath?: string;
|
||||
notionPageId?: string;
|
||||
appliedOffsetMinutes?: number;
|
||||
}
|
||||
|
||||
@ -452,7 +447,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
|
||||
tailoredSkills: ["TypeScript", "Architecture", "Mentorship", "SRE"],
|
||||
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
|
||||
pdfPath: "/pdfs/demo-job-applied-1.pdf",
|
||||
notionPageId: "demo-notion-applied-1",
|
||||
},
|
||||
{
|
||||
id: "demo-job-applied-2",
|
||||
@ -478,7 +472,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
|
||||
tailoredSkills: ["Data Pipelines", "TypeScript", "SQL", "Observability"],
|
||||
selectedProjectIds: "demo-project-1,demo-project-2,demo-project-4",
|
||||
pdfPath: "/pdfs/demo-job-applied-2.pdf",
|
||||
notionPageId: "demo-notion-applied-2",
|
||||
},
|
||||
{
|
||||
id: "demo-job-applied-3",
|
||||
@ -504,7 +497,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
|
||||
tailoredSkills: ["System Design", "Team Leadership", "TypeScript", "APIs"],
|
||||
selectedProjectIds: "demo-project-2,demo-project-4,demo-project-5",
|
||||
pdfPath: "/pdfs/demo-job-applied-3.pdf",
|
||||
notionPageId: "demo-notion-applied-3",
|
||||
},
|
||||
{
|
||||
id: "demo-job-applied-4",
|
||||
@ -530,7 +522,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
|
||||
tailoredSkills: ["APIs", "Testing", "TypeScript", "CI/CD"],
|
||||
selectedProjectIds: "demo-project-2,demo-project-3,demo-project-4",
|
||||
pdfPath: "/pdfs/demo-job-applied-4.pdf",
|
||||
notionPageId: "demo-notion-applied-4",
|
||||
},
|
||||
{
|
||||
id: "demo-job-applied-5",
|
||||
@ -556,7 +547,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
|
||||
tailoredSkills: ["Reliability", "Node.js", "TypeScript", "Operations"],
|
||||
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
|
||||
pdfPath: "/pdfs/demo-job-applied-5.pdf",
|
||||
notionPageId: "demo-notion-applied-5",
|
||||
},
|
||||
{
|
||||
id: "demo-job-skipped-1",
|
||||
|
||||
@ -141,7 +141,6 @@ function buildGeneratedJob(
|
||||
| "tailoredSkills"
|
||||
| "selectedProjectIds"
|
||||
| "pdfPath"
|
||||
| "notionPageId"
|
||||
>;
|
||||
|
||||
if (status === "applied") {
|
||||
@ -163,7 +162,6 @@ function buildGeneratedJob(
|
||||
tailoredSkills: ["TypeScript", "Node.js", "APIs", "Observability"],
|
||||
selectedProjectIds: PROJECT_ID_SETS[idx % PROJECT_ID_SETS.length],
|
||||
pdfPath: `/pdfs/demo-job-applied-auto-${n}.pdf`,
|
||||
notionPageId: `demo-notion-applied-auto-${n}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -68,7 +68,6 @@ const migrations = [
|
||||
suitability_reason TEXT,
|
||||
tailored_summary TEXT,
|
||||
pdf_path TEXT,
|
||||
notion_page_id TEXT,
|
||||
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
processed_at TEXT,
|
||||
applied_at TEXT,
|
||||
@ -131,6 +130,15 @@ const migrations = [
|
||||
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
||||
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
||||
`DELETE FROM settings WHERE key = 'webhookUrl'`,
|
||||
// Drop legacy settings keys that are no longer read by the app.
|
||||
`DELETE FROM settings
|
||||
WHERE key IN (
|
||||
'jobspyHoursOld',
|
||||
'jobspySites',
|
||||
'jobspyLinkedinFetchDescription',
|
||||
'jobspyIsRemote',
|
||||
'openrouterApiKey'
|
||||
)`,
|
||||
|
||||
// Add source column for existing databases (safe to skip if already present)
|
||||
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,
|
||||
|
||||
@ -92,7 +92,6 @@ export const jobs = sqliteTable("jobs", {
|
||||
tailoredSkills: text("tailored_skills"),
|
||||
selectedProjectIds: text("selected_project_ids"),
|
||||
pdfPath: text("pdf_path"),
|
||||
notionPageId: text("notion_page_id"),
|
||||
sponsorMatchScore: real("sponsor_match_score"),
|
||||
sponsorMatchNames: text("sponsor_match_names"),
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* correctly calculates and stores sponsor match scores and names.
|
||||
*/
|
||||
|
||||
import { createJob as createBaseJob } from "@shared/testing/factories";
|
||||
import type { Job } from "@shared/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@ -53,69 +54,22 @@ vi.mock("../services/ukvisajobs", () => ({
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Mock job template
|
||||
const createMockJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "test-job-1",
|
||||
source: "gradcracker",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Software Engineer",
|
||||
employer: "Acme Corporation Ltd",
|
||||
employerUrl: null,
|
||||
jobUrl: "http://test.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Looking for a TypeScript developer.",
|
||||
status: "discovered",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: now,
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
createBaseJob({
|
||||
id: "test-job-1",
|
||||
source: "gradcracker",
|
||||
title: "Software Engineer",
|
||||
employer: "Acme Corporation Ltd",
|
||||
location: "London",
|
||||
jobDescription: "Looking for a TypeScript developer.",
|
||||
status: "discovered",
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
discoveredAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Sponsor Match Calculation", () => {
|
||||
let searchSponsors: ReturnType<typeof vi.fn>;
|
||||
@ -171,7 +125,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
|
||||
describe("searchSponsors integration", () => {
|
||||
it("should calculate sponsor match score when employer matches a sponsor", async () => {
|
||||
const mockJob = createMockJob({ employer: "Acme Corporation Ltd" });
|
||||
const mockJob = createJob({ employer: "Acme Corporation Ltd" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning a match
|
||||
@ -206,7 +160,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should handle 100% perfect matches correctly", async () => {
|
||||
const mockJob = createMockJob({ employer: "Microsoft UK" });
|
||||
const mockJob = createJob({ employer: "Microsoft UK" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning perfect matches
|
||||
@ -245,7 +199,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should report single top match when no perfect matches exist", async () => {
|
||||
const mockJob = createMockJob({ employer: "Tech Corp" });
|
||||
const mockJob = createJob({ employer: "Tech Corp" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning partial matches only
|
||||
@ -276,7 +230,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should not set sponsor match when no matches found", async () => {
|
||||
const mockJob = createMockJob({ employer: "Unknown Company XYZ" });
|
||||
const mockJob = createJob({ employer: "Unknown Company XYZ" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning no matches
|
||||
@ -302,7 +256,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should skip sponsor matching when job has no employer", async () => {
|
||||
const mockJob = createMockJob({ employer: null as unknown as string });
|
||||
const mockJob = createJob({ employer: null as unknown as string });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import("./orchestrator");
|
||||
@ -322,7 +276,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should skip sponsor matching when job has empty employer string", async () => {
|
||||
const mockJob = createMockJob({ employer: "" });
|
||||
const mockJob = createJob({ employer: "" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import("./orchestrator");
|
||||
@ -335,7 +289,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
|
||||
describe("sponsor match edge cases", () => {
|
||||
it("should use correct limit and minScore options", async () => {
|
||||
const mockJob = createMockJob({ employer: "Test Company" });
|
||||
const mockJob = createJob({ employer: "Test Company" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
@ -349,7 +303,7 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should handle single 100% match correctly", async () => {
|
||||
const mockJob = createMockJob({ employer: "Google UK" });
|
||||
const mockJob = createJob({ employer: "Google UK" });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
searchSponsors.mockReturnValue([
|
||||
@ -374,11 +328,11 @@ describe("Sponsor Match Calculation", () => {
|
||||
});
|
||||
|
||||
it("should process multiple jobs with different sponsor matches", async () => {
|
||||
const mockJob1 = createMockJob({
|
||||
const mockJob1 = createJob({
|
||||
id: "job-1",
|
||||
employer: "Amazon UK",
|
||||
});
|
||||
const mockJob2 = createMockJob({
|
||||
const mockJob2 = createJob({
|
||||
id: "job-2",
|
||||
employer: "Meta Platforms",
|
||||
});
|
||||
|
||||
@ -40,14 +40,13 @@ describe("discoverJobsStep", () => {
|
||||
resetProgress();
|
||||
});
|
||||
|
||||
it("applies jobspySites setting and aggregates source errors", async () => {
|
||||
it("aggregates source errors for enabled sources", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
const ukVisa = await import("../../services/ukvisajobs");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspySites: JSON.stringify(["linkedin"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
@ -72,7 +71,7 @@ describe("discoverJobsStep", () => {
|
||||
expect(result.discoveredJobs).toHaveLength(1);
|
||||
expect(result.sourceErrors).toEqual(["ukvisajobs: login failed"]);
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sites: ["linkedin"] }),
|
||||
expect.objectContaining({ sites: ["indeed", "linkedin"] }),
|
||||
);
|
||||
});
|
||||
|
||||
@ -82,7 +81,6 @@ describe("discoverJobsStep", () => {
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspySites: JSON.stringify(["glassdoor"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
@ -110,32 +108,6 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps glassdoor enabled even when jobspySites override omits it", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspySites: JSON.stringify(["linkedin"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["glassdoor", "linkedin"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sites: ["glassdoor", "linkedin"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out glassdoor for unsupported countries", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
@ -231,7 +203,6 @@ describe("discoverJobsStep", () => {
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer", "frontend"]),
|
||||
jobspySites: JSON.stringify(["linkedin"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockImplementation(async (options: any) => {
|
||||
|
||||
@ -66,25 +66,11 @@ export async function discoverJobsStep(args: {
|
||||
);
|
||||
}
|
||||
|
||||
let jobSpySites = compatibleSources.filter(
|
||||
const jobSpySites = compatibleSources.filter(
|
||||
(source): source is "indeed" | "linkedin" | "glassdoor" =>
|
||||
source === "indeed" || source === "linkedin" || source === "glassdoor",
|
||||
);
|
||||
|
||||
const jobspySitesSettingRaw = settings.jobspySites;
|
||||
if (jobspySitesSettingRaw) {
|
||||
try {
|
||||
const allowed = JSON.parse(jobspySitesSettingRaw);
|
||||
if (Array.isArray(allowed)) {
|
||||
jobSpySites = jobSpySites.filter(
|
||||
(site) => site === "glassdoor" || allowed.includes(site),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse error
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRunJobSpy = jobSpySites.length > 0;
|
||||
const shouldRunGradcracker = compatibleSources.includes("gradcracker");
|
||||
const shouldRunUkVisaJobs = compatibleSources.includes("ukvisajobs");
|
||||
@ -119,20 +105,7 @@ export async function discoverJobsStep(args: {
|
||||
resultsWanted: settings.jobspyResultsWanted
|
||||
? parseInt(settings.jobspyResultsWanted, 10)
|
||||
: undefined,
|
||||
hoursOld: settings.jobspyHoursOld
|
||||
? parseInt(settings.jobspyHoursOld, 10)
|
||||
: undefined,
|
||||
countryIndeed: settings.jobspyCountryIndeed ?? undefined,
|
||||
linkedinFetchDescription:
|
||||
settings.jobspyLinkedinFetchDescription !== null &&
|
||||
settings.jobspyLinkedinFetchDescription !== undefined
|
||||
? settings.jobspyLinkedinFetchDescription === "1"
|
||||
: undefined,
|
||||
isRemote:
|
||||
settings.jobspyIsRemote !== null &&
|
||||
settings.jobspyIsRemote !== undefined
|
||||
? settings.jobspyIsRemote === "1"
|
||||
: undefined,
|
||||
onProgress: (event) => {
|
||||
if (event.type === "term_start") {
|
||||
progressHelpers.crawlingUpdate({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Job } from "@shared/types";
|
||||
import { createJob } from "@shared/testing/factories";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { scoreJobsStep } from "./score-jobs";
|
||||
|
||||
@ -36,18 +36,6 @@ vi.mock("../progress", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function createMockJob(overrides: Partial<Job> = {}): Job {
|
||||
return {
|
||||
id: "job-1",
|
||||
title: "Software Engineer",
|
||||
employer: "Acme Corp",
|
||||
status: "discovered",
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
...overrides,
|
||||
} as Job;
|
||||
}
|
||||
|
||||
describe("scoreJobsStep auto-skip behavior", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@ -58,7 +46,13 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
||||
const visaSponsors = await import("../../services/visa-sponsors/index");
|
||||
|
||||
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
|
||||
createMockJob(),
|
||||
createJob({
|
||||
title: "Software Engineer",
|
||||
employer: "Acme Corp",
|
||||
status: "discovered",
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
}),
|
||||
]);
|
||||
vi.mocked(jobsRepo.updateJob).mockResolvedValue(null);
|
||||
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
|
||||
@ -164,7 +158,14 @@ describe("scoreJobsStep auto-skip behavior", () => {
|
||||
|
||||
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
|
||||
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
|
||||
createMockJob({ id: "job-applied", status: "applied" }),
|
||||
createJob({
|
||||
id: "job-applied",
|
||||
status: "applied",
|
||||
title: "Software Engineer",
|
||||
employer: "Acme Corp",
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
await scoreJobsStep({ profile: {} });
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./jobs";
|
||||
export * from "./pipeline";
|
||||
@ -373,7 +373,6 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||
tailoredSkills: row.tailoredSkills ?? null,
|
||||
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||
pdfPath: row.pdfPath,
|
||||
notionPageId: row.notionPageId,
|
||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||
jobType: row.jobType ?? null,
|
||||
|
||||
@ -24,13 +24,8 @@ export type SettingKey =
|
||||
| "searchTerms"
|
||||
| "jobspyLocation"
|
||||
| "jobspyResultsWanted"
|
||||
| "jobspyHoursOld"
|
||||
| "jobspyCountryIndeed"
|
||||
| "jobspySites"
|
||||
| "jobspyLinkedinFetchDescription"
|
||||
| "jobspyIsRemote"
|
||||
| "showSponsorInfo"
|
||||
| "openrouterApiKey"
|
||||
| "rxresumeEmail"
|
||||
| "rxresumePassword"
|
||||
| "basicAuthUser"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Job } from "@shared/types";
|
||||
import { createJob } from "@shared/testing/factories";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { pickProjectIdsForJob } from "./projectSelection";
|
||||
import { scoreJobSuitability } from "./scorer";
|
||||
@ -12,70 +12,16 @@ vi.mock("../repositories/settings", () => ({
|
||||
// We need to mock 'fetch' globally for these tests
|
||||
const globalFetch = global.fetch;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// A simple mock job
|
||||
const mockJob: Job = {
|
||||
const mockJob = createJob({
|
||||
id: "test-job",
|
||||
source: "gradcracker",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Senior Engineer",
|
||||
employer: "Test Corp",
|
||||
employerUrl: null,
|
||||
jobUrl: "http://test.com",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Looking for a TypeScript and React expert.",
|
||||
status: "discovered",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: now,
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const mockProfile = { name: "Test User" };
|
||||
|
||||
|
||||
@ -117,7 +117,6 @@ describe.sequential("demo seed baseline", () => {
|
||||
source: schema.jobs.source,
|
||||
title: schema.jobs.title,
|
||||
employer: schema.jobs.employer,
|
||||
notionPageId: schema.jobs.notionPageId,
|
||||
})
|
||||
.from(schema.jobs),
|
||||
db
|
||||
|
||||
@ -66,7 +66,6 @@ export function buildDemoBaseline(now: Date): BuiltDemoBaseline {
|
||||
: null,
|
||||
selectedProjectIds: job.selectedProjectIds ?? null,
|
||||
pdfPath: job.pdfPath ?? null,
|
||||
notionPageId: job.notionPageId ?? null,
|
||||
discoveredAt: toIsoFromOffset(now, job.discoveredOffsetMinutes),
|
||||
appliedAt:
|
||||
job.status === "applied" && typeof job.appliedOffsetMinutes === "number"
|
||||
|
||||
@ -151,7 +151,6 @@ export async function simulateApplyJob(jobId: string): Promise<Job> {
|
||||
const updated = await jobsRepo.updateJob(job.id, {
|
||||
status: "applied",
|
||||
appliedAt: appliedAtDate.toISOString(),
|
||||
notionPageId: `demo-notion-${job.id.slice(0, 8)}`,
|
||||
});
|
||||
if (!updated) throw new Error("Job not found");
|
||||
return updated;
|
||||
|
||||
@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe.sequential("envSettings migration", () => {
|
||||
describe.sequential("envSettings overrides", () => {
|
||||
let tempDir: string;
|
||||
let closeDb: (() => void) | null = null;
|
||||
|
||||
@ -17,6 +17,7 @@ describe.sequential("envSettings migration", () => {
|
||||
DATA_DIR: tempDir,
|
||||
NODE_ENV: "test",
|
||||
MODEL: "test-model",
|
||||
LLM_API_KEY: "sk-env-default",
|
||||
};
|
||||
|
||||
await import("../db/migrate");
|
||||
@ -30,34 +31,26 @@ describe.sequential("envSettings migration", () => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("migrates stored openrouterApiKey -> llmApiKey for openrouter provider", async () => {
|
||||
it("applies stored llmApiKey override to process env", async () => {
|
||||
const settingsRepo = await import("../repositories/settings");
|
||||
const { applyStoredEnvOverrides } = await import("./envSettings");
|
||||
|
||||
await settingsRepo.setSetting("llmProvider", "openrouter");
|
||||
await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy");
|
||||
await settingsRepo.setSetting("llmApiKey", null);
|
||||
await settingsRepo.setSetting("llmApiKey", "sk-db-override");
|
||||
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
expect(await settingsRepo.getSetting("llmApiKey")).toBe("sk-or-legacy");
|
||||
expect(await settingsRepo.getSetting("openrouterApiKey")).toBe(null);
|
||||
expect(process.env.LLM_API_KEY).toBe("sk-or-legacy");
|
||||
expect(await settingsRepo.getSetting("llmApiKey")).toBe("sk-db-override");
|
||||
expect(process.env.LLM_API_KEY).toBe("sk-db-override");
|
||||
});
|
||||
|
||||
it("does not migrate openrouterApiKey when provider is not openrouter", async () => {
|
||||
it("restores default env value when override is explicitly cleared", async () => {
|
||||
const settingsRepo = await import("../repositories/settings");
|
||||
const { applyStoredEnvOverrides } = await import("./envSettings");
|
||||
|
||||
await settingsRepo.setSetting("llmProvider", "openai");
|
||||
await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy");
|
||||
await settingsRepo.setSetting("llmApiKey", null);
|
||||
await settingsRepo.setSetting("llmApiKey", "");
|
||||
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
expect(await settingsRepo.getSetting("llmApiKey")).toBe(null);
|
||||
expect(await settingsRepo.getSetting("openrouterApiKey")).toBe(
|
||||
"sk-or-legacy",
|
||||
);
|
||||
expect(process.env.LLM_API_KEY).toBe("sk-env-default");
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,11 +27,6 @@ const privateStringConfig: {
|
||||
envKey: "LLM_API_KEY",
|
||||
hintKey: "llmApiKeyHint",
|
||||
},
|
||||
{
|
||||
settingKey: "openrouterApiKey",
|
||||
envKey: "OPENROUTER_API_KEY",
|
||||
hintKey: "openrouterApiKeyHint",
|
||||
},
|
||||
{
|
||||
settingKey: "rxresumePassword",
|
||||
envKey: "RXRESUME_PASSWORD",
|
||||
@ -103,56 +98,6 @@ export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
const safeSetSetting = async (key: SettingKey, value: string | null) => {
|
||||
try {
|
||||
await settingsRepo.setSetting(key, value);
|
||||
} catch (error) {
|
||||
const msg = String((error as Error)?.message ?? error);
|
||||
if (msg.includes("no such table") && msg.includes("settings")) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Migration: move legacy OpenRouter key to the unified LLM key.
|
||||
//
|
||||
// Users only see their API keys once. If we simply switch to LLM_API_KEY without
|
||||
// copying, they may be unable to recover their existing key.
|
||||
const providerOverride = await safeGetSetting("llmProvider");
|
||||
const legacyOpenrouterKey = normalizeEnvInput(
|
||||
await safeGetSetting("openrouterApiKey"),
|
||||
);
|
||||
const unifiedKey = normalizeEnvInput(await safeGetSetting("llmApiKey"));
|
||||
|
||||
const effectiveProvider = (providerOverride ?? process.env.LLM_PROVIDER)
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (
|
||||
(effectiveProvider ?? "openrouter") === "openrouter" &&
|
||||
legacyOpenrouterKey &&
|
||||
!unifiedKey
|
||||
) {
|
||||
console.warn(
|
||||
"[DEPRECATED] Detected stored OpenRouter API key. Migrating to LLM_API_KEY and clearing legacy storage.",
|
||||
);
|
||||
await safeSetSetting("llmApiKey", legacyOpenrouterKey);
|
||||
await safeSetSetting("openrouterApiKey", null);
|
||||
}
|
||||
|
||||
// Migration helper for env-based users: copy OPENROUTER_API_KEY -> LLM_API_KEY
|
||||
// at runtime so the app keeps working after removing fallback logic.
|
||||
if (
|
||||
(effectiveProvider ?? "openrouter") === "openrouter" &&
|
||||
!normalizeEnvInput(process.env.LLM_API_KEY) &&
|
||||
normalizeEnvInput(process.env.OPENROUTER_API_KEY)
|
||||
) {
|
||||
console.warn(
|
||||
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY for compatibility.",
|
||||
);
|
||||
const normalizedKey = normalizeEnvInput(process.env.OPENROUTER_API_KEY);
|
||||
if (normalizedKey) process.env.LLM_API_KEY = normalizedKey;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...readableStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await safeGetSetting(settingKey);
|
||||
@ -207,12 +152,6 @@ export async function getEnvSettingsData(
|
||||
privateValues[hintKey] = rawValue.slice(0, hintLength);
|
||||
}
|
||||
|
||||
// Backwards-compat: old clients still expect openrouterApiKeyHint.
|
||||
// Always prefer the unified LLM key hint when present.
|
||||
if (privateValues.llmApiKeyHint) {
|
||||
privateValues.openrouterApiKeyHint = privateValues.llmApiKeyHint;
|
||||
}
|
||||
|
||||
const basicAuthUser =
|
||||
activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER;
|
||||
const basicAuthPassword =
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
export * from "./crawler";
|
||||
export * from "./jobspy";
|
||||
export * from "./notion";
|
||||
export * from "./pdf";
|
||||
export * from "./profile";
|
||||
export * from "./scorer";
|
||||
export * from "./summary";
|
||||
@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Service for syncing with Notion.
|
||||
*/
|
||||
|
||||
export interface NotionSyncResult {
|
||||
success: boolean;
|
||||
pageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job entry in Notion.
|
||||
*
|
||||
* This is a placeholder - implement based on your Notion setup.
|
||||
*/
|
||||
export async function createNotionEntry(job: {
|
||||
id: string;
|
||||
title: string;
|
||||
employer: string;
|
||||
applicationLink: string | null;
|
||||
deadline: string | null;
|
||||
salary: string | null;
|
||||
location: string | null;
|
||||
pdfPath: string | null;
|
||||
appliedAt: string;
|
||||
}): Promise<NotionSyncResult> {
|
||||
const notionApiKey = process.env.NOTION_API_KEY;
|
||||
const notionDatabaseId = process.env.NOTION_DATABASE_ID;
|
||||
|
||||
if (!notionApiKey || !notionDatabaseId) {
|
||||
console.log("ℹ️ Notion API not configured, skipping sync");
|
||||
return { success: true, pageId: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.notion.com/v1/pages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${notionApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent: { database_id: notionDatabaseId },
|
||||
properties: {
|
||||
// Customize these based on your Notion database schema
|
||||
Name: {
|
||||
title: [{ text: { content: `${job.title} @ ${job.employer}` } }],
|
||||
},
|
||||
Company: {
|
||||
rich_text: [{ text: { content: job.employer } }],
|
||||
},
|
||||
"Application Link": job.applicationLink
|
||||
? {
|
||||
url: job.applicationLink,
|
||||
}
|
||||
: undefined,
|
||||
Deadline: job.deadline
|
||||
? {
|
||||
date: { start: job.deadline },
|
||||
}
|
||||
: undefined,
|
||||
Salary: job.salary
|
||||
? {
|
||||
rich_text: [{ text: { content: job.salary } }],
|
||||
}
|
||||
: undefined,
|
||||
Location: job.location
|
||||
? {
|
||||
rich_text: [{ text: { content: job.location } }],
|
||||
}
|
||||
: undefined,
|
||||
"Applied Date": {
|
||||
date: { start: job.appliedAt },
|
||||
},
|
||||
Status: {
|
||||
status: { name: "Applied" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Notion API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(`✅ Created Notion entry: ${data.id}`);
|
||||
return { success: true, pageId: data.id };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`❌ Notion sync failed: ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
* Tests for scorer.ts - focusing on robust JSON parsing from AI responses
|
||||
*/
|
||||
|
||||
import type { Job } from "@shared/types";
|
||||
import { createJob } from "@shared/testing/factories";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseJsonFromContent } from "./scorer";
|
||||
|
||||
@ -254,72 +254,6 @@ This score reflects the candidate's technical capabilities while accounting for
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create minimal test job
|
||||
function createTestJob(overrides: Partial<Job> = {}): Job {
|
||||
return {
|
||||
id: "test-job-1",
|
||||
source: "gradcracker",
|
||||
sourceJobId: "ext-1",
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Software Engineer",
|
||||
employer: "Test Company",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: null,
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "A test job",
|
||||
status: "discovered",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: new Date().toISOString(),
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("salary penalty", () => {
|
||||
let getEffectiveSettingsMock: ReturnType<typeof vi.fn>;
|
||||
let getSettingMock: ReturnType<typeof vi.fn>;
|
||||
@ -365,7 +299,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(70); // 80 - 10
|
||||
@ -388,7 +326,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: "" });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: "",
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(70);
|
||||
@ -409,7 +351,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: " " });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: " ",
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(70);
|
||||
@ -430,7 +376,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: "Competitive" });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: "Competitive",
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(80); // No penalty
|
||||
@ -451,7 +401,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: "£40,000 - £50,000" });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: "£40,000 - £50,000",
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(80); // No penalty
|
||||
@ -474,7 +428,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(80); // No penalty when disabled
|
||||
@ -495,7 +453,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 50, reason: "Average match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(0); // Clamped, not negative
|
||||
@ -516,7 +478,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 5, reason: "Weak match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(0); // 5 - 10 = -5, clamped to 0
|
||||
@ -537,7 +503,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 80, reason: "Good match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(80); // No change with 0 penalty
|
||||
@ -558,7 +528,11 @@ describe("salary penalty", () => {
|
||||
data: { score: 90, reason: "Excellent match" },
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.score).toBe(65); // 90 - 25
|
||||
@ -584,7 +558,11 @@ describe("salary penalty", () => {
|
||||
error: "API key not configured",
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
// Mock score base is 50, with keyword bonuses from "Software Engineer"
|
||||
@ -607,7 +585,11 @@ describe("salary penalty", () => {
|
||||
error: "API key not configured",
|
||||
});
|
||||
|
||||
const job = createTestJob({ salary: null });
|
||||
const job = createJob({
|
||||
id: "test-job-1",
|
||||
salary: null,
|
||||
title: "Software Engineer",
|
||||
});
|
||||
const result = await scoreJobSuitability(job, {});
|
||||
|
||||
expect(result.reason).not.toContain("missing salary");
|
||||
|
||||
@ -26,13 +26,13 @@ describe("settings-conversion", () => {
|
||||
});
|
||||
|
||||
it("round-trips boolean bit settings", () => {
|
||||
expect(serializeSettingValue("jobspyIsRemote", true)).toBe("1");
|
||||
expect(serializeSettingValue("jobspyIsRemote", false)).toBe("0");
|
||||
expect(serializeSettingValue("showSponsorInfo", true)).toBe("1");
|
||||
expect(serializeSettingValue("showSponsorInfo", false)).toBe("0");
|
||||
|
||||
expect(resolveSettingValue("jobspyIsRemote", "1").value).toBe(true);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "0").value).toBe(false);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "true").value).toBe(true);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "false").value).toBe(false);
|
||||
expect(resolveSettingValue("showSponsorInfo", "1").value).toBe(true);
|
||||
expect(resolveSettingValue("showSponsorInfo", "0").value).toBe(false);
|
||||
expect(resolveSettingValue("showSponsorInfo", "true").value).toBe(true);
|
||||
expect(resolveSettingValue("showSponsorInfo", "false").value).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips JSON array settings", () => {
|
||||
@ -79,26 +79,6 @@ describe("settings-conversion", () => {
|
||||
expect(malformedOverride.value).toEqual(["web developer"]);
|
||||
});
|
||||
|
||||
it("always includes glassdoor in resolved jobspySites", () => {
|
||||
delete process.env.JOBSPY_SITES;
|
||||
expect(resolveSettingValue("jobspySites", undefined).value).toEqual([
|
||||
"indeed",
|
||||
"linkedin",
|
||||
"glassdoor",
|
||||
]);
|
||||
|
||||
process.env.JOBSPY_SITES = "indeed,linkedin";
|
||||
expect(resolveSettingValue("jobspySites", undefined).value).toEqual([
|
||||
"indeed",
|
||||
"linkedin",
|
||||
"glassdoor",
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveSettingValue("jobspySites", JSON.stringify(["linkedin"])).value,
|
||||
).toEqual(["linkedin", "glassdoor"]);
|
||||
});
|
||||
|
||||
it("round-trips penalizeMissingSalary boolean setting", () => {
|
||||
expect(serializeSettingValue("penalizeMissingSalary", true)).toBe("1");
|
||||
expect(serializeSettingValue("penalizeMissingSalary", false)).toBe("0");
|
||||
|
||||
@ -11,11 +11,7 @@ type SettingsConversionValueMap = {
|
||||
searchTerms: string[];
|
||||
jobspyLocation: string;
|
||||
jobspyResultsWanted: number;
|
||||
jobspyHoursOld: number;
|
||||
jobspyCountryIndeed: string;
|
||||
jobspySites: string[];
|
||||
jobspyLinkedinFetchDescription: boolean;
|
||||
jobspyIsRemote: boolean;
|
||||
showSponsorInfo: boolean;
|
||||
backupEnabled: boolean;
|
||||
backupHour: number;
|
||||
@ -57,24 +53,6 @@ function parseJsonArrayOrNull(raw: string | undefined): string[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeJobspySites(value: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const site of value) {
|
||||
const trimmed = site.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
if (!seen.has("glassdoor")) {
|
||||
normalized.push("glassdoor");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseBitBoolOrNull(raw: string | undefined): boolean | null {
|
||||
if (!raw) return null;
|
||||
return raw === "true" || raw === "1";
|
||||
@ -147,41 +125,12 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyHoursOld: {
|
||||
defaultValue: () => parseInt(process.env.JOBSPY_HOURS_OLD || "72", 10),
|
||||
parseOverride: parseIntOrNull,
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK",
|
||||
parseOverride: (raw) => raw ?? null,
|
||||
serialize: (value) => value ?? null,
|
||||
resolve: resolveWithEmptyStringFallback,
|
||||
},
|
||||
jobspySites: {
|
||||
defaultValue: () =>
|
||||
normalizeJobspySites(
|
||||
(process.env.JOBSPY_SITES || "indeed,linkedin,glassdoor").split(","),
|
||||
),
|
||||
parseOverride: parseJsonArrayOrNull,
|
||||
serialize: serializeNullableJsonArray,
|
||||
resolve: ({ defaultValue, overrideValue }) =>
|
||||
normalizeJobspySites(overrideValue ?? defaultValue),
|
||||
},
|
||||
jobspyLinkedinFetchDescription: {
|
||||
defaultValue: () =>
|
||||
(process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1",
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyIsRemote: {
|
||||
defaultValue: () => (process.env.JOBSPY_IS_REMOTE || "0") === "1",
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
showSponsorInfo: {
|
||||
defaultValue: () => true,
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
|
||||
@ -36,17 +36,15 @@ describe("applySettingsUpdates", () => {
|
||||
model: "gpt-4o-mini",
|
||||
ukvisajobsMaxJobs: 42,
|
||||
searchTerms: ["backend", "platform"],
|
||||
jobspyIsRemote: true,
|
||||
llmProvider: "openai",
|
||||
});
|
||||
|
||||
expect(settingsRepo.setSetting).toHaveBeenCalledTimes(5);
|
||||
expect(settingsRepo.setSetting).toHaveBeenCalledTimes(4);
|
||||
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
["model", "gpt-4o-mini"],
|
||||
["ukvisajobsMaxJobs", "42"],
|
||||
["searchTerms", '["backend","platform"]'],
|
||||
["jobspyIsRemote", "1"],
|
||||
["llmProvider", "openai"],
|
||||
]),
|
||||
);
|
||||
@ -57,33 +55,6 @@ describe("applySettingsUpdates", () => {
|
||||
expect(plan.shouldRefreshBackupScheduler).toBe(false);
|
||||
});
|
||||
|
||||
it("handles deprecated openrouterApiKey migration path", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const envSettings = await import("@server/services/envSettings");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await applySettingsUpdates({
|
||||
openrouterApiKey: " legacy-key ",
|
||||
});
|
||||
|
||||
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
["llmApiKey", "legacy-key"],
|
||||
["openrouterApiKey", null],
|
||||
]),
|
||||
);
|
||||
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
|
||||
"LLM_API_KEY",
|
||||
"legacy-key",
|
||||
);
|
||||
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
|
||||
"OPENROUTER_API_KEY",
|
||||
null,
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks backup scheduler refresh when backup settings are changed", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ export type {
|
||||
} from "./registry";
|
||||
export {
|
||||
settingsUpdateRegistry,
|
||||
toBitBoolOrNull,
|
||||
toJsonOrNull,
|
||||
toNormalizedStringOrNull,
|
||||
toNumberStringOrNull,
|
||||
|
||||
@ -55,12 +55,6 @@ export function toJsonOrNull<T>(value: T | null | undefined): string | null {
|
||||
return value !== null && value !== undefined ? JSON.stringify(value) : null;
|
||||
}
|
||||
|
||||
export function toBitBoolOrNull(
|
||||
value: boolean | null | undefined,
|
||||
): string | null {
|
||||
return serializeSettingValue("jobspyIsRemote", value);
|
||||
}
|
||||
|
||||
function result(
|
||||
args: {
|
||||
actions?: SettingsUpdateAction[];
|
||||
@ -186,48 +180,14 @@ export const settingsUpdateRegistry: Partial<{
|
||||
actions: [metadataPersistAction("jobspyResultsWanted", value)],
|
||||
}),
|
||||
),
|
||||
jobspyHoursOld: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyHoursOld", value)],
|
||||
}),
|
||||
),
|
||||
jobspyCountryIndeed: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspyCountryIndeed", value)] }),
|
||||
),
|
||||
jobspySites: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspySites", value)] }),
|
||||
),
|
||||
jobspyLinkedinFetchDescription: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyLinkedinFetchDescription", value)],
|
||||
}),
|
||||
),
|
||||
jobspyIsRemote: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyIsRemote", value)],
|
||||
}),
|
||||
),
|
||||
showSponsorInfo: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("showSponsorInfo", value)],
|
||||
}),
|
||||
),
|
||||
openrouterApiKey: singleAction(({ value }) => {
|
||||
console.warn(
|
||||
"[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.",
|
||||
);
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmApiKey", normalized, () => {
|
||||
applyEnvValue("LLM_API_KEY", normalized);
|
||||
}),
|
||||
persistAction("openrouterApiKey", null, () => {
|
||||
applyEnvValue("OPENROUTER_API_KEY", null);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
llmApiKey: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
|
||||
@ -130,14 +130,6 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrideJobspyResultsWanted = jobspyResultsWantedSetting.overrideValue;
|
||||
const jobspyResultsWanted = jobspyResultsWantedSetting.value;
|
||||
|
||||
const jobspyHoursOldSetting = resolveSettingValue(
|
||||
"jobspyHoursOld",
|
||||
overrides.jobspyHoursOld,
|
||||
);
|
||||
const defaultJobspyHoursOld = jobspyHoursOldSetting.defaultValue;
|
||||
const overrideJobspyHoursOld = jobspyHoursOldSetting.overrideValue;
|
||||
const jobspyHoursOld = jobspyHoursOldSetting.value;
|
||||
|
||||
const jobspyCountryIndeedSetting = resolveSettingValue(
|
||||
"jobspyCountryIndeed",
|
||||
overrides.jobspyCountryIndeed,
|
||||
@ -146,33 +138,6 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrideJobspyCountryIndeed = jobspyCountryIndeedSetting.overrideValue;
|
||||
const jobspyCountryIndeed = jobspyCountryIndeedSetting.value;
|
||||
|
||||
const jobspySitesSetting = resolveSettingValue(
|
||||
"jobspySites",
|
||||
overrides.jobspySites,
|
||||
);
|
||||
const defaultJobspySites = jobspySitesSetting.defaultValue;
|
||||
const overrideJobspySites = jobspySitesSetting.overrideValue;
|
||||
const jobspySites = jobspySitesSetting.value;
|
||||
|
||||
const jobspyLinkedinFetchDescriptionSetting = resolveSettingValue(
|
||||
"jobspyLinkedinFetchDescription",
|
||||
overrides.jobspyLinkedinFetchDescription,
|
||||
);
|
||||
const defaultJobspyLinkedinFetchDescription =
|
||||
jobspyLinkedinFetchDescriptionSetting.defaultValue;
|
||||
const overrideJobspyLinkedinFetchDescription =
|
||||
jobspyLinkedinFetchDescriptionSetting.overrideValue;
|
||||
const jobspyLinkedinFetchDescription =
|
||||
jobspyLinkedinFetchDescriptionSetting.value;
|
||||
|
||||
const jobspyIsRemoteSetting = resolveSettingValue(
|
||||
"jobspyIsRemote",
|
||||
overrides.jobspyIsRemote,
|
||||
);
|
||||
const defaultJobspyIsRemote = jobspyIsRemoteSetting.defaultValue;
|
||||
const overrideJobspyIsRemote = jobspyIsRemoteSetting.overrideValue;
|
||||
const jobspyIsRemote = jobspyIsRemoteSetting.value;
|
||||
|
||||
const showSponsorInfoSetting = resolveSettingValue(
|
||||
"showSponsorInfo",
|
||||
overrides.showSponsorInfo,
|
||||
@ -274,21 +239,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
jobspyHoursOld,
|
||||
defaultJobspyHoursOld,
|
||||
overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed,
|
||||
defaultJobspyCountryIndeed,
|
||||
overrideJobspyCountryIndeed,
|
||||
jobspySites,
|
||||
defaultJobspySites,
|
||||
overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
jobspyIsRemote,
|
||||
defaultJobspyIsRemote,
|
||||
overrideJobspyIsRemote,
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
|
||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,9 +12,13 @@
|
||||
"check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types",
|
||||
"check:all": "./orchestrator/node_modules/.bin/biome ci .",
|
||||
"format:all": "./orchestrator/node_modules/.bin/biome format . --write",
|
||||
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types"
|
||||
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types",
|
||||
"knip": "knip"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.2"
|
||||
"@types/node": "^25.2.3",
|
||||
"knip": "^5.83.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,18 +52,8 @@ export const updateSettingsSchema = z
|
||||
.max(1000)
|
||||
.nullable()
|
||||
.optional(),
|
||||
jobspyHoursOld: z.number().int().min(1).max(720).nullable().optional(),
|
||||
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
|
||||
jobspySites: z
|
||||
.array(z.string().trim().min(1).max(50))
|
||||
.max(20)
|
||||
.nullable()
|
||||
.optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
jobspyIsRemote: z.boolean().nullable().optional(),
|
||||
showSponsorInfo: z.boolean().nullable().optional(),
|
||||
/** @deprecated Use llmApiKey instead. */
|
||||
openrouterApiKey: z.string().trim().max(2000).nullable().optional(),
|
||||
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
|
||||
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
|
||||
basicAuthUser: z.string().trim().max(200).nullable().optional(),
|
||||
|
||||
210
shared/src/testing/factories.ts
Normal file
210
shared/src/testing/factories.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import type {
|
||||
ApplicationTask,
|
||||
AppSettings,
|
||||
Job,
|
||||
PipelineRun,
|
||||
ResumeProjectCatalogItem,
|
||||
StageEvent,
|
||||
} from "../types";
|
||||
|
||||
export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: "https://example.com/apply",
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "California",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Job description content",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: "Strong fit",
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createStageEvent = (
|
||||
overrides: Partial<StageEvent> = {},
|
||||
): StageEvent => ({
|
||||
id: "event-1",
|
||||
applicationId: "job-1",
|
||||
title: "Moved to applied",
|
||||
groupId: null,
|
||||
fromStage: null,
|
||||
toStage: "applied",
|
||||
occurredAt: Date.now(),
|
||||
metadata: null,
|
||||
outcome: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createApplicationTask = (
|
||||
overrides: Partial<ApplicationTask> = {},
|
||||
): ApplicationTask => ({
|
||||
id: "task-1",
|
||||
applicationId: "job-1",
|
||||
type: "todo",
|
||||
title: "Follow up",
|
||||
dueDate: null,
|
||||
isCompleted: false,
|
||||
notes: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createPipelineRun = (
|
||||
overrides: Partial<PipelineRun> = {},
|
||||
): PipelineRun => ({
|
||||
id: "run-1",
|
||||
startedAt: "2025-01-01T00:00:00Z",
|
||||
completedAt: null,
|
||||
status: "running",
|
||||
jobsDiscovered: 0,
|
||||
jobsProcessed: 0,
|
||||
errorMessage: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createResumeProjectCatalogItem = (
|
||||
overrides: Partial<ResumeProjectCatalogItem> = {},
|
||||
): ResumeProjectCatalogItem => ({
|
||||
id: "p1",
|
||||
name: "Project 1",
|
||||
description: "Description 1",
|
||||
date: "2024",
|
||||
isVisibleInBase: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createAppSettings = (
|
||||
overrides: Partial<AppSettings> = {},
|
||||
): AppSettings => ({
|
||||
model: "gpt-4o",
|
||||
defaultModel: "gpt-4o",
|
||||
overrideModel: null,
|
||||
modelScorer: "gpt-4o",
|
||||
overrideModelScorer: null,
|
||||
modelTailoring: "gpt-4o",
|
||||
overrideModelTailoring: null,
|
||||
modelProjectSelection: "gpt-4o",
|
||||
overrideModelProjectSelection: null,
|
||||
llmProvider: "openai",
|
||||
defaultLlmProvider: "openai",
|
||||
overrideLlmProvider: null,
|
||||
llmBaseUrl: "https://api.openai.com/v1",
|
||||
defaultLlmBaseUrl: "https://api.openai.com/v1",
|
||||
overrideLlmBaseUrl: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
overridePipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: "",
|
||||
defaultJobCompleteWebhookUrl: "",
|
||||
overrideJobCompleteWebhookUrl: null,
|
||||
profileProjects: [],
|
||||
resumeProjects: {
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: [],
|
||||
},
|
||||
defaultResumeProjects: {
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: [],
|
||||
},
|
||||
overrideResumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: 50,
|
||||
defaultUkvisajobsMaxJobs: 50,
|
||||
overrideUkvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: 50,
|
||||
defaultGradcrackerMaxJobsPerTerm: 50,
|
||||
overrideGradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: ["Software Engineer"],
|
||||
defaultSearchTerms: ["Software Engineer"],
|
||||
overrideSearchTerms: null,
|
||||
jobspyLocation: "United Kingdom",
|
||||
defaultJobspyLocation: "United Kingdom",
|
||||
overrideJobspyLocation: null,
|
||||
jobspyResultsWanted: 20,
|
||||
defaultJobspyResultsWanted: 20,
|
||||
overrideJobspyResultsWanted: null,
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
defaultJobspyCountryIndeed: "united kingdom",
|
||||
overrideJobspyCountryIndeed: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
llmApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPasswordHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
backupEnabled: false,
|
||||
defaultBackupEnabled: false,
|
||||
overrideBackupEnabled: null,
|
||||
backupHour: 3,
|
||||
defaultBackupHour: 3,
|
||||
overrideBackupHour: null,
|
||||
backupMaxCount: 7,
|
||||
defaultBackupMaxCount: 7,
|
||||
overrideBackupMaxCount: null,
|
||||
penalizeMissingSalary: false,
|
||||
defaultPenalizeMissingSalary: false,
|
||||
overridePenalizeMissingSalary: null,
|
||||
missingSalaryPenalty: 10,
|
||||
defaultMissingSalaryPenalty: 10,
|
||||
overrideMissingSalaryPenalty: null,
|
||||
autoSkipScoreThreshold: null,
|
||||
defaultAutoSkipScoreThreshold: null,
|
||||
overrideAutoSkipScoreThreshold: null,
|
||||
...overrides,
|
||||
});
|
||||
@ -6,7 +6,7 @@ export type JobStatus =
|
||||
| "discovered" // Crawled but not processed
|
||||
| "processing" // Currently generating resume
|
||||
| "ready" // PDF generated, waiting for user to apply
|
||||
| "applied" // User marked as applied (added to Notion)
|
||||
| "applied" // User marked as applied
|
||||
| "skipped" // User skipped this job
|
||||
| "expired"; // Deadline passed
|
||||
|
||||
@ -160,7 +160,6 @@ export interface Job {
|
||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
notionPageId: string | null; // Notion page ID if synced
|
||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
@ -314,7 +313,6 @@ export interface UpdateJobInput {
|
||||
tailoredSkills?: string;
|
||||
selectedProjectIds?: string;
|
||||
pdfPath?: string;
|
||||
notionPageId?: string;
|
||||
appliedAt?: string;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
@ -601,27 +599,13 @@ export interface AppSettings {
|
||||
jobspyResultsWanted: number;
|
||||
defaultJobspyResultsWanted: number;
|
||||
overrideJobspyResultsWanted: number | null;
|
||||
jobspyHoursOld: number;
|
||||
defaultJobspyHoursOld: number;
|
||||
overrideJobspyHoursOld: number | null;
|
||||
jobspyCountryIndeed: string;
|
||||
defaultJobspyCountryIndeed: string;
|
||||
overrideJobspyCountryIndeed: string | null;
|
||||
jobspySites: string[];
|
||||
defaultJobspySites: string[];
|
||||
overrideJobspySites: string[] | null;
|
||||
jobspyLinkedinFetchDescription: boolean;
|
||||
defaultJobspyLinkedinFetchDescription: boolean;
|
||||
overrideJobspyLinkedinFetchDescription: boolean | null;
|
||||
jobspyIsRemote: boolean;
|
||||
defaultJobspyIsRemote: boolean;
|
||||
overrideJobspyIsRemote: boolean | null;
|
||||
showSponsorInfo: boolean;
|
||||
defaultShowSponsorInfo: boolean;
|
||||
overrideShowSponsorInfo: boolean | null;
|
||||
llmApiKeyHint: string | null;
|
||||
/** @deprecated Use llmApiKeyHint instead. */
|
||||
openrouterApiKeyHint: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumePasswordHint: string | null;
|
||||
basicAuthUser: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user