191 lines
7.4 KiB
JavaScript
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')
|