HR-ATS-Portal/frontend/cvbank.test.mjs

191 lines
7.4 KiB
JavaScript

/**
* CV Bank mapper — speculative vs silver medalist, ATS score, suggested jobs,
* and the per-row job the last ATS ran against.
*
* node cvbank.test.mjs
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
const outDir = mkdtempSync(join(tmpdir(), 'tf-bank-'))
const outFile = join(outDir, 'candidates.mjs')
await esbuild.build({
entryPoints: ['src/api/candidates.js'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
logLevel: 'error',
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
})
const { toBankRowView, expiryLabel } = await import(pathToFileURL(outFile).href)
let failed = 0
function ok(name, cond, extra) {
if (cond) {
console.log(`ok ${name}`)
} else {
failed += 1
console.log(`FAIL ${name}`)
if (extra) console.log(` ${extra}`)
}
}
/* --- a speculative upload: extracted, never scored ------------------------ */
const speculative = toBankRowView({
id: 'bank:11111111-1111-1111-1111-111111111111',
record_id: '11111111-1111-1111-1111-111111111111',
bank_source: 'speculative',
name: 'Ada Lovelace',
email: 'ada@example.com',
phone: '0321-5551234',
file_name: 'ada.pdf',
file_path: 'https://s3/Temp/x/ada.pdf',
current_company: 'Acme',
current_position: 'Backend Engineer',
education: 'BS CS',
skills: ['Python', 'FastAPI', 'Docker'],
years_experience: 6,
ai_score: null,
recommendation: null,
bank_reason: 'speculative',
bank_expires_at: '2028-09-03T00:00:00Z',
created_at: '2026-09-03T10:00:00Z',
})
ok('speculative row keeps the prefixed list id', speculative.id === 'bank:11111111-1111-1111-1111-111111111111')
ok('recordId is the raw uuid the delete/file routes take', speculative.recordId === '11111111-1111-1111-1111-111111111111')
ok('source label is human', speculative.sourceLabel === 'Speculative')
ok('speculative rows are removable stored CVs', speculative.isStoredCv === true)
ok('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker')
ok('years is numeric', speculative.years === 6)
ok('role comes through', speculative.title === 'Backend Engineer')
ok('company comes through', speculative.company === 'Acme')
ok('an unscored CV has no ATS score', speculative.aiScore === null)
ok('and no invented band', speculative.recommendation === null)
ok('speculative rows can run bank ATS', speculative.canRunAts === true)
ok('no suggestions stays an empty list', Array.isArray(speculative.suggestedJobs) && speculative.suggestedJobs.length === 0)
ok('no scored job until ATS runs', speculative.scoredJobPostId === null)
ok('expiry parses to a Date', speculative.expiresAt instanceof Date)
ok('added parses to a Date', speculative.added instanceof Date)
/* --- a silver medalist: scored, read live, not the bank's to delete ------- */
const silver = toBankRowView({
id: 'app:42',
record_id: '42',
bank_source: 'silver_medalist',
name: 'Grace Hopper',
email: 'grace@example.com',
current_company: 'Navy',
current_position: 'Rear Admiral',
skills: ['COBOL', 'Compilers'],
years_experience: 20,
ai_score: 88,
recommendation: 'Strong Match',
last_job_title: 'Principal Engineer',
assigned_job_post_id: 'job-assigned',
assigned_job_title: 'Principal Engineer',
scored_job_post_id: 'job-assigned',
scored_job_title: 'Principal Engineer',
suggested_jobs: [{ id: 'job-a', title: 'Compiler Engineer' }, { id: 'job-b', title: 'Systems Lead' }],
message_id: 'msg-1',
user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
created_at: '2026-08-01T10:00:00Z',
})
ok('silver medalist is keyed off the application', silver.id === 'app:42')
ok('silver medalist label', silver.sourceLabel === 'Silver medalist')
ok('silver medalist is NOT a stored CV, so the bank cannot delete it', silver.isStoredCv === false)
ok('paid ATS score survives', silver.aiScore === 88)
ok('band survives', silver.recommendation === 'Strong Match')
ok('the job they were rejected from is kept for context', silver.lastJobTitle === 'Principal Engineer')
ok('userId is kept so the row can open a real profile', silver.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ok('assigned job is shown on the picker', silver.assignedJobPostId === 'job-assigned' && silver.scoredJobTitle === 'Principal Engineer')
ok('suggested job titles come through', silver.suggestedJobs.map((j) => j.title).join(',') === 'Compiler Engineer,Systems Lead')
ok('inbox message lets silver Run ATS via score_inbox', silver.canRunAts === true && silver.messageId === 'msg-1')
const silverNoInbox = toBankRowView({
id: 'app:99',
record_id: '99',
bank_source: 'silver_medalist',
name: 'No Inbox',
ai_score: 70,
})
ok('silver without a message cannot run bank ATS', silverNoInbox.canRunAts === false)
/* --- ATS score after scoring, independent of suggestions ----------------- */
const scoredBank = toBankRowView({
id: 'bank:2',
record_id: '2',
bank_source: 'speculative',
name: 'Scored',
skills: [],
ai_score: 84,
scored_job_post_id: 'job-9',
scored_job_title: 'Backend Engineer',
})
ok('ATS score maps onto the bank row', scoredBank.aiScore === 84)
ok('Pick a job can restore the scored job on load', scoredBank.scoredJobPostId === 'job-9' && scoredBank.scoredJobTitle === 'Backend Engineer')
const suggestedFromIds = toBankRowView({
id: 'app:3', record_id: '3', bank_source: 'silver_medalist',
name: 'Ids only', suggested_job_post_ids: ['aaa', 'bbb'],
})
ok('suggested_job_post_ids hydrate when titles were not resolved', suggestedFromIds.suggestedJobs.map((j) => j.id).join(',') === 'aaa,bbb')
/* --- absent values stay absent ------------------------------------------- */
const sparse = toBankRowView({
id: 'bank:4',
record_id: '4',
bank_source: 'speculative',
file_name: 'unknown.pdf',
})
ok('a nameless CV falls back rather than rendering blank', sparse.name === 'Unknown')
ok('no skills is an empty array, not null', Array.isArray(sparse.skills) && sparse.skills.length === 0)
ok('unknown years stays null, never 0', sparse.years === null)
ok('no expiry stays null', sparse.expiresAt === null)
ok('unknown source defaults to speculative', sparse.source === 'speculative')
ok('empty suggested-jobs cell stays empty', sparse.suggestedJobs.length === 0)
const zeroYears = toBankRowView({
id: 'bank:5', record_id: '5', bank_source: 'speculative',
name: 'Fresh Grad', years_experience: 0,
})
ok('0 years is a real value and must not collapse to null', zeroYears.years === 0)
const derivedBand = toBankRowView({
id: 'app:6', record_id: '6', bank_source: 'silver_medalist',
name: 'Derived', ai_score: 70, message_id: 'm',
})
ok('a score without a band derives one', derivedBand.recommendation === 'Potential Match')
/* --- expiry wording ------------------------------------------------------ */
const now = new Date('2026-09-03T00:00:00Z')
ok('no expiry has no label', expiryLabel(null, now) === null)
ok('months are counted forward', expiryLabel(new Date('2027-04-01T00:00:00Z'), now) === 'expires in 7 months')
ok('one month is singular', expiryLabel(new Date('2026-10-03T00:00:00Z'), now) === 'expires in 1 month')
ok(
'a past window reads as expired, not a negative count',
expiryLabel(new Date('2026-01-01T00:00:00Z'), now) === 'expired',
)
rmSync(outDir, { recursive: true, force: true })
if (failed) {
console.log(`\n${failed} check(s) failed`)
process.exit(1)
}
console.log('\nAll CV Bank mapper checks passed')