From 53350709d8726fdaed4d436a747731cca64469ac Mon Sep 17 00:00:00 2001 From: QvarcY Date: Sun, 27 Sep 2026 02:11:32 +0300 Subject: [PATCH] fix: normalize quiz join inputs --- apps/frontend/src/screens/JoinQuiz.test.tsx | 92 +++++++++++++++++++++ apps/frontend/src/screens/JoinQuiz.tsx | 51 +++++++++--- 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 apps/frontend/src/screens/JoinQuiz.test.tsx diff --git a/apps/frontend/src/screens/JoinQuiz.test.tsx b/apps/frontend/src/screens/JoinQuiz.test.tsx new file mode 100644 index 0000000..a172cc7 --- /dev/null +++ b/apps/frontend/src/screens/JoinQuiz.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import JoinQuiz from './JoinQuiz'; + +vi.mock('../services/api', () => ({ + api: { post: vi.fn() }, +})); + +vi.mock('../components/BgBoss', () => ({ + default: () => null, +})); + +const { api } = await import('../services/api'); + +function PlayState() { + const location = useLocation(); + + return ( +
+ Joined +
{JSON.stringify(location.state)}
+
+ ); +} + +function renderJoin() { + render( + + + } /> + } /> + + + ); + + return userEvent.setup(); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + + vi.mocked(api.post).mockResolvedValue({ + data: { + participantId: 'participant-1', + sessionId: 'session-1', + quizTitle: 'Test Quiz', + }, + }); +}); + +describe('JoinQuiz input normalization', () => { + it('normalizes a pasted PIN before applying the six-character limit and trims the nickname on submit', async () => { + const user = renderJoin(); + + const codeInput = screen.getByPlaceholderText('e.g. XY3F12'); + const nicknameInput = screen.getByPlaceholderText('Enter your name'); + + await user.click(codeInput); + await user.paste(' XY3F12 '); + + expect(codeInput).toHaveValue('XY3F12'); + + await user.type(nicknameInput, ' bob '); + await user.click(screen.getByRole('button', { name: 'Enter Game' })); + + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/session/join', { + code: 'XY3F12', + username: 'bob', + }) + ); + + expect(localStorage.getItem('username')).toBe('bob'); + + expect(await screen.findByText('Joined')).toBeInTheDocument(); + expect(screen.getByTestId('play-state')).toHaveTextContent('"username":"bob"'); + }); + + it('rejects a whitespace-only nickname without calling the join API', async () => { + const user = renderJoin(); + + await user.type(screen.getByPlaceholderText('e.g. XY3F12'), 'AB12CD'); + await user.type(screen.getByPlaceholderText('Enter your name'), ' '); + await user.click(screen.getByRole('button', { name: 'Enter Game' })); + + expect(await screen.findByText('Nickname cannot be empty')).toBeInTheDocument(); + expect(api.post).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/frontend/src/screens/JoinQuiz.tsx b/apps/frontend/src/screens/JoinQuiz.tsx index 9b2eb2f..528bed8 100644 --- a/apps/frontend/src/screens/JoinQuiz.tsx +++ b/apps/frontend/src/screens/JoinQuiz.tsx @@ -8,37 +8,68 @@ import { RiLoader2Fill } from 'react-icons/ri'; import BgBoss from '../components/BgBoss'; +const normalizeJoinCode = (value: string) => + value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 6); + +type JoinRequestError = { + response?: { + data?: { + error?: string; + }; + }; +}; function JoinQuiz() { const location = useLocation(); const navigate = useNavigate(); - const [code, setCode] = useState(location.state?.code || ''); + const [code, setCode] = useState(() => normalizeJoinCode(location.state?.code || '')); const [username, setUsername] = useState(location.state?.username || ''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const handleJoin = async (e: React.FormEvent) => { e.preventDefault(); - if (!code || !username) return; + const normalizedCode = normalizeJoinCode(code); + const normalizedUsername = username.trim(); + + if (!normalizedCode) { + setError('Game PIN is required'); + return; + } + + if (!normalizedUsername) { + setError('Nickname cannot be empty'); + return; + } + + setCode(normalizedCode); + setUsername(normalizedUsername); setError(''); setLoading(true); try { - const { data } = await api.post('/session/join', { code, username }); - + const { data } = await api.post('/session/join', { + code: normalizedCode, + username: normalizedUsername + }); localStorage.setItem("participantId", data.participantId); - localStorage.setItem("username", username); + localStorage.setItem("username", normalizedUsername); // Redirect to play arena with session/participant data navigate(`/quiz/play/${data.sessionId}`, { - state: { participantId: data.participantId, username, quizTitle: data.quizTitle } + state: { + participantId: data.participantId, + username: normalizedUsername, + quizTitle: data.quizTitle + } }); - } catch (err: any) { - setError(err.response?.data?.error || 'Failed to join quiz'); + } catch (err: unknown) { + const joinError = err as JoinRequestError; + setError(joinError.response?.data?.error || 'Failed to join quiz'); setLoading(false); } }; @@ -71,10 +102,10 @@ function JoinQuiz() { setCode(e.target.value.toUpperCase())} + onChange={e => setCode(normalizeJoinCode(e.target.value))} className="input-field border-2 border-pink-500/60 py-2 px-4 rounded-xl text-center text outline-none text-xl tracking-widest font-mono uppercase font-bold text-white placeholder:text-zinc-600" placeholder="e.g. XY3F12" - maxLength={6} + required />