HR-ATS-Portal/frontend/job-profile.test.mjs

354 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Job profile page test — /job/:jobId rendered into jsdom against a mocked
* GET /jobs/profile/fetch.
*
* node job-profile.test.mjs
*
* Pins the design contract: employment type in the hero line, Suggested and
* Top Match stats from the one profile request, Optional Skills highlighted
* below Required Skills, and suggested-candidate cards ranked best → worst with
* matched optional skills highlighted on each card.
*/
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'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
class RO { observe() {} unobserve() {} disconnect() {} }
class MO { observe() {} disconnect() {} takeRecords() { return [] } }
globalThis.ResizeObserver = RO
globalThis.MutationObserver = MO
dom.window.ResizeObserver = RO
dom.window.MutationObserver = MO
dom.window.matchMedia = () => ({
matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
})
dom.window.HTMLCanvasElement.prototype.getContext = () =>
new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) })
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
'requisitions']
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
dom.window.localStorage.setItem('tf-auth', JSON.stringify({
access_token: 'test', refresh_token: 'test', expires_in: 1800,
expires_at: Date.now() + 1800_000,
data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions },
}))
// ---------------------------------------------------------------- fixtures
const JOB_ID = '11111111-2222-3333-4444-555555555555'
function jobRow(overrides = {}) {
return {
id: JOB_ID,
title: 'Regional Sales Executive',
department: 'GEO',
location: 'Multi-region',
employment_type: 'Permanent',
vacancies: 3,
platform: 'linkedin',
requisition_status: 'open',
status: 'draft',
experience_min: 3,
experience_max: 5,
requirements: ['B2B Sales', 'Distributor Management'],
optional_skills: ['Arabic', 'FMCG Background', 'Regional Travel'],
description: 'Own the sales pipeline across the GEO region.',
current_recruiter_ids: [],
recruiter_names: ['Nida Khan'],
hiring_manager_name: 'Amara Osei',
applicant_count: 42,
created_by_name: 'Nida Khan',
created_at: '2026-08-12T09:00:00Z',
...overrides,
}
}
function candidate(overrides) {
return {
id: overrides.id,
user_id: null, candidate_id: null, form_data_id: null, inbox_id: null,
email: null, current_title: null, current_company: null, years_experience: null,
matched_keywords: [], missing_keywords: [], optional_matched: [],
summary: null, source: 'upload', scored_candidate_id: null,
created_at: '2026-09-01T10:00:00Z',
...overrides,
}
}
const CANDIDATES = [
candidate({
id: 'a1', name: 'Sana Iqbal', match_score: 91, band: 'Strong Match', source: 'inbox',
current_title: 'Regional Sales Manager', current_company: 'Unilever', years_experience: 6,
matched_keywords: ['B2B Sales', 'Distributor Management'], optional_matched: ['Arabic'],
summary: 'Six years leading distributor relationships across GCC.', created_at: '2026-09-01T10:00:00Z',
}),
candidate({
id: 'a2', name: 'Ayesha Noor', match_score: 78, band: 'Potential Match',
matched_keywords: ['B2B Sales'], missing_keywords: ['Distributor Management'],
created_at: '2026-09-05T10:00:00Z',
}),
candidate({
id: 'a3', name: 'Bilal Ahmed', match_score: 40, band: 'Weak Match', source: 'form',
created_at: '2026-09-03T10:00:00Z',
}),
]
let profilePayload = null
const REQUESTS = []
/** Stand-in for the API's search → offset → limit, so paging is exercised
against a server that really returns one page and a total. */
function serveProfile(url) {
const params = new URL(url, 'http://localhost').searchParams
const term = (params.get('search') || '').toLowerCase()
const top = Number(params.get('top') || 0)
const skip = Number(params.get('skip') || 0)
const matching = profilePayload.candidates.filter((c) => !term || String(c.name).toLowerCase().includes(term))
const page = top ? matching.slice(skip, skip + top) : matching.slice(skip)
return { data: { ...profilePayload, total: matching.length, candidates: page }, total: matching.length, status_code: 200 }
}
globalThis.fetch = async (input) => {
const url = String(input?.url ?? input)
REQUESTS.push(url)
const body = url.includes('/jobs/profile/fetch')
? serveProfile(url)
: { data: [], status_code: 200 }
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) }
}
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-jobprofile-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
entryPoints: ['src/__smoke__/entry.jsx'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
const errors = []
const origError = console.error
console.error = (...args) => {
const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ')
if (msg.includes('React Router Future Flag')) return
errors.push(msg)
}
let failed = 0
function check(name, ok, detail = '') {
if (ok) console.log(`ok ${name}`)
else {
failed++
console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`)
}
}
try {
const mod = await import(pathToFileURL(outFile).href)
mod.boot()
// ------------------------------------------------ full profile
profilePayload = {
job: jobRow(),
suggested: 3,
top_match: 1,
top_score: 91,
bands: { 'Strong Match': 1, 'Potential Match': 1, 'Weak Match': 1 },
candidates: CANDIDATES,
}
let container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
let m = await mod.mountRoute(`/job/${JOB_ID}`, container)
await m.settle(60)
const profileCalls = REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
check('one profile request feeds the page', profileCalls.length === 1, `calls=${profileCalls.length}`)
check('profile request carries the job id', profileCalls[0]?.includes(`job_post_id=${JOB_ID}`), profileCalls[0])
const role = m.find('.job-hero .ph-role')?.textContent || ''
check('hero line shows department, location and employment type', role === 'GEO · Multi-region · Permanent', `role="${role}"`)
const stats = [...container.querySelectorAll('.hero-stat')].map((el) => ({
v: el.querySelector('.v')?.textContent, l: el.querySelector('.l')?.textContent,
}))
check('Suggested stat reads 3', stats.some((s) => s.l === 'Suggested' && s.v === '3'), JSON.stringify(stats))
check('Top Match stat counts the Strong Match band', stats.some((s) => s.l === 'Top Match' && s.v === '1'), JSON.stringify(stats))
check('hero tags show vacancies and applicants', m.text().includes('3 Vacancies') && m.text().includes('42 Applicants'))
const html = m.html()
const reqAt = html.indexOf('Required Skills')
const optAt = html.indexOf('Optional Skills')
check('Optional Skills section sits below Required Skills', reqAt > -1 && optAt > reqAt, `req=${reqAt} opt=${optAt}`)
const optionalTags = [...container.querySelectorAll('.tag.tag-optional')].map((el) => el.textContent.trim())
check('every optional skill is a highlighted tag', optionalTags.join('|') === 'Arabic|FMCG Background|Regional Travel', optionalTags.join('|'))
check('created line joins date and author', m.text().includes('by Nida Khan'))
// ------------------------------------------------ suggested tab
await m.click(m.findByText('[role="tab"]', 'Suggested Candidates'))
let cards = [...container.querySelectorAll('.cand-card')]
check('one card per suggested candidate', cards.length === 3, `cards=${cards.length}`)
const names = () => [...container.querySelectorAll('.cand-card .cand-name')].map((el) => el.textContent.trim())
check('default sort is best → worst', names().join('|') === 'Sana Iqbal|Ayesha Noor|Bilal Ahmed', names().join('|'))
const ranks = [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
check('cards are ranked 1..n', ranks.join(',') === '1,2,3', ranks.join(','))
check('sub line counts and marks the sort', m.text().includes('3 candidates suggested') && m.text().includes('Sorted: Best → Worst'))
const first = container.querySelector('.cand-card')
const optChips = [...first.querySelectorAll('.cand-chip.opt')].map((el) => el.textContent.trim())
check('matched optional skill is highlighted on its card', optChips.join('|') === 'Arabic', optChips.join('|'))
check('matched and missing chips render', first.querySelectorAll('.cand-chip.ok').length === 2
&& container.querySelectorAll('.cand-card')[1].querySelectorAll('.cand-chip.miss').length === 1)
check('source label maps inbox → Email', first.textContent.includes('Email'))
check('foot shows years and company', first.textContent.includes('6 yrs · Unilever'))
const sortSelect = container.querySelector('#suggested-sort')
await m.selectOption(sortSelect, 'name')
check('A → Z sort orders by name and drops ranks',
names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal' && !container.querySelector('.cand-rank'), names().join('|'))
await m.selectOption(sortSelect, 'recent')
check('Most Recent sort orders by score time', names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal', names().join('|'))
await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match')
check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|'))
// ------------------------------------------------ search / top go to the API
const profileCallsNow = () => REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
const lastParams = () => new URL(profileCallsNow().at(-1), 'http://localhost').searchParams
check('first request sends the default size as top=50', new URL(profileCallsNow()[0], 'http://localhost').searchParams.get('top') === '50')
check('first request sends no search', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('search'))
check('first request sends no limit', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('limit'))
const before = profileCallsNow().length
await m.type(container.querySelector('.toolbar-search input'), 'sana')
check('typing a search re-requests the profile', profileCallsNow().length > before)
check('search is sent as search=', lastParams().get('search') === 'sana', lastParams().get('search'))
check('search keeps the size', lastParams().get('top') === '50')
await m.selectOption(container.querySelector('.page-size-select'), '10')
check('Show changes top', lastParams().get('top') === '10', lastParams().get('top'))
check('Show keeps the search', lastParams().get('search') === 'sana')
await m.type(container.querySelector('.toolbar-search input'), ' ')
check('blank search is not sent', !lastParams().has('search'))
await m.unmount()
container.remove()
// ------------------------------------------------ page arrows
// 120 candidates: two full pages of 50 and a short third page of 20.
const PAGED_ID = '77777777-2222-3333-4444-555555555555'
profilePayload = {
job: jobRow({ id: PAGED_ID }),
suggested: 50, top_match: 0, top_score: 100,
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
candidates: Array.from({ length: 120 }, (_, i) => candidate({
id: `p${i}`, name: `Candidate ${String(i).padStart(3, '0')}`, match_score: 100 - i, band: 'Weak Match',
})),
}
container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
m = await mod.mountRoute(`/job/${PAGED_ID}?tab=suggested`, container)
await m.settle(60)
const pageCards = () => container.querySelectorAll('.cand-card').length
const pageNames = () => [...container.querySelectorAll('.cand-name')].map((el) => el.textContent.trim())
const pageRanks = () => [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
const lastCall = () => new URL(REQUESTS.filter((u) => u.includes('/jobs/profile/fetch')).at(-1), 'http://localhost').searchParams
check('pagination control renders under the grid', container.querySelectorAll('.pagination').length === 1)
check('first page holds 50 of 120', pageCards() === 50, `cards=${pageCards()}`)
check('page info reads 150 of 120', m.text().includes('150') && m.text().includes('of 120'))
check('first request asks for skip-less page 1', !lastCall().get('skip') || lastCall().get('skip') === '0')
check('Suggested stat shows the whole total, not the page', container.querySelector('.hero-stat .v')?.textContent === '120')
check('previous arrow is disabled on page 1', container.querySelector('.page-btn[aria-label="Previous page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Next page"]'))
check('next arrow requests skip=50', lastCall().get('skip') === '50', lastCall().get('skip'))
check('next arrow keeps top=50', lastCall().get('top') === '50')
check('page 2 starts where page 1 stopped', pageNames()[0] === 'Candidate 050', pageNames()[0])
check('ranks continue across pages', pageRanks()[0] === '51' && pageRanks().at(-1) === '100', pageRanks()[0])
await m.click(container.querySelector('.page-btn[aria-label="Last page"]'))
check('last page requests skip=100', lastCall().get('skip') === '100')
check('last page holds the remaining 20', pageCards() === 20, `cards=${pageCards()}`)
check('page info reads 101120 of 120', m.text().includes('101120'))
check('next arrow is disabled on the last page', container.querySelector('.page-btn[aria-label="Next page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Previous page"]'))
// Page 2 was fetched already, so React Query may serve it from cache without a
// new request — assert what is on screen, not the last URL.
check('previous arrow steps back to page 2', pageNames()[0] === 'Candidate 050' && m.text().includes('51100'), pageNames()[0])
await m.type(container.querySelector('.toolbar-search input'), 'Candidate 11')
check('a new search goes back to page 1', !lastCall().get('skip') || lastCall().get('skip') === '0', lastCall().get('skip'))
check('search narrows the total', m.text().includes('of 10'), m.text().match(/of \d+/)?.[0])
await m.unmount()
container.remove()
// ------------------------------------------------ sparse job
// A different id: the query client is shared across mounts, so the first
// job's profile is still cached under its own key.
const SPARSE_ID = '99999999-2222-3333-4444-555555555555'
profilePayload = {
job: jobRow({ id: SPARSE_ID, employment_type: null, optional_skills: [] }),
suggested: 0, top_match: 0, top_score: null,
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
candidates: [],
}
container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
m = await mod.mountRoute(`/job/${SPARSE_ID}?tab=suggested`, container)
await m.settle(60)
const sparseRole = m.find('.job-hero .ph-role')?.textContent || ''
check('no employment type → hero line omits it', sparseRole === 'GEO · Multi-region', `role="${sparseRole}"`)
check('?tab=suggested opens that tab, with an empty state', m.text().includes('No suggested candidates yet'))
await m.click(m.findByText('[role="tab"]', 'Details'))
check('no optional skills → no Optional Skills section', !m.text().includes('Optional Skills'))
await m.unmount()
container.remove()
check('no console errors', errors.length === 0, errors[0]?.split('\n').slice(0, 3).join(' | '))
} finally {
console.error = origError
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed} job profile check(s) FAILED` : '\nAll job profile checks passed')
process.exit(failed ? 1 : 0)