tests
This commit is contained in:
parent
ba27eb07b1
commit
6c3bb681d1
403
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
403
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Tests for sponsor match calculation logic in the pipeline orchestrator.
|
||||
*
|
||||
* These tests verify that during job scoring, the sponsor matching functionality
|
||||
* correctly calculates and stores sponsor match scores and names.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Job } from '../../shared/types.js';
|
||||
|
||||
// Mock the visa-sponsors module
|
||||
vi.mock('../services/visa-sponsors/index.js', () => ({
|
||||
searchSponsors: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the scorer module
|
||||
vi.mock('../services/scorer.js', () => ({
|
||||
scoreJobSuitability: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the jobs repository
|
||||
vi.mock('../repositories/jobs.js', () => ({
|
||||
updateJob: vi.fn(),
|
||||
getUnscoredDiscoveredJobs: vi.fn(),
|
||||
getJobById: vi.fn(),
|
||||
bulkCreateJobs: vi.fn(),
|
||||
getAllJobUrls: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies to prevent side effects
|
||||
vi.mock('../repositories/pipeline.js', () => ({
|
||||
createPipelineRun: vi.fn(() => ({ id: 'test-run-id' })),
|
||||
updatePipelineRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../services/crawler.js', () => ({
|
||||
runCrawler: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/jobspy.js', () => ({
|
||||
runJobSpy: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ukvisajobs.js', () => ({
|
||||
runUkVisaJobs: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
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',
|
||||
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,
|
||||
});
|
||||
|
||||
describe('Sponsor Match Calculation', () => {
|
||||
let searchSponsors: ReturnType<typeof vi.fn>;
|
||||
let scoreJobSuitability: ReturnType<typeof vi.fn>;
|
||||
let updateJob: ReturnType<typeof vi.fn>;
|
||||
let getUnscoredDiscoveredJobs: ReturnType<typeof vi.fn>;
|
||||
let bulkCreateJobs: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get mocked functions
|
||||
const visaSponsors = await import('../services/visa-sponsors/index.js');
|
||||
const scorer = await import('../services/scorer.js');
|
||||
const jobsRepo = await import('../repositories/jobs.js');
|
||||
|
||||
searchSponsors = visaSponsors.searchSponsors as ReturnType<typeof vi.fn>;
|
||||
scoreJobSuitability = scorer.scoreJobSuitability as ReturnType<typeof vi.fn>;
|
||||
updateJob = jobsRepo.updateJob as ReturnType<typeof vi.fn>;
|
||||
getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType<typeof vi.fn>;
|
||||
bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Default mock implementations
|
||||
scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' });
|
||||
bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 });
|
||||
updateJob.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('searchSponsors integration', () => {
|
||||
it('should calculate sponsor match score when employer matches a sponsor', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Acme Corporation Ltd' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning a match
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'ACME CORPORATION LIMITED' },
|
||||
score: 85,
|
||||
matchedName: 'acme corporation',
|
||||
},
|
||||
]);
|
||||
|
||||
// Import and run pipeline
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify searchSponsors was called with correct parameters
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Acme Corporation Ltd', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
// Verify updateJob was called with sponsor match data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
sponsorMatchScore: 85,
|
||||
sponsorMatchNames: JSON.stringify(['ACME CORPORATION LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 100% perfect matches correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Microsoft UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning perfect matches
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LTD' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'microsoft',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should include up to 2 perfect matches
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify([
|
||||
'MICROSOFT UK LIMITED',
|
||||
'MICROSOFT UK LTD',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should report single top match when no perfect matches exist', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Tech Corp' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning partial matches only
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'TECH CORPORATION' },
|
||||
score: 75,
|
||||
matchedName: 'tech corporation',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'TECHNO CORP' },
|
||||
score: 60,
|
||||
matchedName: 'techno corp',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should only include the top match since none are 100%
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 75,
|
||||
sponsorMatchNames: JSON.stringify(['TECH CORPORATION']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set sponsor match when no matches found', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Unknown Company XYZ' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning no matches
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// sponsorMatchScore should be 0 (not set) and sponsorMatchNames undefined
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
|
||||
// Verify that sponsorMatchScore is 0 and sponsorMatchNames is not included
|
||||
// when there are no matches
|
||||
const updateCall = updateJob.mock.calls[0][1];
|
||||
expect(updateCall.sponsorMatchScore).toBe(0);
|
||||
expect(updateCall.sponsorMatchNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has no employer', async () => {
|
||||
const mockJob = createMockJob({ employer: null as unknown as string });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
|
||||
// updateJob should still be called but without sponsor data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has empty employer string', async () => {
|
||||
const mockJob = createMockJob({ employer: '' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called for empty string
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsor match edge cases', () => {
|
||||
it('should use correct limit and minScore options', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Test Company' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Test Company', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single 100% match correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Google UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'GOOGLE UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'google uk',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Single perfect match should be reported
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify(['GOOGLE UK LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should process multiple jobs with different sponsor matches', async () => {
|
||||
const mockJob1 = createMockJob({
|
||||
id: 'job-1',
|
||||
employer: 'Amazon UK',
|
||||
});
|
||||
const mockJob2 = createMockJob({
|
||||
id: 'job-2',
|
||||
employer: 'Meta Platforms',
|
||||
});
|
||||
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob1, mockJob2]);
|
||||
|
||||
// Different results for each employer
|
||||
searchSponsors
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'AMAZON UK SERVICES LTD' },
|
||||
score: 90,
|
||||
matchedName: 'amazon uk',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'META PLATFORMS IRELAND LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'meta platforms',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify both jobs were processed with different sponsor data
|
||||
expect(updateJob).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 90,
|
||||
sponsorMatchNames: JSON.stringify(['AMAZON UK SERVICES LTD']),
|
||||
})
|
||||
);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-2',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 80,
|
||||
sponsorMatchNames: JSON.stringify(['META PLATFORMS IRELAND LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user