289 lines
8.0 KiB
TypeScript
289 lines
8.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Job } from "../../shared/types.js";
|
|
import { pickProjectIdsForJob } from "./projectSelection.js";
|
|
import { scoreJobSuitability } from "./scorer.js";
|
|
|
|
// --- Mocks ---
|
|
vi.mock("../repositories/settings.js", () => ({
|
|
getSetting: vi.fn().mockResolvedValue(null),
|
|
getAllSettings: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
// 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 = {
|
|
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",
|
|
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" };
|
|
|
|
describe("AI Service Resilience", () => {
|
|
beforeEach(() => {
|
|
global.fetch = vi.fn();
|
|
process.env.OPENROUTER_API_KEY = "mock-key"; // Ensure logic tries to call API
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = globalFetch;
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("scoreJobSuitability (Scorer)", () => {
|
|
it("should return parsed score when API returns valid JSON", async () => {
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify({ score: 85, reason: "Great match" }),
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
|
|
const result = await scoreJobSuitability(mockJob, mockProfile);
|
|
|
|
expect(result.score).toBe(85);
|
|
expect(result.reason).toBe("Great match");
|
|
});
|
|
|
|
it("should fallback to mock scoring if API Key is missing", async () => {
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
|
|
// Should NOT call fetch
|
|
const result = await scoreJobSuitability(mockJob, mockProfile);
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
// Mock score logic gives 50 + points for keywords.
|
|
// 'TypeScript' and 'React' are in JD (5+5) -> 60?
|
|
// "Senior" is bad keyword (-10)? -> 50?
|
|
// Let's just check it didn't crash and returned a number
|
|
expect(typeof result.score).toBe("number");
|
|
expect(result.reason).toContain("keyword matching");
|
|
});
|
|
|
|
it("should handle API 500/400 errors gracefully (fallback)", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: "Internal Server Error",
|
|
} as any);
|
|
|
|
// Spy on console.error to keep test output clean
|
|
const consoleSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
|
|
const result = await scoreJobSuitability(mockJob, mockProfile);
|
|
|
|
expect(result.score).toBeDefined(); // Fallback score
|
|
expect(result.reason).toContain("keyword matching"); // Fallback reason
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle Malformed/Invalid JSON in API response", async () => {
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{ message: { content: "This is not JSON at all, just text." } },
|
|
],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const result = await scoreJobSuitability(mockJob, mockProfile);
|
|
|
|
expect(result.reason).toContain("keyword matching"); // Fell back
|
|
});
|
|
|
|
it("should extract JSON from markdown code blocks", async () => {
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
'Here is the score: ```json\n{ "score": 90, "reason": "Good" }\n```',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
|
|
const result = await scoreJobSuitability(mockJob, mockProfile);
|
|
expect(result.score).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe("pickProjectIdsForJob (Project Selection)", () => {
|
|
const mockProjects = [
|
|
{
|
|
id: "p1",
|
|
name: "React App",
|
|
description: "Used React",
|
|
date: "2022",
|
|
summaryText: "React stuff",
|
|
isVisibleInBase: true,
|
|
},
|
|
{
|
|
id: "p2",
|
|
name: "Python Script",
|
|
description: "Used Python",
|
|
date: "2023",
|
|
summaryText: "Python stuff",
|
|
isVisibleInBase: true,
|
|
},
|
|
];
|
|
|
|
it("should return projects selected by AI", async () => {
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify({ selectedProjectIds: ["p1"] }),
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
|
|
const result = await pickProjectIdsForJob({
|
|
jobDescription: "React dev",
|
|
eligibleProjects: mockProjects,
|
|
desiredCount: 1,
|
|
});
|
|
|
|
expect(result).toEqual(["p1"]);
|
|
});
|
|
|
|
it("should fallback if API fails", async () => {
|
|
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
|
|
|
const result = await pickProjectIdsForJob({
|
|
jobDescription: "React dev", // Should match p1 due to keyword 'React'
|
|
eligibleProjects: mockProjects,
|
|
desiredCount: 1,
|
|
});
|
|
|
|
// It should fall back to keyword matching
|
|
// p1 has 'React', p2 has 'Python'. 'React dev' matches p1.
|
|
expect(result).toEqual(["p1"]);
|
|
});
|
|
|
|
it("should fallback if AI returns garbage", async () => {
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: "No valid JSON here" } }],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
|
|
const result = await pickProjectIdsForJob({
|
|
jobDescription: "Python dev", // Should match p2
|
|
eligibleProjects: mockProjects,
|
|
desiredCount: 1,
|
|
});
|
|
|
|
expect(result).toEqual(["p2"]);
|
|
});
|
|
|
|
it("should validate returned IDs exist in eligible list", async () => {
|
|
// AI returns an ID that doesn't exist ('p999')
|
|
const mockResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify({ selectedProjectIds: ["p999", "p1"] }),
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
|
|
|
const result = await pickProjectIdsForJob({
|
|
jobDescription: "stuff",
|
|
eligibleProjects: mockProjects,
|
|
desiredCount: 2,
|
|
});
|
|
|
|
// Should strip p999 and only return p1
|
|
expect(result).toEqual(["p1"]);
|
|
});
|
|
});
|
|
});
|