import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { scoreJobSuitability } from './scorer.js'; import { pickProjectIdsForJob } from './projectSelection.js'; import type { Job } from '../../shared/types.js'; // --- Mocks --- // 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']); }); }); });