diff --git a/.env.example b/.env.example index 541d740..98729b4 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,13 @@ SUPABASE_STORAGE_BUCKET=applypilot-assets OPENAI_API_KEY= OPENAI_MODEL=gpt-4.1-mini + +# Optional paid job aggregation. Leave blank to disable resume-triggered search. +ADZUNA_APP_ID= +ADZUNA_APP_KEY= +ADZUNA_COUNTRY=sg +ADZUNA_BASE_URL=https://api.adzuna.com/v1/api + APPLYPILOT_LOCAL_STORE_PATH= ENABLE_DEMO_DATA=false VITE_DASHBOARD_URL=http://localhost:3000 diff --git a/apps/web/src/app/api/jobs/score/route.ts b/apps/web/src/app/api/jobs/score/route.ts index 833eb39..34b5a26 100644 --- a/apps/web/src/app/api/jobs/score/route.ts +++ b/apps/web/src/app/api/jobs/score/route.ts @@ -1,15 +1,44 @@ import type { JobPosting } from '@applypilot/domain'; import { withAuthenticatedRoute } from '../../_lib'; +import { isAiConfigured } from '@/server/services/ai'; import { scoreJobForCandidate } from '@/server/services/app-service'; +import { recordUsage } from '@/server/services/usage-meter'; export async function POST(request: Request) { return withAuthenticatedRoute(request, async ({ candidateId }) => { const body = (await request.json()) as Partial; - return scoreJobForCandidate({ + const result = await scoreJobForCandidate({ candidateId, jobInput: body, }); + + recordUsage({ + candidateId, + eventType: 'job_score', + provider: 'openai', + aiCallCount: isAiConfigured() ? 1 : 0, + metadata: { + jobPostingId: result.job.id, + fallbackMode: !isAiConfigured(), + }, + }); + + if (result.tailoredResume) { + recordUsage({ + candidateId, + eventType: 'tailored_resume_generation', + provider: 'openai', + aiCallCount: isAiConfigured() ? 1 : 0, + metadata: { + jobPostingId: result.job.id, + resumeId: result.tailoredResume.baseResumeId, + fallbackMode: !isAiConfigured(), + }, + }); + } + + return result; }); } diff --git a/apps/web/src/app/api/jobs/search-from-resume/route.ts b/apps/web/src/app/api/jobs/search-from-resume/route.ts new file mode 100644 index 0000000..3d61606 --- /dev/null +++ b/apps/web/src/app/api/jobs/search-from-resume/route.ts @@ -0,0 +1,20 @@ +import { withAuthenticatedRoute } from '../../_lib'; +import { searchJobsFromResume } from '@/server/services/resume-job-search'; + +export async function POST(request: Request) { + return withAuthenticatedRoute(request, async ({ candidateId, store }) => { + const body = (await request.json().catch(() => ({}))) as { + resumeId?: string; + limit?: number; + }; + + const search = await searchJobsFromResume({ + candidateId, + resumeId: body.resumeId, + limit: body.limit, + store, + }); + + return { search }; + }); +} diff --git a/apps/web/src/app/api/profile/parse/route.ts b/apps/web/src/app/api/profile/parse/route.ts index 11c8b64..19e034f 100644 --- a/apps/web/src/app/api/profile/parse/route.ts +++ b/apps/web/src/app/api/profile/parse/route.ts @@ -1,5 +1,7 @@ import { withAuthenticatedRoute } from '../../_lib'; +import { isAiConfigured } from '@/server/services/ai'; import { parseProfileFromResume } from '@/server/services/app-service'; +import { recordUsage } from '@/server/services/usage-meter'; export async function POST(request: Request) { return withAuthenticatedRoute(request, async ({ candidateId }) => { @@ -14,6 +16,17 @@ export async function POST(request: Request) { resumeId: body.resumeId, }); + recordUsage({ + candidateId, + eventType: 'profile_parse', + provider: 'openai', + aiCallCount: isAiConfigured() ? 1 : 0, + metadata: { + resumeId: body.resumeId, + fallbackMode: !isAiConfigured(), + }, + }); + return { profile }; }); } diff --git a/apps/web/src/components/resume-upload-form.tsx b/apps/web/src/components/resume-upload-form.tsx index 703b91d..6f7eaa8 100644 --- a/apps/web/src/components/resume-upload-form.tsx +++ b/apps/web/src/components/resume-upload-form.tsx @@ -52,7 +52,44 @@ export function ResumeUploadForm() { return; } - setStatus('Resume uploaded and profile parsed.'); + setStatus('Searching matching jobs...'); + const searchResponse = await fetch('/api/jobs/search-from-resume', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resumeId: uploaded.resume.id, + }), + }); + + if (!searchResponse.ok) { + setStatus('Resume uploaded and profile parsed. Save preferences to search matching jobs.'); + router.refresh(); + return; + } + + const searchResult = (await searchResponse.json()) as { + search: { + enabled: boolean; + savedCount: number; + disabledReason?: string; + }; + }; + + if (!searchResult.search.enabled) { + setStatus( + 'Resume uploaded and profile parsed. Job search is disabled until Adzuna API credentials are configured.', + ); + router.refresh(); + return; + } + + setStatus( + searchResult.search.savedCount > 0 + ? `Resume uploaded and ${searchResult.search.savedCount} matching jobs added to the tracker.` + : 'Resume uploaded and parsed. No matching jobs returned yet.', + ); router.refresh(); }; diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts index 733877f..9ad936f 100644 --- a/apps/web/src/lib/env.ts +++ b/apps/web/src/lib/env.ts @@ -15,3 +15,5 @@ export const hasSupabaseServiceRoleConfig = Boolean( env.NEXT_PUBLIC_SUPABASE_URL && (env.SUPABASE_SECRET_KEY || env.SUPABASE_SERVICE_ROLE_KEY), ); + +export const hasAdzunaConfig = Boolean(env.ADZUNA_APP_ID && env.ADZUNA_APP_KEY); diff --git a/apps/web/src/server/services/ai.ts b/apps/web/src/server/services/ai.ts index 0360eec..5fd99a6 100644 --- a/apps/web/src/server/services/ai.ts +++ b/apps/web/src/server/services/ai.ts @@ -23,6 +23,8 @@ const createOpenAIClient = () => { }); }; +export const isAiConfigured = () => Boolean(env.OPENAI_API_KEY); + const parseJsonContent = (value: string | null | undefined, fallback: T) => { if (!value) { return fallback; diff --git a/apps/web/src/server/services/job-aggregator.ts b/apps/web/src/server/services/job-aggregator.ts new file mode 100644 index 0000000..7620e5c --- /dev/null +++ b/apps/web/src/server/services/job-aggregator.ts @@ -0,0 +1,198 @@ +import { z } from 'zod'; + +import type { JobPosting } from '@applypilot/domain'; + +import { env, hasAdzunaConfig } from '@/lib/env'; +import { slugify } from '@/lib/utils'; + +export type JobAggregatorConfig = { + appId?: string | null; + appKey?: string | null; + country?: string | null; + baseUrl?: string | null; + fetchImpl?: typeof fetch; +}; + +type AdzunaSearchInput = { + query: string; + location?: string | null; + limit?: number; + page?: number; + config?: JobAggregatorConfig; +}; + +const adzunaCompanySchema = z.object({ + display_name: z.string().optional(), +}); + +const adzunaLocationSchema = z.object({ + display_name: z.string().optional(), +}); + +const adzunaJobSchema = z.object({ + id: z.union([z.string(), z.number()]), + title: z.string().optional(), + company: adzunaCompanySchema.optional(), + location: adzunaLocationSchema.optional(), + redirect_url: z.string().url().optional(), + description: z.string().optional(), + contract_time: z.string().nullable().optional(), + contract_type: z.string().nullable().optional(), + salary_min: z.number().nullable().optional(), + salary_max: z.number().nullable().optional(), + created: z.string().optional(), +}); + +const adzunaSearchResponseSchema = z.object({ + count: z.number().default(0), + results: z.array(adzunaJobSchema).default([]), +}); + +const clampLimit = (limit: number | undefined) => + Math.max(1, Math.min(50, Math.floor(limit ?? 20))); + +const cleanDescription = (value: string | undefined) => + (value ?? '') + .replace(/<[^>]*>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\s+/g, ' ') + .trim(); + +const normalizeTimestamp = (value: string | undefined) => { + const parsed = value ? new Date(value) : null; + + return parsed && !Number.isNaN(parsed.getTime()) + ? parsed.toISOString() + : new Date().toISOString(); +}; + +const formatSalaryText = ({ + min, + max, +}: { + min?: number | null; + max?: number | null; +}) => { + if (typeof min !== 'number' && typeof max !== 'number') { + return null; + } + + if (typeof min === 'number' && typeof max === 'number') { + return `Estimated salary ${Math.round(min)} - ${Math.round(max)}`; + } + + return `Estimated salary ${Math.round((min ?? max) as number)}`; +}; + +const formatEmploymentType = ({ + contractTime, + contractType, +}: { + contractTime?: string | null; + contractType?: string | null; +}) => + [contractTime, contractType] + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => value.replace(/_/g, ' ')) + .join(', ') || null; + +const resolveAdzunaConfig = (config?: JobAggregatorConfig) => ({ + appId: config?.appId ?? env.ADZUNA_APP_ID ?? null, + appKey: config?.appKey ?? env.ADZUNA_APP_KEY ?? null, + country: (config?.country ?? env.ADZUNA_COUNTRY).toLowerCase(), + baseUrl: (config?.baseUrl ?? env.ADZUNA_BASE_URL).replace(/\/+$/, ''), + fetchImpl: config?.fetchImpl ?? fetch, +}); + +export const isAdzunaSearchConfigured = (config?: JobAggregatorConfig) => { + if (config) { + return Boolean(config.appId && config.appKey); + } + + return hasAdzunaConfig; +}; + +export const mapAdzunaJobToPosting = ( + job: z.infer, +): JobPosting => { + const externalJobId = String(job.id); + + return { + id: `adzuna_${slugify(externalJobId)}`, + source: 'adzuna', + externalJobId, + title: job.title?.trim() || 'Untitled role', + company: job.company?.display_name?.trim() || 'Unknown company', + location: job.location?.display_name?.trim() || '', + salaryText: formatSalaryText({ + min: job.salary_min, + max: job.salary_max, + }), + employmentType: formatEmploymentType({ + contractTime: job.contract_time, + contractType: job.contract_type, + }), + url: job.redirect_url ?? 'https://www.adzuna.com/', + description: cleanDescription(job.description), + easyApply: false, + detectedQuestions: [], + scrapedAt: normalizeTimestamp(job.created), + }; +}; + +export const searchAdzunaJobs = async ({ + query, + location, + limit, + page = 1, + config, +}: AdzunaSearchInput) => { + const resolved = resolveAdzunaConfig(config); + + if (!isAdzunaSearchConfigured(config)) { + return { + enabled: false as const, + provider: 'adzuna' as const, + attribution: 'Jobs by Adzuna', + disabledReason: 'missing_api_key' as const, + jobs: [], + totalCount: 0, + }; + } + + const url = new URL( + `${resolved.baseUrl}/jobs/${resolved.country}/search/${Math.max(1, page)}`, + ); + url.searchParams.set('app_id', resolved.appId ?? ''); + url.searchParams.set('app_key', resolved.appKey ?? ''); + url.searchParams.set('what', query); + url.searchParams.set('results_per_page', String(clampLimit(limit))); + url.searchParams.set('content-type', 'application/json'); + + if (location?.trim()) { + url.searchParams.set('where', location.trim()); + } + + const response = await resolved.fetchImpl(url, { + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Adzuna search failed with HTTP ${response.status}.`); + } + + const payload = adzunaSearchResponseSchema.parse(await response.json()); + + return { + enabled: true as const, + provider: 'adzuna' as const, + attribution: 'Jobs by Adzuna', + jobs: payload.results.map(mapAdzunaJobToPosting), + totalCount: payload.count, + }; +}; diff --git a/apps/web/src/server/services/resume-job-search.ts b/apps/web/src/server/services/resume-job-search.ts new file mode 100644 index 0000000..87b3b82 --- /dev/null +++ b/apps/web/src/server/services/resume-job-search.ts @@ -0,0 +1,346 @@ +import { + type CandidateProfile, + type JobPosting, + type JobPreference, + type MatchScore, + type ResumeVersion, + scoreJobAgainstPreferences, + sourcePlatformSchema, +} from '@applypilot/domain'; + +import { slugify } from '@/lib/utils'; + +import { isAiConfigured, parseCandidateProfileWithAi } from './ai'; +import { saveManualJob } from './app-service'; +import { + isAdzunaSearchConfigured, + searchAdzunaJobs, + type JobAggregatorConfig, +} from './job-aggregator'; +import type { AppStore } from './store'; +import { recordUsage } from './usage-meter'; + +const SEARCH_QUERY_STOPWORDS = new Set([ + 'and', + 'for', + 'the', + 'with', + 'from', + 'that', + 'this', + 'have', + 'has', + 'years', + 'experience', + 'manager', + 'lead', + 'senior', +]); + +const uniqueStrings = (items: string[]) => { + const seen = new Set(); + const unique: string[] = []; + + items.forEach((item) => { + const normalized = item.trim(); + const key = normalized.toLowerCase(); + + if (!normalized || seen.has(key)) { + return; + } + + seen.add(key); + unique.push(normalized); + }); + + return unique; +}; + +const extractResumeSearchTerms = (resumeText: string) => + uniqueStrings( + resumeText + .match(/\b[A-Za-z][A-Za-z0-9+#.-]{2,}\b/g) + ?.map((term) => term.trim()) + .filter((term) => !SEARCH_QUERY_STOPWORDS.has(term.toLowerCase())) ?? [], + ).slice(0, 6); + +const resolveSearchLimit = (limit: unknown, fallback: number) => { + const parsed = typeof limit === 'number' ? limit : Number(limit); + + if (!Number.isFinite(parsed)) { + return Math.max(1, Math.min(25, fallback)); + } + + return Math.max(1, Math.min(25, Math.floor(parsed))); +}; + +const buildResumeJobSearchQuery = ({ + profile, + preference, + resume, +}: { + profile: CandidateProfile; + preference: JobPreference; + resume: ResumeVersion; +}) => { + const targetRole = + preference.targetRoles[0] ?? profile.targetRoles[0] ?? 'Product Manager'; + const terms = uniqueStrings([ + targetRole, + ...preference.keywords.slice(0, 4), + ...preference.industries.slice(0, 2), + ...profile.skills.slice(0, 4), + ...extractResumeSearchTerms(resume.textContent), + preference.remotePolicy === 'remote' ? 'remote' : '', + ]).slice(0, 10); + + return terms.join(' '); +}; + +const buildResumeJobSearchLocation = ({ + profile, + preference, +}: { + profile: CandidateProfile; + preference: JobPreference; +}) => { + const preferredRegion = preference.regions.find( + (region) => !/remote|anywhere/i.test(region), + ); + + return preferredRegion ?? profile.location ?? ''; +}; + +const scopeJobRecordToCandidate = ( + job: JobPosting, + candidateId: string, +): JobPosting => ({ + ...job, + id: `${job.id}_${slugify(candidateId)}`, +}); + +const ensureProfileFromResume = async ({ + candidateId, + resume, + store, +}: { + candidateId: string; + resume: ResumeVersion; + store: AppStore; +}) => { + const existingProfile = await store.getProfile(candidateId); + + if (existingProfile && resume.parsedProfileId === existingProfile.id) { + return existingProfile; + } + + const profile = await parseCandidateProfileWithAi({ + candidateId, + resumeText: resume.textContent, + }); + + recordUsage({ + candidateId, + eventType: 'profile_parse', + provider: 'openai', + aiCallCount: isAiConfigured() ? 1 : 0, + metadata: { + resumeId: resume.id, + triggeredBy: 'resume_job_search', + fallbackMode: !isAiConfigured(), + }, + }); + + await store.upsertProfile(profile); + await store.saveResume({ + ...resume, + parsedProfileId: profile.id, + }); + + return profile; +}; + +const saveRankedJob = async ({ + candidateId, + job, + profile, + preference, + store, +}: { + candidateId: string; + job: JobPosting; + profile: CandidateProfile; + preference: JobPreference; + store: AppStore; +}) => { + const source = sourcePlatformSchema.parse(job.source); + const savedJob = await saveManualJob({ + candidateId, + input: { + source, + title: job.title, + company: job.company, + location: job.location, + salaryText: job.salaryText ?? undefined, + employmentType: job.employmentType ?? undefined, + url: job.url, + description: job.description, + easyApply: job.easyApply, + }, + }); + const score = scoreJobAgainstPreferences(profile, preference, savedJob); + + await store.saveMatchScore(score); + + return { + job: savedJob, + score, + }; +}; + +export const searchJobsFromResume = async ({ + candidateId, + resumeId, + limit, + aggregatorConfig, + store, +}: { + candidateId: string; + resumeId?: string | null; + limit?: number; + aggregatorConfig?: JobAggregatorConfig; + store: AppStore; +}) => { + const [resumes, preference] = await Promise.all([ + store.listResumes(candidateId), + store.getPreferences(candidateId), + ]); + const resume = resumeId + ? resumes.find((item) => item.id === resumeId) + : resumes[0]; + + if (!resume) { + throw new Error('Upload a resume before searching jobs.'); + } + + if (!preference) { + throw new Error('Save job preferences before searching jobs.'); + } + + if (!isAdzunaSearchConfigured(aggregatorConfig)) { + recordUsage({ + candidateId, + eventType: 'resume_job_search_disabled', + provider: 'adzuna', + metadata: { + reason: 'missing_api_key', + resumeId: resume.id, + }, + }); + + return { + enabled: false, + provider: 'adzuna' as const, + disabledReason: 'missing_api_key' as const, + query: '', + location: '', + fetchedCount: 0, + savedCount: 0, + savedJobs: [], + }; + } + + const profile = await ensureProfileFromResume({ + candidateId, + resume, + store, + }); + const searchLimit = resolveSearchLimit( + limit, + Math.min(preference.dailyTarget, 10), + ); + const query = buildResumeJobSearchQuery({ + profile, + preference, + resume, + }); + const location = buildResumeJobSearchLocation({ + profile, + preference, + }); + + recordUsage({ + candidateId, + eventType: 'resume_job_search', + provider: 'adzuna', + searchCount: 1, + metadata: { + queryLength: query.length, + hasLocation: location.length > 0, + requestedLimit: searchLimit, + }, + }); + const searchResult = await searchAdzunaJobs({ + query, + location, + limit: Math.min(50, Math.max(searchLimit * 2, searchLimit)), + config: aggregatorConfig, + }); + + if (!searchResult.enabled) { + return { + enabled: false, + provider: searchResult.provider, + disabledReason: searchResult.disabledReason, + query, + location, + fetchedCount: 0, + savedCount: 0, + savedJobs: [], + }; + } + + const scoredJobs = searchResult.jobs + .map((job) => { + const scopedJob = scopeJobRecordToCandidate(job, candidateId); + const score = scoreJobAgainstPreferences(profile, preference, scopedJob); + + return { + job, + score, + }; + }) + .sort((left, right) => { + if (right.score.overall !== left.score.overall) { + return right.score.overall - left.score.overall; + } + + return right.job.scrapedAt.localeCompare(left.job.scrapedAt); + }) + .slice(0, searchLimit); + + const savedJobs: Array<{ job: JobPosting; score: MatchScore }> = []; + + for (const { job } of scoredJobs) { + savedJobs.push( + await saveRankedJob({ + candidateId, + job, + profile, + preference, + store, + }), + ); + } + + return { + enabled: true, + provider: searchResult.provider, + attribution: searchResult.attribution, + query, + location, + fetchedCount: searchResult.jobs.length, + savedCount: savedJobs.length, + savedJobs, + }; +}; diff --git a/apps/web/src/server/services/usage-meter.ts b/apps/web/src/server/services/usage-meter.ts new file mode 100644 index 0000000..fb53dd7 --- /dev/null +++ b/apps/web/src/server/services/usage-meter.ts @@ -0,0 +1,30 @@ +export type UsageMeterEventInput = { + candidateId: string; + eventType: string; + provider?: string | null; + searchCount?: number; + aiCallCount?: number; + metadata?: Record; +}; + +export const recordUsage = ({ + candidateId, + eventType, + provider = null, + searchCount = 0, + aiCallCount = 0, + metadata = {}, +}: UsageMeterEventInput) => { + console.info( + '[usage-meter]', + JSON.stringify({ + candidateId, + eventType, + provider, + searchCount, + aiCallCount, + metadata, + createdAt: new Date().toISOString(), + }), + ); +}; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 480af6a..c54ed07 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,16 +1,40 @@ import { z } from 'zod'; +const emptyStringToUndefined = (value: unknown) => + typeof value === 'string' && value.trim() === '' ? undefined : value; + +const optionalString = z.preprocess( + emptyStringToUndefined, + z.string().trim().optional(), +); + +const optionalUrl = z.preprocess( + emptyStringToUndefined, + z.string().url().optional(), +); + export const webEnvSchema = z.object({ NEXT_PUBLIC_APP_URL: z.string().url().default('http://localhost:3000'), - NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(), - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().optional(), - NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(), - SUPABASE_SECRET_KEY: z.string().optional(), - SUPABASE_SERVICE_ROLE_KEY: z.string().optional(), + NEXT_PUBLIC_SUPABASE_URL: optionalUrl, + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: optionalString, + NEXT_PUBLIC_SUPABASE_ANON_KEY: optionalString, + SUPABASE_SECRET_KEY: optionalString, + SUPABASE_SERVICE_ROLE_KEY: optionalString, SUPABASE_STORAGE_BUCKET: z.string().default('applypilot-assets'), - OPENAI_API_KEY: z.string().optional(), + OPENAI_API_KEY: optionalString, OPENAI_MODEL: z.string().default('gpt-4.1-mini'), - APPLYPILOT_LOCAL_STORE_PATH: z.string().optional(), + ADZUNA_APP_ID: optionalString, + ADZUNA_APP_KEY: optionalString, + ADZUNA_COUNTRY: z + .preprocess(emptyStringToUndefined, z.string().min(2).max(2).default('sg')) + .transform((value) => value.toLowerCase()), + ADZUNA_BASE_URL: z + .preprocess( + emptyStringToUndefined, + z.string().url().default('https://api.adzuna.com/v1/api'), + ) + .transform((value) => value.replace(/\/+$/, '')), + APPLYPILOT_LOCAL_STORE_PATH: optionalString, ENABLE_DEMO_DATA: z.string().optional().transform((value) => value !== 'false'), }); diff --git a/packages/domain/src/schemas.ts b/packages/domain/src/schemas.ts index f60c197..511a61e 100644 --- a/packages/domain/src/schemas.ts +++ b/packages/domain/src/schemas.ts @@ -4,6 +4,7 @@ export const sourcePlatformSchema = z.enum([ 'linkedin', 'mycareersfuture', 'greenhouse', + 'adzuna', ]); export type SourcePlatform = z.infer; diff --git a/tests/app-service.test.ts b/tests/app-service.test.ts index 09fb9ac..f102a72 100644 --- a/tests/app-service.test.ts +++ b/tests/app-service.test.ts @@ -15,6 +15,7 @@ import { saveManualJob, updateApplicationStatus, } from '../apps/web/src/server/services/app-service'; +import { searchJobsFromResume } from '../apps/web/src/server/services/resume-job-search'; import { store } from '../apps/web/src/server/services/store'; describe('app service saved jobs and material search', () => { @@ -22,6 +23,7 @@ describe('app service saved jobs and material search', () => { const otherCandidateId = 'app-service-test-other-user'; afterEach(async () => { + vi.restoreAllMocks(); await store.clearCandidateData(candidateId); await store.clearCandidateData(otherCandidateId); }); @@ -244,6 +246,151 @@ describe('app service saved jobs and material search', () => { }); }); + it('searches Adzuna jobs from a resume, ranks them, and syncs tracker items', async () => { + await store.clearCandidateData(candidateId); + await store.upsertProfile({ + ...demoCandidateProfile, + id: candidateId, + }); + await store.upsertPreferences({ + ...demoPreferences, + candidateId, + targetRoles: ['Product Manager'], + keywords: ['workflow automation', 'analytics'], + regions: ['Singapore'], + easyApplyOnly: false, + }); + await store.saveResume({ + ...demoResume, + id: 'resume_app_service_adzuna_search', + candidateId, + parsedProfileId: candidateId, + }); + + const usageSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const fetchMock = vi.fn(async () => + Response.json({ + count: 2, + results: [ + { + id: 'adzuna-low-fit', + title: 'Office Coordinator', + company: { + display_name: 'Admin Co', + }, + location: { + display_name: 'Singapore', + }, + redirect_url: 'https://www.adzuna.sg/details/low', + description: 'Coordinate office supplies and facilities.', + created: '2026-06-28T01:00:00Z', + }, + { + id: 'adzuna-high-fit', + title: 'Product Manager, Workflow Automation', + company: { + display_name: 'Workflow Co', + }, + location: { + display_name: 'Singapore', + }, + redirect_url: 'https://www.adzuna.sg/details/high', + description: + 'Own workflow automation, analytics dashboards, and product growth loops.', + created: '2026-06-28T02:00:00Z', + }, + ], + }), + ); + + const result = await searchJobsFromResume({ + candidateId, + resumeId: 'resume_app_service_adzuna_search', + limit: 1, + aggregatorConfig: { + appId: 'test-app', + appKey: 'test-key', + country: 'sg', + baseUrl: 'https://api.adzuna.test/v1/api', + fetchImpl: fetchMock as typeof fetch, + }, + store, + }); + + expect(result.enabled).toBe(true); + expect(result.savedCount).toBe(1); + expect(result.savedJobs[0]?.job.company).toBe('Workflow Co'); + + const attempts = await store.listAttempts(candidateId); + const jobs = await store.listJobs(candidateId); + const usageEvents = usageSpy.mock.calls + .filter(([label]) => label === '[usage-meter]') + .map(([, payload]) => JSON.parse(String(payload))); + + expect(attempts).toHaveLength(1); + expect(jobs.some((job) => job.company === 'Workflow Co')).toBe(true); + expect(jobs.some((job) => job.company === 'Admin Co')).toBe(false); + expect(usageEvents[0]).toMatchObject({ + candidateId, + eventType: 'resume_job_search', + provider: 'adzuna', + searchCount: 1, + aiCallCount: 0, + }); + }); + + it('records a disabled search usage event when Adzuna credentials are missing', async () => { + await store.clearCandidateData(candidateId); + await store.upsertProfile({ + ...demoCandidateProfile, + id: candidateId, + }); + await store.upsertPreferences({ + ...demoPreferences, + candidateId, + }); + await store.saveResume({ + ...demoResume, + id: 'resume_app_service_adzuna_disabled', + candidateId, + parsedProfileId: candidateId, + }); + const usageSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const fetchMock = vi.fn(); + + const result = await searchJobsFromResume({ + candidateId, + resumeId: 'resume_app_service_adzuna_disabled', + aggregatorConfig: { + appId: '', + appKey: '', + fetchImpl: fetchMock as typeof fetch, + }, + store, + }); + + const attempts = await store.listAttempts(candidateId); + const usageEvents = usageSpy.mock.calls + .filter(([label]) => label === '[usage-meter]') + .map(([, payload]) => JSON.parse(String(payload))); + + expect(result).toMatchObject({ + enabled: false, + provider: 'adzuna', + disabledReason: 'missing_api_key', + savedCount: 0, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(attempts).toHaveLength(0); + expect(usageEvents[0]).toMatchObject({ + candidateId, + eventType: 'resume_job_search_disabled', + provider: 'adzuna', + searchCount: 0, + aiCallCount: 0, + }); + }); + it('retrieves resume material for a scored job', () => { const job = demoJobs[0]; const score = scoreJobAgainstPreferences( diff --git a/tests/job-aggregator.test.ts b/tests/job-aggregator.test.ts new file mode 100644 index 0000000..3e92838 --- /dev/null +++ b/tests/job-aggregator.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { searchAdzunaJobs } from '../apps/web/src/server/services/job-aggregator'; + +describe('Adzuna job aggregator', () => { + it('maps mocked Adzuna search results into job postings', async () => { + const fetchMock = vi.fn(async () => + Response.json({ + count: 2, + results: [ + { + id: '123', + title: 'Product Manager, Workflow Automation', + company: { + display_name: 'Workflow Co', + }, + location: { + display_name: 'Singapore', + }, + redirect_url: 'https://www.adzuna.sg/details/123', + description: '

Own workflow automation and analytics.

', + contract_time: 'full_time', + contract_type: 'permanent', + salary_min: 120000, + salary_max: 150000, + created: '2026-06-28T01:00:00Z', + }, + ], + }), + ); + + const result = await searchAdzunaJobs({ + query: 'Product Manager workflow automation', + location: 'Singapore', + limit: 5, + config: { + appId: 'test-app', + appKey: 'test-key', + country: 'sg', + baseUrl: 'https://api.adzuna.test/v1/api', + fetchImpl: fetchMock as typeof fetch, + }, + }); + const requestedUrl = fetchMock.mock.calls[0]?.[0] as URL; + + expect(result.enabled).toBe(true); + expect(requestedUrl.toString()).toContain('/jobs/sg/search/1'); + expect(requestedUrl.searchParams.get('app_id')).toBe('test-app'); + expect(requestedUrl.searchParams.get('app_key')).toBe('test-key'); + expect(requestedUrl.searchParams.get('what')).toBe( + 'Product Manager workflow automation', + ); + expect(requestedUrl.searchParams.get('where')).toBe('Singapore'); + expect(requestedUrl.searchParams.get('results_per_page')).toBe('5'); + expect(result.jobs[0]).toMatchObject({ + id: 'adzuna_123', + source: 'adzuna', + externalJobId: '123', + title: 'Product Manager, Workflow Automation', + company: 'Workflow Co', + location: 'Singapore', + employmentType: 'full time, permanent', + salaryText: 'Estimated salary 120000 - 150000', + description: 'Own workflow automation and analytics.', + easyApply: false, + }); + }); + + it('disables search without API credentials and does not call fetch', async () => { + const fetchMock = vi.fn(); + + const result = await searchAdzunaJobs({ + query: 'Product Manager', + config: { + appId: '', + appKey: '', + fetchImpl: fetchMock as typeof fetch, + }, + }); + + expect(result).toMatchObject({ + enabled: false, + provider: 'adzuna', + disabledReason: 'missing_api_key', + jobs: [], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});