2026-09-19 20:31:14 +02:00
|
|
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
|
|
|
import userEvent from '@testing-library/user-event'
|
|
|
|
|
import { BrowserRouter, MemoryRouter } from 'react-router-dom'
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import App from './App'
|
2026-09-19 22:12:10 +02:00
|
|
|
import type { Job, Participant } from '../types'
|
2026-09-19 20:31:14 +02:00
|
|
|
|
|
|
|
|
const question = {
|
|
|
|
|
id: 11,
|
|
|
|
|
text: 'How do you design a resilient Go service?',
|
|
|
|
|
type: 'open' as const,
|
|
|
|
|
options: [],
|
|
|
|
|
referenceAnswer: 'Use timeouts, retries, and idempotency.',
|
2026-09-19 20:56:27 +02:00
|
|
|
codeSnippet: '',
|
2026-09-19 20:31:14 +02:00
|
|
|
deprecated: false,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const job: Job = {
|
|
|
|
|
id: 7,
|
|
|
|
|
title: 'Senior Go Engineer',
|
|
|
|
|
description: 'Build reliable services.',
|
|
|
|
|
seniority: 'Senior',
|
|
|
|
|
position: 'Backend Engineer',
|
|
|
|
|
labels: ['Platform'],
|
|
|
|
|
requiredSkills: ['Go'],
|
|
|
|
|
additionalSkills: ['Kubernetes'],
|
|
|
|
|
status: 'draft',
|
|
|
|
|
durationMinutes: 10,
|
|
|
|
|
questions: [],
|
|
|
|
|
createdAt: '2026-09-01T10:00:00Z',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const participant: Participant = {
|
|
|
|
|
id: 3,
|
|
|
|
|
firstName: 'Amélie',
|
|
|
|
|
lastName: 'Dubois',
|
|
|
|
|
email: 'amelie@example.com',
|
|
|
|
|
contactInfo: 'Brussels · +32 470 00 00 00',
|
|
|
|
|
traits: ['Go', 'PostgreSQL'],
|
|
|
|
|
createdAt: '2026-09-02T10:00:00Z',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function response(body: unknown, status = 200) {
|
|
|
|
|
return Promise.resolve(
|
|
|
|
|
new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mockApi() {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
'fetch',
|
|
|
|
|
vi.fn((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const method = options?.method ?? 'GET'
|
|
|
|
|
if (path === '/api/auth/login')
|
|
|
|
|
return response({
|
|
|
|
|
token: 'jwt-token',
|
2026-09-19 21:58:24 +02:00
|
|
|
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
2026-09-19 20:31:14 +02:00
|
|
|
})
|
|
|
|
|
if (path === '/api/jobs') return response([job])
|
|
|
|
|
if (path === '/api/participants') return response([participant])
|
|
|
|
|
if (path === '/api/jobs/7') return response(job)
|
|
|
|
|
if (path === '/api/jobs/7/candidates')
|
|
|
|
|
return response([
|
|
|
|
|
{
|
|
|
|
|
participant,
|
|
|
|
|
score: 90,
|
|
|
|
|
matchedTraits: ['Go'],
|
2026-09-19 21:34:12 +02:00
|
|
|
missingTraits: null,
|
2026-09-19 20:31:14 +02:00
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([])
|
2026-09-19 21:58:24 +02:00
|
|
|
if (path === '/api/jobs/7/interviews' && method === 'GET') return response([])
|
2026-09-19 20:31:14 +02:00
|
|
|
if (path === '/api/jobs/7/invitations' && method === 'POST')
|
|
|
|
|
return response({
|
|
|
|
|
id: 31,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'invite-token',
|
|
|
|
|
status: 'pending',
|
2026-09-19 20:56:27 +02:00
|
|
|
participantUrl: 'https://example.test/participant/invite-token',
|
2026-09-19 20:31:14 +02:00
|
|
|
acceptedAt: null,
|
|
|
|
|
completedAt: null,
|
|
|
|
|
createdAt: '2026-09-19T10:00:00Z',
|
|
|
|
|
})
|
|
|
|
|
if (path.startsWith('/api/questions') && method === 'GET') return response([question])
|
|
|
|
|
if (path === '/api/questions' && method === 'POST')
|
|
|
|
|
return response({ ...question, id: 12, text: 'Explain channels.' }, 201)
|
|
|
|
|
if (path === '/api/questions/11' && method === 'PATCH')
|
|
|
|
|
return response({ ...question, deprecated: true })
|
|
|
|
|
if (path === '/api/questions/11' && method === 'PUT')
|
|
|
|
|
return response({
|
|
|
|
|
...question,
|
|
|
|
|
text: 'Updated resilience question',
|
2026-09-19 20:56:27 +02:00
|
|
|
type: 'code_review',
|
2026-09-19 20:31:14 +02:00
|
|
|
referenceAnswer: 'Prefer bounded retries with jitter.',
|
2026-09-19 20:56:27 +02:00
|
|
|
codeSnippet: '+ return retry(request)',
|
2026-09-19 20:31:14 +02:00
|
|
|
})
|
|
|
|
|
if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201)
|
|
|
|
|
throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authenticate() {
|
|
|
|
|
sessionStorage.setItem('go-guess-token', 'jwt-token')
|
|
|
|
|
sessionStorage.setItem(
|
|
|
|
|
'go-guess-user',
|
2026-09-19 21:58:24 +02:00
|
|
|
JSON.stringify({
|
|
|
|
|
id: 1,
|
|
|
|
|
email: 'admin@example.com',
|
|
|
|
|
displayName: 'Admin',
|
|
|
|
|
role: 'admin',
|
|
|
|
|
}),
|
2026-09-19 20:31:14 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderApp(path = '/') {
|
|
|
|
|
return render(
|
|
|
|
|
<MemoryRouter initialEntries={[path]}>
|
|
|
|
|
<App />
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Go Guess frontend', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
sessionStorage.clear()
|
|
|
|
|
mockApi()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 21:34:12 +02:00
|
|
|
it('renders candidate matches when an older API returns null trait arrays', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /candidate match/i }))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText('90%')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Missing').nextElementSibling).toHaveTextContent('0')
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 20:31:14 +02:00
|
|
|
it('persists login, navigates from the login URL, and shows API jobs', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
window.history.replaceState({}, '', '/login')
|
|
|
|
|
render(
|
|
|
|
|
<BrowserRouter>
|
|
|
|
|
<App />
|
|
|
|
|
</BrowserRouter>,
|
|
|
|
|
)
|
|
|
|
|
await user.type(screen.getByLabelText(/email address/i), 'interviewer@go-guess.local')
|
|
|
|
|
await user.type(screen.getByLabelText(/password/i), 'admin123')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Job postings' })).toBeInTheDocument()
|
|
|
|
|
expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument()
|
|
|
|
|
expect(sessionStorage.getItem('go-guess-token')).toBe('jwt-token')
|
|
|
|
|
expect(window.location.pathname).toBe('/jobs')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters jobs by title', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/jobs')
|
|
|
|
|
expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
await user.type(screen.getByRole('textbox', { name: /search jobs/i }), 'designer')
|
|
|
|
|
expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('adds multiple choice options with the accessible plus button', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/jobs/7/questions/new')
|
|
|
|
|
await screen.findByRole('heading', { name: 'Create a question' })
|
|
|
|
|
await user.type(screen.getByLabelText('Reference answer'), 'This should be cleared.')
|
|
|
|
|
await user.selectOptions(screen.getByLabelText('Response type'), 'multiple_choice')
|
|
|
|
|
expect(screen.queryByLabelText('Reference answer')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const optionInput = screen.getByRole('textbox', { name: 'New option' })
|
|
|
|
|
await user.type(optionInput, 'Retries')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Add option' }))
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Retries')).toBeInTheDocument()
|
|
|
|
|
expect(optionInput).toHaveValue('')
|
|
|
|
|
|
|
|
|
|
await user.selectOptions(screen.getByLabelText('Response type'), 'open')
|
|
|
|
|
expect(screen.getByLabelText('Reference answer')).toHaveValue('')
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument()
|
|
|
|
|
await user.selectOptions(screen.getByLabelText('Response type'), 'code_review')
|
|
|
|
|
expect(screen.getByLabelText('Expected answer')).toBeInTheDocument()
|
2026-09-19 20:56:27 +02:00
|
|
|
expect(screen.getByLabelText('Code snippet')).toBeRequired()
|
2026-09-19 20:31:14 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('surfaces API failures instead of demo content', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
vi.mocked(fetch).mockImplementation(() => response({ error: 'database unavailable' }, 500))
|
|
|
|
|
renderApp('/jobs')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('database unavailable')
|
|
|
|
|
expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('exposes participant multipart fields', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
renderApp('/participants/new')
|
|
|
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled())
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('heading', { name: 'Add a participant' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByLabelText(/birthday/i)).toHaveAttribute('type', 'date')
|
|
|
|
|
expect(screen.getByLabelText(/contact information/i)).toBeRequired()
|
|
|
|
|
expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates and deprecates questions in the standalone library', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/questions')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText(question.text)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getByRole('checkbox', { name: `Deprecated: ${question.text}` }))
|
|
|
|
|
await user.type(screen.getByLabelText('Question prompt'), 'Explain channels.')
|
|
|
|
|
await user.type(screen.getByLabelText('Reference answer'), 'Mention ownership and closure.')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: /create question/i }))
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/questions',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
text: 'Explain channels.',
|
|
|
|
|
type: 'open',
|
|
|
|
|
options: [],
|
|
|
|
|
referenceAnswer: 'Mention ownership and closure.',
|
2026-09-19 20:56:27 +02:00
|
|
|
codeSnippet: '',
|
2026-09-19 20:31:14 +02:00
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/questions/11',
|
|
|
|
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ deprecated: true }) }),
|
|
|
|
|
)
|
|
|
|
|
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('populates and persists the reference answer while editing', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
2026-09-19 20:56:27 +02:00
|
|
|
const codeReviewQuestion = {
|
|
|
|
|
...question,
|
|
|
|
|
id: 21,
|
|
|
|
|
type: 'code_review' as const,
|
|
|
|
|
text: 'Review this retry change',
|
|
|
|
|
codeSnippet: '+ return retry(request)',
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const method = options?.method ?? 'GET'
|
|
|
|
|
if (path.startsWith('/api/questions') && method === 'GET')
|
|
|
|
|
return response([codeReviewQuestion])
|
|
|
|
|
if (path === '/api/questions/21' && method === 'PUT')
|
|
|
|
|
return response({
|
|
|
|
|
...codeReviewQuestion,
|
|
|
|
|
text: 'Updated resilience question',
|
|
|
|
|
referenceAnswer: 'Prefer bounded retries with jitter.',
|
|
|
|
|
})
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
2026-09-19 20:31:14 +02:00
|
|
|
renderApp('/questions')
|
|
|
|
|
|
2026-09-19 20:56:27 +02:00
|
|
|
await user.click(await screen.findByRole('button', { name: `Edit ${codeReviewQuestion.text}` }))
|
2026-09-19 20:31:14 +02:00
|
|
|
const prompt = screen.getByLabelText('Question prompt')
|
|
|
|
|
await user.clear(prompt)
|
|
|
|
|
await user.type(prompt, 'Updated resilience question')
|
2026-09-19 20:56:27 +02:00
|
|
|
const reference = screen.getByLabelText('Expected answer')
|
|
|
|
|
expect(reference).toHaveValue(codeReviewQuestion.referenceAnswer)
|
|
|
|
|
expect(screen.getByLabelText('Code snippet')).toHaveValue(codeReviewQuestion.codeSnippet)
|
2026-09-19 20:31:14 +02:00
|
|
|
await user.clear(reference)
|
|
|
|
|
await user.type(reference, 'Prefer bounded retries with jitter.')
|
|
|
|
|
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
2026-09-19 20:56:27 +02:00
|
|
|
'/api/questions/21',
|
2026-09-19 20:31:14 +02:00
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
text: 'Updated resilience question',
|
2026-09-19 20:56:27 +02:00
|
|
|
type: 'code_review',
|
2026-09-19 20:31:14 +02:00
|
|
|
options: [],
|
|
|
|
|
referenceAnswer: 'Prefer bounded retries with jitter.',
|
2026-09-19 20:56:27 +02:00
|
|
|
codeSnippet: '+ return retry(request)',
|
2026-09-19 20:31:14 +02:00
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('attaches and detaches library questions without deleting them', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /questions/i }))
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Attach' }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: `Detach ${question.text}` }))
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/questions/11',
|
|
|
|
|
expect.objectContaining({ method: 'POST' }),
|
|
|
|
|
)
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/questions/11',
|
|
|
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
|
|
|
)
|
|
|
|
|
expect(fetch).not.toHaveBeenCalledWith(
|
|
|
|
|
'/api/questions/11',
|
|
|
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 20:56:27 +02:00
|
|
|
it('generates an invitation for an eligible participant and displays its URL', async () => {
|
2026-09-19 20:31:14 +02:00
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
2026-09-19 20:56:27 +02:00
|
|
|
await user.click(screen.getByRole('button', { name: 'Generate invitation' }))
|
2026-09-19 20:31:14 +02:00
|
|
|
|
|
|
|
|
expect(
|
2026-09-19 20:56:27 +02:00
|
|
|
await screen.findByText('https://example.test/participant/invite-token'),
|
2026-09-19 20:31:14 +02:00
|
|
|
).toBeInTheDocument()
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/invitations',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ participantId: 3 }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('navigates participant questions, persists answers, and derives the timer from acceptedAt', async () => {
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
const now = new Date('2026-09-19T12:00:00Z')
|
|
|
|
|
vi.setSystemTime(now)
|
|
|
|
|
const interview = {
|
|
|
|
|
invitation: {
|
|
|
|
|
id: 31,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'invite-token',
|
|
|
|
|
status: 'pending',
|
2026-09-19 20:56:27 +02:00
|
|
|
participantUrl: 'https://example.test/participant/invite-token',
|
2026-09-19 20:31:14 +02:00
|
|
|
acceptedAt: null,
|
|
|
|
|
completedAt: null,
|
|
|
|
|
createdAt: now.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
job: {
|
|
|
|
|
id: 7,
|
|
|
|
|
title: job.title,
|
|
|
|
|
description: job.description,
|
|
|
|
|
position: job.position,
|
|
|
|
|
seniority: job.seniority,
|
|
|
|
|
durationMinutes: 10,
|
|
|
|
|
questions: [question, { ...question, id: 12, text: 'How do you test it?' }],
|
|
|
|
|
},
|
|
|
|
|
answers: {},
|
|
|
|
|
}
|
|
|
|
|
const acceptedInterview = {
|
|
|
|
|
...interview,
|
|
|
|
|
invitation: {
|
|
|
|
|
...interview.invitation,
|
|
|
|
|
status: 'accepted',
|
|
|
|
|
acceptedAt: now.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
if (path === '/api/interviews/invite-token' && !options?.method) return response(interview)
|
|
|
|
|
if (path.endsWith('/accept')) return response(acceptedInterview)
|
|
|
|
|
if (path.includes('/answers/')) return response({})
|
|
|
|
|
if (path.endsWith('/finish'))
|
|
|
|
|
return response({
|
|
|
|
|
...acceptedInterview,
|
|
|
|
|
invitation: {
|
|
|
|
|
...acceptedInterview.invitation,
|
|
|
|
|
status: 'completed',
|
|
|
|
|
completedAt: now.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
})
|
2026-09-19 20:56:27 +02:00
|
|
|
renderApp('/participant/invite-token')
|
2026-09-19 20:31:14 +02:00
|
|
|
await act(async () => {
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
})
|
2026-09-19 20:56:27 +02:00
|
|
|
expect(screen.queryByText(question.text)).not.toBeInTheDocument()
|
2026-09-19 20:31:14 +02:00
|
|
|
expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument()
|
|
|
|
|
await act(async () => {
|
2026-09-19 20:56:27 +02:00
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Accept' }))
|
2026-09-19 20:31:14 +02:00
|
|
|
})
|
|
|
|
|
expect(screen.getByText(question.text)).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
|
2026-09-19 20:56:27 +02:00
|
|
|
expect(screen.getByRole('progressbar', { name: 'Interview progress' })).toHaveAttribute(
|
|
|
|
|
'aria-valuenow',
|
|
|
|
|
'50',
|
|
|
|
|
)
|
|
|
|
|
for (const name of ['First', 'Previous', 'Next', 'Last', 'Submit']) {
|
|
|
|
|
expect(screen.getByRole('button', { name })).toBeInTheDocument()
|
|
|
|
|
}
|
2026-09-19 20:31:14 +02:00
|
|
|
expect(screen.getByTestId('countdown')).toHaveTextContent('10:00')
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
|
})
|
|
|
|
|
expect(screen.getByTestId('countdown')).toHaveTextContent('09:59')
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Your answer'), {
|
|
|
|
|
target: { value: 'Use retries and timeouts.' },
|
|
|
|
|
})
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
|
|
|
|
})
|
|
|
|
|
expect(screen.getByText('How do you test it?')).toBeInTheDocument()
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/interviews/invite-token/answers/11',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify({ answer: 'Use retries and timeouts.' }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'First' }))
|
|
|
|
|
})
|
|
|
|
|
expect(screen.getByText(question.text)).toBeInTheDocument()
|
2026-09-19 20:56:27 +02:00
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
|
|
|
|
})
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/interviews/invite-token/finish',
|
|
|
|
|
expect.objectContaining({ method: 'POST' }),
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByRole('heading', { name: /thank you/i })).toBeInTheDocument()
|
2026-09-19 20:31:14 +02:00
|
|
|
vi.useRealTimers()
|
|
|
|
|
})
|
2026-09-19 20:56:27 +02:00
|
|
|
|
|
|
|
|
it('renders protected invitation answers for interviewer review', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const acceptedInvitation = {
|
|
|
|
|
id: 44,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'review-token',
|
|
|
|
|
status: 'accepted',
|
|
|
|
|
participantUrl: 'https://example.test/participant/review-token',
|
|
|
|
|
acceptedAt: '2026-09-19T10:05:00Z',
|
|
|
|
|
completedAt: null,
|
|
|
|
|
createdAt: '2026-09-19T10:00:00Z',
|
|
|
|
|
}
|
|
|
|
|
const choiceQuestion = {
|
|
|
|
|
...question,
|
|
|
|
|
id: 22,
|
|
|
|
|
text: 'Choose a strategy',
|
|
|
|
|
type: 'radio' as const,
|
|
|
|
|
options: ['Retry', 'Fail fast'],
|
|
|
|
|
referenceAnswer: '',
|
|
|
|
|
}
|
|
|
|
|
const codeQuestion = {
|
|
|
|
|
...question,
|
|
|
|
|
id: 23,
|
|
|
|
|
text: 'Review the patch',
|
|
|
|
|
type: 'code_review' as const,
|
|
|
|
|
codeSnippet: '+ return retry(request)',
|
|
|
|
|
referenceAnswer: 'Bound the retry count.',
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
if (path === '/api/jobs/7/invitations' && !options?.method)
|
|
|
|
|
return response([acceptedInvitation])
|
|
|
|
|
if (path === '/api/jobs/7/invitations/44')
|
|
|
|
|
return response({
|
|
|
|
|
invitation: acceptedInvitation,
|
|
|
|
|
job: {
|
|
|
|
|
id: 7,
|
|
|
|
|
title: job.title,
|
|
|
|
|
description: job.description,
|
|
|
|
|
position: job.position,
|
|
|
|
|
seniority: job.seniority,
|
|
|
|
|
durationMinutes: job.durationMinutes,
|
|
|
|
|
questions: [question, choiceQuestion, codeQuestion],
|
|
|
|
|
},
|
|
|
|
|
answers: {
|
|
|
|
|
'11': 'Use exponential backoff.',
|
|
|
|
|
'23': 'The retry loop needs a maximum.',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Review answers' }))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Amélie Dubois' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Use exponential backoff.')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Retry · Fail fast')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Unanswered')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('The retry loop needs a maximum.')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('region', { name: 'Code changes' })).toBeInTheDocument()
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith('/api/jobs/7/invitations/44', expect.any(Object))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders code review lines and a review comment without exposing the reference answer', async () => {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const codeQuestion = {
|
|
|
|
|
...question,
|
|
|
|
|
id: 20,
|
|
|
|
|
type: 'code_review' as const,
|
|
|
|
|
text: 'Review this change',
|
|
|
|
|
codeSnippet: ['func total() int {', '+ return 42', '}'].join('\n'),
|
|
|
|
|
referenceAnswer: 'The hard-coded value should be replaced.',
|
|
|
|
|
}
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
if (path === '/api/interviews/code-token')
|
|
|
|
|
return response({
|
|
|
|
|
invitation: {
|
|
|
|
|
id: 32,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'code-token',
|
|
|
|
|
status: 'accepted',
|
|
|
|
|
participantUrl: 'https://example.test/participant/code-token',
|
|
|
|
|
acceptedAt: now.toISOString(),
|
|
|
|
|
completedAt: null,
|
|
|
|
|
createdAt: now.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
job: {
|
|
|
|
|
id: 7,
|
|
|
|
|
title: job.title,
|
|
|
|
|
description: job.description,
|
|
|
|
|
position: job.position,
|
|
|
|
|
seniority: job.seniority,
|
|
|
|
|
durationMinutes: 10,
|
|
|
|
|
questions: [codeQuestion],
|
|
|
|
|
},
|
|
|
|
|
answers: {},
|
|
|
|
|
})
|
|
|
|
|
throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { container } = renderApp('/participant/code-token')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('region', { name: 'Code changes' })).toBeInTheDocument()
|
|
|
|
|
expect(container.querySelectorAll('.code-line')).toHaveLength(3)
|
|
|
|
|
expect(container.querySelector('.code-line.added code')?.textContent).toBe('+ return 42')
|
|
|
|
|
expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
|
|
|
|
|
expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-09-19 21:58:24 +02:00
|
|
|
|
|
|
|
|
it('records pass and fail decisions from a completed answer review', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const completed = {
|
|
|
|
|
id: 44,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'review-token',
|
|
|
|
|
status: 'completed',
|
|
|
|
|
outcome: 'pending',
|
|
|
|
|
participantUrl: '/participant/review-token',
|
|
|
|
|
acceptedAt: '2026-09-19T10:05:00Z',
|
|
|
|
|
completedAt: '2026-09-19T10:15:00Z',
|
|
|
|
|
createdAt: '2026-09-19T10:00:00Z',
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const method = options?.method ?? 'GET'
|
|
|
|
|
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([completed])
|
|
|
|
|
if (path === '/api/jobs/7/invitations/44' && method === 'GET')
|
|
|
|
|
return response({
|
|
|
|
|
invitation: completed,
|
|
|
|
|
job: { ...job, questions: [question] },
|
|
|
|
|
answers: { '11': 'Retries with jitter.' },
|
|
|
|
|
})
|
|
|
|
|
if (path === '/api/jobs/7/invitations/44/outcome' && method === 'PATCH')
|
|
|
|
|
return response({
|
|
|
|
|
...completed,
|
|
|
|
|
outcome: JSON.parse(String(options?.body)).outcome,
|
|
|
|
|
})
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Review answers' }))
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Pass candidate' }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Fail candidate' }))
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/invitations/44/outcome',
|
|
|
|
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'passed' }) }),
|
|
|
|
|
)
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/invitations/44/outcome',
|
|
|
|
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'failed' }) }),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates an interview from a passed invitation with co-interviewers and shared documentation', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const passed = {
|
|
|
|
|
id: 45,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
participantEmail: 'amelie@example.com',
|
|
|
|
|
token: 'passed-token',
|
|
|
|
|
status: 'completed',
|
|
|
|
|
outcome: 'passed',
|
|
|
|
|
participantUrl: '/participant/passed-token',
|
|
|
|
|
acceptedAt: '2026-09-19T10:05:00Z',
|
|
|
|
|
completedAt: '2026-09-19T10:15:00Z',
|
|
|
|
|
createdAt: '2026-09-19T10:00:00Z',
|
|
|
|
|
}
|
|
|
|
|
const interviewer = {
|
|
|
|
|
id: 2,
|
|
|
|
|
email: 'alex@example.com',
|
|
|
|
|
displayName: 'Alex Morgan',
|
|
|
|
|
role: 'interviewer',
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const method = options?.method ?? 'GET'
|
|
|
|
|
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([passed])
|
|
|
|
|
if (path === '/api/users') return response([interviewer])
|
|
|
|
|
if (path === '/api/jobs/7/interviews' && method === 'POST')
|
|
|
|
|
return response({
|
|
|
|
|
id: 80,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
jobTitle: job.title,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
startsAt: '2026-09-22T08:30:00.000Z',
|
|
|
|
|
location: 'Brussels',
|
|
|
|
|
status: 'scheduled',
|
|
|
|
|
candidateToken: 'meeting-token',
|
|
|
|
|
candidateUrl: '/participant/meeting/meeting-token',
|
|
|
|
|
sharedDocument: 'Read the architecture brief.',
|
|
|
|
|
attendees: [],
|
|
|
|
|
notes: [],
|
|
|
|
|
})
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/jobs/7')
|
|
|
|
|
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Schedule Amélie Dubois' }))
|
2026-09-19 22:12:10 +02:00
|
|
|
expect(screen.getByRole('heading', { name: 'Interview details' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('heading', { name: 'Interview team' })).toBeInTheDocument()
|
2026-09-19 21:58:24 +02:00
|
|
|
await user.type(screen.getByLabelText('Date and time'), '2026-09-22T10:30')
|
|
|
|
|
await user.type(screen.getByLabelText('Location or meeting link'), 'Brussels')
|
|
|
|
|
await user.click(await screen.findByLabelText(/Alex Morgan/))
|
|
|
|
|
await user.type(screen.getByLabelText('Shared documentation'), 'Read the architecture brief.')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Create interview' }))
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/jobs/7/interviews',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: expect.stringContaining('"interviewerIds":[2]'),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
expect(await screen.findByText(/Brussels/)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getAllByText('Amélie Dubois')).not.toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lists users and creates a co-interviewer', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const method = options?.method ?? 'GET'
|
|
|
|
|
if (path === '/api/users' && method === 'GET')
|
|
|
|
|
return response([
|
|
|
|
|
{ id: 1, email: 'admin@example.com', displayName: 'Admin User', role: 'admin' },
|
|
|
|
|
])
|
|
|
|
|
if (path === '/api/users' && method === 'POST')
|
|
|
|
|
return response(
|
|
|
|
|
{ id: 2, email: 'sam@example.com', displayName: 'Sam Lee', role: 'interviewer' },
|
|
|
|
|
201,
|
|
|
|
|
)
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/users')
|
|
|
|
|
expect(await screen.findByText('Admin User')).toBeInTheDocument()
|
|
|
|
|
await user.type(screen.getByLabelText('Display name'), 'Sam Lee')
|
|
|
|
|
await user.type(screen.getByLabelText('Email address'), 'sam@example.com')
|
|
|
|
|
await user.type(screen.getByLabelText('Temporary password'), 'password123')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Create co-interviewer' }))
|
|
|
|
|
expect(await screen.findByText('Sam Lee')).toBeInTheDocument()
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/users',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
email: 'sam@example.com',
|
|
|
|
|
password: 'password123',
|
|
|
|
|
displayName: 'Sam Lee',
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts and declines interviews from the inbox', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const scheduled = {
|
|
|
|
|
id: 80,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
jobTitle: job.title,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
startsAt: '2026-09-22T08:30:00Z',
|
|
|
|
|
location: 'Brussels',
|
|
|
|
|
status: 'pending',
|
|
|
|
|
candidateToken: 'meeting-token',
|
|
|
|
|
candidateUrl: '/participant/meeting/meeting-token',
|
|
|
|
|
sharedDocument: 'Agenda',
|
|
|
|
|
attendees: [],
|
|
|
|
|
notes: [],
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
if (path === '/api/inbox' && !options?.method) return response([scheduled])
|
|
|
|
|
if (path === '/api/inbox/80')
|
|
|
|
|
return response({ ...scheduled, status: JSON.parse(String(options?.body)).status })
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/inbox')
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Accept' }))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Decline' }))
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/inbox/80',
|
|
|
|
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }),
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('declined')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('groups calendar interviews by date and links to the session', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
if (String(input) === '/api/calendar')
|
|
|
|
|
return response([
|
|
|
|
|
{
|
|
|
|
|
id: 80,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
jobTitle: job.title,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
startsAt: '2026-09-22T08:30:00Z',
|
|
|
|
|
location: 'Brussels',
|
|
|
|
|
status: 'scheduled',
|
|
|
|
|
candidateToken: 'meeting-token',
|
|
|
|
|
candidateUrl: '/participant/meeting/meeting-token',
|
|
|
|
|
sharedDocument: 'Agenda',
|
|
|
|
|
attendees: [],
|
|
|
|
|
notes: [],
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/calendar')
|
|
|
|
|
expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute(
|
|
|
|
|
'href',
|
|
|
|
|
'/scheduled-interviews/80',
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole('heading', { level: 2, name: /September 22, 2026/ }),
|
|
|
|
|
).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('edits shared documentation and displays notes received over authenticated fetch streaming', async () => {
|
|
|
|
|
authenticate()
|
|
|
|
|
const user = userEvent.setup()
|
2026-09-19 22:13:50 +02:00
|
|
|
const writeText = vi.spyOn(navigator.clipboard, 'writeText')
|
2026-09-19 21:58:24 +02:00
|
|
|
const scheduled = {
|
|
|
|
|
id: 80,
|
|
|
|
|
jobId: 7,
|
|
|
|
|
jobTitle: job.title,
|
|
|
|
|
participantId: 3,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
startsAt: '2026-09-22T08:30:00Z',
|
|
|
|
|
location: 'Brussels',
|
|
|
|
|
status: 'started',
|
|
|
|
|
candidateToken: 'meeting-token',
|
|
|
|
|
candidateUrl: '/participant/meeting/meeting-token',
|
|
|
|
|
sharedDocument: 'Original agenda',
|
|
|
|
|
attendees: [
|
|
|
|
|
{ userId: 2, displayName: 'Alex Morgan', email: 'alex@example.com', status: 'accepted' },
|
|
|
|
|
],
|
|
|
|
|
notes: [],
|
|
|
|
|
}
|
|
|
|
|
const liveNote = {
|
|
|
|
|
id: 9,
|
|
|
|
|
authorUserId: 2,
|
|
|
|
|
authorName: 'Alex Morgan',
|
|
|
|
|
body: 'Strong system design answer.',
|
|
|
|
|
createdAt: '2026-09-22T08:35:00Z',
|
|
|
|
|
}
|
|
|
|
|
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
if (path === '/api/scheduled-interviews/80') return response(scheduled)
|
|
|
|
|
if (path === '/api/scheduled-interviews/80/notes' && !options?.method) return response([])
|
|
|
|
|
if (path === '/api/scheduled-interviews/80/document')
|
|
|
|
|
return response({
|
|
|
|
|
...scheduled,
|
|
|
|
|
sharedDocument: JSON.parse(String(options?.body)).sharedDocument,
|
|
|
|
|
})
|
|
|
|
|
if (path === '/api/scheduled-interviews/80/events') {
|
|
|
|
|
const stream = new ReadableStream({
|
|
|
|
|
start(controller) {
|
|
|
|
|
controller.enqueue(
|
2026-09-19 22:22:42 +02:00
|
|
|
new TextEncoder().encode(
|
|
|
|
|
`event: interview-event\ndata: ${JSON.stringify({
|
|
|
|
|
type: 'note.created',
|
|
|
|
|
interviewId: 80,
|
|
|
|
|
note: liveNote,
|
|
|
|
|
})}\n\nevent: interview-event\ndata: ${JSON.stringify({
|
|
|
|
|
type: 'document.updated',
|
|
|
|
|
interviewId: 80,
|
|
|
|
|
sharedDocument: 'Remote agenda',
|
|
|
|
|
})}\n\n`,
|
|
|
|
|
),
|
2026-09-19 21:58:24 +02:00
|
|
|
)
|
|
|
|
|
controller.close()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return Promise.resolve(
|
|
|
|
|
new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
|
|
|
|
return defaultFetch(input, options)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/scheduled-interviews/80')
|
|
|
|
|
expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument()
|
2026-09-19 22:22:42 +02:00
|
|
|
expect(screen.getByLabelText('Shared documentation')).toHaveValue('Remote agenda')
|
2026-09-19 22:13:50 +02:00
|
|
|
await user.click(screen.getByRole('button', { name: 'Copy participant interview link' }))
|
|
|
|
|
expect(writeText).toHaveBeenCalledWith('/participant/meeting/meeting-token')
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole('button', { name: 'Copy participant interview link' }),
|
|
|
|
|
).toHaveTextContent('Link copied')
|
2026-09-19 21:58:24 +02:00
|
|
|
const document = screen.getByLabelText('Shared documentation')
|
|
|
|
|
await user.clear(document)
|
|
|
|
|
await user.type(document, 'Updated candidate exercise')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save shared documentation' }))
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/scheduled-interviews/80/document',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify({ sharedDocument: 'Updated candidate exercise' }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
|
|
|
'/api/scheduled-interviews/80/events',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
headers: expect.objectContaining({ Accept: 'text/event-stream' }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows the public participant meeting without internal notes', async () => {
|
|
|
|
|
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
|
|
|
|
|
if (String(input) === '/api/participant-meetings/meeting-token')
|
|
|
|
|
return response({
|
|
|
|
|
id: 80,
|
|
|
|
|
jobTitle: job.title,
|
|
|
|
|
participantName: 'Amélie Dubois',
|
|
|
|
|
startsAt: '2026-09-22T08:30:00Z',
|
|
|
|
|
location: 'Brussels',
|
|
|
|
|
status: 'scheduled',
|
|
|
|
|
sharedDocument: 'Please prepare the architecture exercise.',
|
|
|
|
|
})
|
2026-09-19 22:22:42 +02:00
|
|
|
if (String(input) === '/api/participant-meetings/meeting-token/events') {
|
|
|
|
|
const stream = new ReadableStream({
|
|
|
|
|
start(controller) {
|
|
|
|
|
controller.enqueue(
|
|
|
|
|
new TextEncoder().encode(
|
|
|
|
|
`event: interview-event\ndata: ${JSON.stringify({
|
|
|
|
|
type: 'document.updated',
|
|
|
|
|
interviewId: 80,
|
|
|
|
|
sharedDocument: 'The architecture exercise changed live.',
|
|
|
|
|
})}\n\n`,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
controller.close()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return Promise.resolve(
|
|
|
|
|
new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-09-19 21:58:24 +02:00
|
|
|
throw new Error(`Unexpected request: ${String(input)}`)
|
|
|
|
|
})
|
|
|
|
|
renderApp('/participant/meeting/meeting-token')
|
|
|
|
|
expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument()
|
2026-09-19 22:22:42 +02:00
|
|
|
expect(await screen.findByText('The architecture exercise changed live.')).toBeInTheDocument()
|
2026-09-19 21:58:24 +02:00
|
|
|
expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-09-19 20:31:14 +02:00
|
|
|
})
|