initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
*.tsbuildinfo
|
||||
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
3
frontend/.prettierignore
Normal file
3
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:26-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
71
frontend/README.md
Normal file
71
frontend/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Go Guess frontend
|
||||
|
||||
React and TypeScript recruitment workspace built with Vite.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format
|
||||
npm run format:check
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run one test:
|
||||
|
||||
```bash
|
||||
npm test -- src/App.test.tsx -t "shows answer inputs"
|
||||
```
|
||||
|
||||
## API integration
|
||||
|
||||
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in
|
||||
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
|
||||
shown in the UI and are never replaced with demo data.
|
||||
|
||||
Supported routes:
|
||||
|
||||
- `POST /api/auth/login`
|
||||
- `GET|POST /api/jobs`
|
||||
- `GET /api/jobs/:id`
|
||||
- `PATCH /api/jobs/:id` for interview status and duration
|
||||
- `POST|DELETE /api/jobs/:id/questions/:questionId`
|
||||
- `GET /api/jobs/:id/candidates`
|
||||
- `GET|POST /api/jobs/:id/invitations`
|
||||
- `GET|POST /api/questions` (`GET` accepts `?search=`)
|
||||
- `PUT /api/questions/:id` to update question text, type, options, and reference answer
|
||||
- `PATCH /api/questions/:id` to deprecate or restore a question
|
||||
- `GET|POST /api/participants`
|
||||
- `GET /api/participants/:id`
|
||||
- `GET /api/participants/:id/photo`
|
||||
- `GET /api/participants/:id/cv`
|
||||
- `GET /api/interviews/:token`
|
||||
- `POST /api/interviews/:token/accept`
|
||||
- `PUT /api/interviews/:token/answers/:questionId`
|
||||
- `POST /api/interviews/:token/finish`
|
||||
|
||||
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
|
||||
`contactInfo`, `photo`, and `cv`.
|
||||
|
||||
## Interview workflow
|
||||
|
||||
Authenticated interviewers create and edit reusable questions at `/questions`, configure job
|
||||
status and duration, attach active library questions, and issue participant invitations from a job
|
||||
detail page. Multiple-choice and radio questions require at least two explicitly added options.
|
||||
Detaching a question removes only the job association; questions can be deprecated but not deleted.
|
||||
Open-answer and code-review questions include an interviewer-only reference answer that is never
|
||||
rendered in the participant interview.
|
||||
|
||||
Participants open the generated `/interview/:token` URL without signing in. The invitation token
|
||||
authorizes the public interview API. Answers are saved when navigating between questions or
|
||||
finishing, and the countdown is derived from the backend `acceptedAt` timestamp plus the job
|
||||
duration so refreshing cannot reset it.
|
||||
|
||||
## Container
|
||||
|
||||
Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and
|
||||
proxies `/api` to the Docker Compose service `api:8080`.
|
||||
25
frontend/eslint.config.js
Normal file
25
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
)
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#081c2c" />
|
||||
<meta name="description" content="Go Guess recruitment assessment workspace" />
|
||||
<title>Go Guess · Recruitment workspace</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3937
frontend/package-lock.json
generated
Normal file
3937
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "go-guess-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^30.1.0",
|
||||
"prettier": "^3.9.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
395
frontend/src/App.test.tsx
Normal file
395
frontend/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
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'
|
||||
import type { Job, Participant } from './types'
|
||||
|
||||
const question = {
|
||||
id: 11,
|
||||
text: 'How do you design a resilient Go service?',
|
||||
type: 'open' as const,
|
||||
options: [],
|
||||
referenceAnswer: 'Use timeouts, retries, and idempotency.',
|
||||
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',
|
||||
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||
})
|
||||
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'],
|
||||
missingTraits: [],
|
||||
},
|
||||
])
|
||||
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([])
|
||||
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',
|
||||
participantUrl: 'https://example.test/interview/invite-token',
|
||||
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',
|
||||
referenceAnswer: 'Prefer bounded retries with jitter.',
|
||||
})
|
||||
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',
|
||||
JSON.stringify({ id: 1, email: 'admin@example.com', role: 'admin' }),
|
||||
)
|
||||
}
|
||||
|
||||
function renderApp(path = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Go Guess frontend', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
sessionStorage.clear()
|
||||
mockApi()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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.',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
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()
|
||||
renderApp('/questions')
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: `Edit ${question.text}` }))
|
||||
const prompt = screen.getByLabelText('Question prompt')
|
||||
await user.clear(prompt)
|
||||
await user.type(prompt, 'Updated resilience question')
|
||||
const reference = screen.getByLabelText('Reference answer')
|
||||
expect(reference).toHaveValue(question.referenceAnswer)
|
||||
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(
|
||||
'/api/questions/11',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
text: 'Updated resilience question',
|
||||
type: 'open',
|
||||
options: [],
|
||||
referenceAnswer: 'Prefer bounded retries with jitter.',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends invitations and displays the generated participant URL', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
renderApp('/jobs/7')
|
||||
|
||||
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
|
||||
await user.selectOptions(screen.getByLabelText('Eligible candidate'), '3')
|
||||
await user.click(screen.getByRole('button', { name: /send invitation/i }))
|
||||
|
||||
expect(
|
||||
await screen.findByText('https://example.test/interview/invite-token'),
|
||||
).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',
|
||||
participantUrl: 'https://example.test/interview/invite-token',
|
||||
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}`)
|
||||
})
|
||||
renderApp('/interview/invite-token')
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Accept and start' }))
|
||||
})
|
||||
expect(screen.getByText(question.text)).toBeInTheDocument()
|
||||
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
|
||||
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()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
2100
frontend/src/App.tsx
Normal file
2100
frontend/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
60
frontend/src/api.test.ts
Normal file
60
frontend/src/api.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, authStorage } from './api'
|
||||
|
||||
describe('API client', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('sends the persisted bearer token on protected requests', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.jobs.list()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/jobs',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer signed-jwt' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the backend participant multipart field names', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 4,
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
email: 'a@example.com',
|
||||
contactInfo: 'Brussels',
|
||||
traits: [],
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
}),
|
||||
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.participants.create({
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
birthday: '1990-01-02',
|
||||
email: 'a@example.com',
|
||||
contactInfo: 'Brussels',
|
||||
})
|
||||
|
||||
const options = fetchMock.mock.calls[0][1] as RequestInit
|
||||
const form = options.body as FormData
|
||||
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
|
||||
})
|
||||
})
|
||||
172
frontend/src/api.ts
Normal file
172
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type {
|
||||
AuthResponse,
|
||||
CandidateMatch,
|
||||
CreateJobInput,
|
||||
CreateParticipantInput,
|
||||
CreateQuestionInput,
|
||||
Interview,
|
||||
Invitation,
|
||||
Job,
|
||||
JobStatus,
|
||||
Participant,
|
||||
Question,
|
||||
} from './types'
|
||||
|
||||
const TOKEN_KEY = 'go-guess-token'
|
||||
const USER_KEY = 'go-guess-user'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const authStorage = {
|
||||
token: () => sessionStorage.getItem(TOKEN_KEY),
|
||||
user: () => {
|
||||
const value = sessionStorage.getItem(USER_KEY)
|
||||
return value ? (JSON.parse(value) as AuthResponse['user']) : null
|
||||
},
|
||||
save: (auth: AuthResponse) => {
|
||||
sessionStorage.setItem(TOKEN_KEY, auth.token)
|
||||
sessionStorage.setItem(USER_KEY, JSON.stringify(auth.user))
|
||||
},
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(USER_KEY)
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
protectedRequest = true,
|
||||
): Promise<T> {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
let details: unknown
|
||||
let message = `Request failed with status ${response.status}`
|
||||
try {
|
||||
details = await response.json()
|
||||
if (
|
||||
typeof details === 'object' &&
|
||||
details !== null &&
|
||||
'error' in details &&
|
||||
typeof details.error === 'string'
|
||||
) {
|
||||
message = details.error
|
||||
}
|
||||
} catch {
|
||||
details = undefined
|
||||
}
|
||||
if (response.status === 401 && protectedRequest) authStorage.clear()
|
||||
throw new ApiError(message, response.status, details)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function requestBlob(path: string): Promise<Blob> {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new ApiError(`Request failed with status ${response.status}`, response.status)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<AuthResponse>(
|
||||
'/auth/login',
|
||||
{ method: 'POST', body: JSON.stringify({ email, password }) },
|
||||
false,
|
||||
),
|
||||
jobs: {
|
||||
list: () => request<Job[]>('/jobs'),
|
||||
get: (id: number) => request<Job>(`/jobs/${id}`),
|
||||
create: (input: CreateJobInput) =>
|
||||
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
|
||||
request<Job>(`/jobs/${jobId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
attachQuestion: (jobId: number, questionId: number) =>
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detachQuestion: (jobId: number, questionId: number) =>
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
|
||||
candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`),
|
||||
invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`),
|
||||
invite: (jobId: number, participantId: number) =>
|
||||
request<Invitation>(`/jobs/${jobId}/invitations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ participantId }),
|
||||
}),
|
||||
},
|
||||
questions: {
|
||||
list: (search = '') =>
|
||||
request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`),
|
||||
create: (input: CreateQuestionInput) =>
|
||||
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: CreateQuestionInput) =>
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
setDeprecated: (id: number, deprecated: boolean) =>
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ deprecated }),
|
||||
}),
|
||||
},
|
||||
interviews: {
|
||||
get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false),
|
||||
accept: (token: string) =>
|
||||
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
|
||||
answer: (token: string, questionId: number, answer: string) =>
|
||||
request<void>(
|
||||
`/interviews/${token}/answers/${questionId}`,
|
||||
{ method: 'PUT', body: JSON.stringify({ answer }) },
|
||||
false,
|
||||
),
|
||||
finish: (token: string) =>
|
||||
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
|
||||
},
|
||||
participants: {
|
||||
list: () => request<Participant[]>('/participants'),
|
||||
get: (id: number) => request<Participant>(`/participants/${id}`),
|
||||
photo: (id: number) => requestBlob(`/participants/${id}/photo`),
|
||||
cv: (id: number) => requestBlob(`/participants/${id}/cv`),
|
||||
create: (input: CreateParticipantInput) => {
|
||||
const form = new FormData()
|
||||
form.append('firstName', input.firstName)
|
||||
form.append('lastName', input.lastName)
|
||||
form.append('birthday', input.birthday ?? '')
|
||||
form.append('email', input.email)
|
||||
form.append('contactInfo', input.contactInfo)
|
||||
if (input.photo) form.append('photo', input.photo)
|
||||
if (input.cv) form.append('cv', input.cv)
|
||||
return request<Participant>('/participants', { method: 'POST', body: form })
|
||||
},
|
||||
},
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
1668
frontend/src/styles.css
Normal file
1668
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
5
frontend/src/test/setup.ts
Normal file
5
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(cleanup)
|
||||
97
frontend/src/types.ts
Normal file
97
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
export type QuestionType = 'open' | 'multiple_choice' | 'radio' | 'code_review'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: number
|
||||
text: string
|
||||
type: QuestionType
|
||||
options: string[]
|
||||
referenceAnswer: string
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
export type JobStatus = 'draft' | 'published' | 'deprecated'
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
seniority: string
|
||||
position: string
|
||||
labels: string[]
|
||||
requiredSkills: string[]
|
||||
additionalSkills: string[]
|
||||
status: JobStatus
|
||||
durationMinutes: number
|
||||
questions: Question[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthday?: string
|
||||
email: string
|
||||
contactInfo: string
|
||||
photoUrl?: string
|
||||
cvFilename?: string
|
||||
cvUrl?: string
|
||||
traits: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CandidateMatch {
|
||||
participant: Participant
|
||||
score: number
|
||||
matchedTraits: string[]
|
||||
missingTraits: string[]
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: number
|
||||
jobId: number
|
||||
participantId: number
|
||||
participantName: string
|
||||
participantEmail: string
|
||||
token: string
|
||||
status: string
|
||||
participantUrl: string
|
||||
acceptedAt: string | null
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Interview {
|
||||
invitation: Invitation
|
||||
job: Pick<
|
||||
Job,
|
||||
'id' | 'title' | 'description' | 'position' | 'seniority' | 'durationMinutes' | 'questions'
|
||||
>
|
||||
answers: Record<string, string>
|
||||
}
|
||||
|
||||
export type CreateJobInput = Omit<
|
||||
Job,
|
||||
'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes'
|
||||
>
|
||||
export type CreateQuestionInput = Pick<Question, 'text' | 'type' | 'options' | 'referenceAnswer'>
|
||||
export interface CreateParticipantInput {
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthday?: string
|
||||
email: string
|
||||
contactInfo: string
|
||||
photo?: File
|
||||
cv?: File
|
||||
}
|
||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue