Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/frontend/src/screens/JoinQuiz.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span>Joined</span>
<pre data-testid="play-state">{JSON.stringify(location.state)}</pre>
</div>
);
}

function renderJoin() {
render(
<MemoryRouter initialEntries={['/join']}>
<Routes>
<Route path="/join" element={<JoinQuiz />} />
<Route path="/quiz/play/:sessionId" element={<PlayState />} />
</Routes>
</MemoryRouter>
);

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();
});
});
51 changes: 41 additions & 10 deletions apps/frontend/src/screens/JoinQuiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -71,10 +102,10 @@ function JoinQuiz() {
<input
type="text"
value={code}
onChange={e => 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
/>
</div>
Expand Down
Loading