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
/>