feat: refresh candidate traits after CV changes
Recalculate stored participant traits when job postings change and expose replacement CV uploads with live candidate refresh events. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
30e3391adb
commit
5dbda53759
12 changed files with 373 additions and 9 deletions
|
|
@ -84,6 +84,53 @@ describe('API client', () => {
|
|||
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
|
||||
})
|
||||
|
||||
it('uploads a replacement CV and publishes participant and job events', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||
})
|
||||
const participant = {
|
||||
id: 4,
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
email: 'a@example.com',
|
||||
contactInfo: 'Brussels',
|
||||
traits: ['React'],
|
||||
cvFilename: 'replacement.pdf',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(participant), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const participantListener = vi.fn()
|
||||
const jobsListener = vi.fn()
|
||||
const unsubscribeParticipant = subscribeToDomainEvent('participants/4', participantListener)
|
||||
const unsubscribeJobs = subscribeToDomainEvent('jobs', jobsListener)
|
||||
const cv = new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' })
|
||||
|
||||
await api.participants.updateCV(4, cv)
|
||||
|
||||
const options = fetchMock.mock.calls[0][1] as RequestInit
|
||||
const form = options.body as FormData
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/participants/4/cv',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
)
|
||||
expect(form.get('cv')).toBe(cv)
|
||||
expect(participantListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'participant.cv-updated', data: participant }),
|
||||
)
|
||||
expect(jobsListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'participant.cv-updated', data: participant }),
|
||||
)
|
||||
unsubscribeParticipant()
|
||||
unsubscribeJobs()
|
||||
})
|
||||
|
||||
it('publishes an entity event after a successful mutation', async () => {
|
||||
const job = {
|
||||
id: 7,
|
||||
|
|
|
|||
|
|
@ -369,6 +369,15 @@ export const api = {
|
|||
(participant) => ['participants', `participants/${participant.id}`],
|
||||
)
|
||||
},
|
||||
updateCV: (id: number, cv: File) => {
|
||||
const form = new FormData()
|
||||
form.append('cv', cv)
|
||||
return mutation(
|
||||
request<Participant>(`/participants/${id}/cv`, { method: 'PUT', body: form }),
|
||||
'participant.cv-updated',
|
||||
(participant) => ['participants', `participants/${participant.id}`, 'jobs'],
|
||||
)
|
||||
},
|
||||
},
|
||||
users: {
|
||||
list: () => request<User[]>('/users'),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ function mockApi() {
|
|||
})
|
||||
if (path === '/api/jobs') return response([job])
|
||||
if (path === '/api/participants') return response([participant])
|
||||
if (path === '/api/participants/3' && method === 'GET') return response(participant)
|
||||
if (path === '/api/participants/3/cv' && method === 'PUT')
|
||||
return response({ ...participant, cvFilename: 'replacement.pdf', traits: ['React'] })
|
||||
if (path === '/api/jobs/7') return response(job)
|
||||
if (path === '/api/jobs/7/candidates')
|
||||
return response([
|
||||
|
|
@ -320,6 +323,21 @@ describe('Go Guess frontend', () => {
|
|||
expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx')
|
||||
})
|
||||
|
||||
it('replaces a participant CV and displays recalculated traits', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
renderApp('/participants/3')
|
||||
|
||||
const input = await screen.findByLabelText(/replace cv/i)
|
||||
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
|
||||
|
||||
expect(await screen.findByText('React')).toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/participants/3/cv',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates and deprecates questions in the standalone library', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
|
|
|
|||
|
|
@ -164,8 +164,21 @@ export function JobDetailPage() {
|
|||
|
||||
useDomainEvent<Job | Invitation | ScheduledInterview | undefined>(`jobs/${id}`, (event) => {
|
||||
const data = event.data
|
||||
if (event.type === 'participant.cv-updated') {
|
||||
void api.jobs
|
||||
.candidates(id)
|
||||
.then(setMatches)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
return
|
||||
}
|
||||
if (isJob(data)) {
|
||||
setJob(data)
|
||||
if (event.type === 'job.updated') {
|
||||
void api.jobs
|
||||
.candidates(id)
|
||||
.then(setMatches)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isInvitation(data)) {
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export function ParticipantDetailPage() {
|
|||
const id = Number(useParams().participantId)
|
||||
const [person, setPerson] = useState<Participant>()
|
||||
const [error, setError] = useState('')
|
||||
const [uploadingCV, setUploadingCV] = useState(false)
|
||||
useEffect(() => {
|
||||
void api.participants
|
||||
.get(id)
|
||||
|
|
@ -229,6 +230,19 @@ export function ParticipantDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function replaceCV(file?: File) {
|
||||
if (!file) return
|
||||
setUploadingCV(true)
|
||||
setError('')
|
||||
try {
|
||||
setPerson(await api.participants.updateCV(id, file))
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setUploadingCV(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/participants">
|
||||
|
|
@ -272,6 +286,15 @@ export function ParticipantDetailPage() {
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
<label className="button secondary file-button">
|
||||
<Upload size={17} /> {uploadingCV ? 'Updating CV…' : 'Replace CV'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx"
|
||||
disabled={uploadingCV}
|
||||
onChange={(event) => void replaceCV(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue