Merge pull request #45 from DaKheera47/rerun-fit-assessment-and-scoring
Rerun fit assessment and scoring
This commit is contained in:
commit
e3e70f5b60
@ -85,6 +85,12 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rescoreJob(id: string): Promise<Job> {
|
||||||
|
return fetchApi<Job>(`/jobs/${id}/rescore`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
|
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
|
||||||
const query = options?.force ? '?force=1' : '';
|
const query = options?.force ? '?force=1' : '';
|
||||||
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
|
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
|
||||||
|
|||||||
142
orchestrator/src/client/components/ReadyPanel.test.tsx
Normal file
142
orchestrator/src/client/components/ReadyPanel.test.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { ReadyPanel } from "./ReadyPanel";
|
||||||
|
import type { Job } from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
|
return {
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../hooks/useProfile", () => ({
|
||||||
|
useProfile: () => ({ personName: "Test" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useSettings", () => ({
|
||||||
|
useSettings: () => ({ showSponsorInfo: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api", () => ({
|
||||||
|
rescoreJob: vi.fn(),
|
||||||
|
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||||
|
markAsApplied: vi.fn(),
|
||||||
|
generateJobPdf: vi.fn(),
|
||||||
|
checkSponsor: vi.fn(),
|
||||||
|
skipJob: vi.fn(),
|
||||||
|
updateJob: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
message: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReadyPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs the fit assessment from the menu", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const job = createJob();
|
||||||
|
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReadyPanel
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -46,6 +46,7 @@ import * as api from "../api";
|
|||||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||||
import { useProfile } from "../hooks/useProfile";
|
import { useProfile } from "../hooks/useProfile";
|
||||||
|
import { useRescoreJob } from "../hooks/useRescoreJob";
|
||||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||||
|
|
||||||
type PanelMode = "ready" | "tailor";
|
type PanelMode = "ready" | "tailor";
|
||||||
@ -67,6 +68,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const [mode, setMode] = useState<PanelMode>("ready");
|
const [mode, setMode] = useState<PanelMode>("ready");
|
||||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
const [recentlyApplied, setRecentlyApplied] = useState<{
|
const [recentlyApplied, setRecentlyApplied] = useState<{
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@ -181,6 +183,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [job, onJobUpdated]);
|
}, [job, onJobUpdated]);
|
||||||
|
|
||||||
|
const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]);
|
||||||
|
|
||||||
const handleSkip = useCallback(async () => {
|
const handleSkip = useCallback(async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
@ -385,6 +389,14 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={handleRescore}
|
||||||
|
disabled={isRescoring}
|
||||||
|
>
|
||||||
|
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
|
||||||
|
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{/* Utility actions */}
|
{/* Utility actions */}
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react";
|
import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
||||||
@ -14,6 +20,8 @@ interface DecideModeProps {
|
|||||||
onTailor: () => void;
|
onTailor: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
isSkipping: boolean;
|
isSkipping: boolean;
|
||||||
|
onRescore: () => void;
|
||||||
|
isRescoring: boolean;
|
||||||
onCheckSponsor?: () => Promise<void>;
|
onCheckSponsor?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,6 +30,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
onTailor,
|
onTailor,
|
||||||
onSkip,
|
onSkip,
|
||||||
isSkipping,
|
isSkipping,
|
||||||
|
onRescore,
|
||||||
|
isRescoring,
|
||||||
onCheckSponsor,
|
onCheckSponsor,
|
||||||
}) => {
|
}) => {
|
||||||
const [showDescription, setShowDescription] = useState(false);
|
const [showDescription, setShowDescription] = useState(false);
|
||||||
@ -87,7 +97,26 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
|
|
||||||
<Separator className='opacity-40' />
|
<Separator className='opacity-40' />
|
||||||
|
|
||||||
<div className='pt-6 pb-2'>
|
<div className='pt-4 pb-2 space-y-4'>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 gap-2 text-xs text-muted-foreground hover:text-foreground justify-center"
|
||||||
|
>
|
||||||
|
More actions
|
||||||
|
<ChevronUp className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
|
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
||||||
|
<RefreshCcw className={isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"} />
|
||||||
|
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{jobLink ? (
|
{jobLink ? (
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<a
|
<a
|
||||||
@ -105,4 +134,3 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { DiscoveredPanel } from "./DiscoveredPanel";
|
||||||
|
import type { Job } from "../../../shared/types";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
|
return {
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useSettings: () => ({ showSponsorInfo: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
rescoreJob: vi.fn(),
|
||||||
|
skipJob: vi.fn(),
|
||||||
|
processJob: vi.fn(),
|
||||||
|
checkSponsor: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
message: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DiscoveredPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs the fit assessment from the menu", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const job = createJob();
|
||||||
|
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DiscoveredPanel
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import { DecideMode } from "./DecideMode";
|
|||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { ProcessingState } from "./ProcessingState";
|
import { ProcessingState } from "./ProcessingState";
|
||||||
import { TailorMode } from "./TailorMode";
|
import { TailorMode } from "./TailorMode";
|
||||||
|
import { useRescoreJob } from "../../hooks/useRescoreJob";
|
||||||
|
|
||||||
type PanelMode = "decide" | "tailor";
|
type PanelMode = "decide" | "tailor";
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
const [mode, setMode] = useState<PanelMode>("decide");
|
const [mode, setMode] = useState<PanelMode>("decide");
|
||||||
const [isSkipping, setIsSkipping] = useState(false);
|
const [isSkipping, setIsSkipping] = useState(false);
|
||||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||||
|
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMode("decide");
|
setMode("decide");
|
||||||
@ -69,6 +71,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRescore = () => rescoreJob(job?.id);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return <EmptyState />;
|
return <EmptyState />;
|
||||||
}
|
}
|
||||||
@ -85,6 +89,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
onTailor={() => setMode("tailor")}
|
onTailor={() => setMode("tailor")}
|
||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
isSkipping={isSkipping}
|
isSkipping={isSkipping}
|
||||||
|
onRescore={handleRescore}
|
||||||
|
isRescoring={isRescoring}
|
||||||
onCheckSponsor={async () => {
|
onCheckSponsor={async () => {
|
||||||
await api.checkSponsor(job.id);
|
await api.checkSponsor(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
|
|||||||
38
orchestrator/src/client/hooks/useRescoreJob.test.ts
Normal file
38
orchestrator/src/client/hooks/useRescoreJob.test.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useRescoreJob } from "./useRescoreJob";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
vi.mock("../api", () => ({
|
||||||
|
rescoreJob: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useRescoreJob", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rescoring updates the job and shows a toast", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRescoreJob(onJobUpdated));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.rescoreJob("job-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.rescoreJob).toHaveBeenCalledWith("job-1");
|
||||||
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
|
||||||
|
});
|
||||||
|
});
|
||||||
29
orchestrator/src/client/hooks/useRescoreJob.ts
Normal file
29
orchestrator/src/client/hooks/useRescoreJob.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import * as api from "../api";
|
||||||
|
|
||||||
|
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||||
|
const [isRescoring, setIsRescoring] = useState(false);
|
||||||
|
|
||||||
|
const rescoreJob = useCallback(
|
||||||
|
async (jobId?: string | null) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRescoring(true);
|
||||||
|
await api.rescoreJob(jobId);
|
||||||
|
toast.success("Match recalculated");
|
||||||
|
await onJobUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to recalculate match";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsRescoring(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onJobUpdated],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isRescoring, rescoreJob };
|
||||||
|
}
|
||||||
@ -96,6 +96,32 @@ describe.sequential('Jobs API routes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rescoring a job updates the suitability fields', async () => {
|
||||||
|
const { createJob } = await import('../../repositories/jobs.js');
|
||||||
|
const { scoreJobSuitability } = await import('../../services/scorer.js');
|
||||||
|
const { getProfile } = await import('../../services/profile.js');
|
||||||
|
|
||||||
|
vi.mocked(getProfile).mockResolvedValue({});
|
||||||
|
vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 77, reason: 'Updated fit' });
|
||||||
|
|
||||||
|
const job = await createJob({
|
||||||
|
source: 'manual',
|
||||||
|
title: 'Test Role',
|
||||||
|
employer: 'Acme',
|
||||||
|
jobUrl: 'https://example.com/job/5',
|
||||||
|
jobDescription: 'Test description',
|
||||||
|
suitabilityScore: 55,
|
||||||
|
suitabilityReason: 'Old fit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/rescore`, { method: 'POST' });
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.suitabilityScore).toBe(77);
|
||||||
|
expect(body.data.suitabilityReason).toBe('Updated fit');
|
||||||
|
});
|
||||||
|
|
||||||
it('checks visa sponsor status for a job', async () => {
|
it('checks visa sponsor status for a job', async () => {
|
||||||
const { searchSponsors } = await import('../../services/visa-sponsors/index.js');
|
const { searchSponsors } = await import('../../services/visa-sponsors/index.js');
|
||||||
vi.mocked(searchSponsors).mockReturnValue([
|
vi.mocked(searchSponsors).mockReturnValue([
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import * as jobsRepo from '../../repositories/jobs.js';
|
|||||||
import * as settingsRepo from '../../repositories/settings.js';
|
import * as settingsRepo from '../../repositories/settings.js';
|
||||||
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
|
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
|
||||||
import { createNotionEntry } from '../../services/notion.js';
|
import { createNotionEntry } from '../../services/notion.js';
|
||||||
|
import { scoreJobSuitability } from '../../services/scorer.js';
|
||||||
|
import { getProfile } from '../../services/profile.js';
|
||||||
import * as visaSponsors from '../../services/visa-sponsors/index.js';
|
import * as visaSponsors from '../../services/visa-sponsors/index.js';
|
||||||
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
@ -139,6 +141,40 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/rescore - Regenerate suitability score + reason
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/rescore', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawProfile = await getProfile();
|
||||||
|
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid resume profile format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { score, reason } = await scoreJobSuitability(job, rawProfile as Record<string, unknown>);
|
||||||
|
|
||||||
|
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||||
|
suitabilityScore: score,
|
||||||
|
suitabilityReason: reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedJob) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: updatedJob });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
|
* POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -45,6 +45,10 @@ vi.mock('../../services/scorer.js', () => ({
|
|||||||
scoreJobSuitability: vi.fn(),
|
scoreJobSuitability: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/profile.js', () => ({
|
||||||
|
getProfile: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/ukvisajobs.js', () => ({
|
vi.mock('../../services/ukvisajobs.js', () => ({
|
||||||
fetchUkVisaJobsPage: vi.fn(),
|
fetchUkVisaJobsPage: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user