import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SharedQuizView } from '../../components/SharedQuizView';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
const mockAuthFetch = vi.fn();
const mockSigninRedirect = vi.fn();
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
useAuthenticatedFetch: () => ({
authFetch: mockAuthFetch,
isAuthenticated: true,
}),
}));
let mockIsAuthenticated = false;
vi.mock('react-oidc-context', () => ({
useAuth: () => ({
isAuthenticated: mockIsAuthenticated,
signinRedirect: mockSigninRedirect,
}),
}));
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockSharedQuiz = {
title: 'Shared Test Quiz',
source: 'manual' as const,
aiTopic: null,
gameConfig: null,
questions: [
{
id: 'q1',
text: 'What is 2 + 2?',
timeLimit: 20,
options: [
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
],
},
{
id: 'q2',
text: 'What is the capital of France?',
timeLimit: 15,
options: [
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue' },
],
},
],
questionCount: 2,
};
const renderWithRouter = (token: string = 'valid-token') => {
const onHostQuiz = vi.fn();
render(
} />
);
return { onHostQuiz };
};
describe('SharedQuizView', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAuthenticated = false;
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loading state', () => {
it('shows loading state while fetching quiz', async () => {
(global.fetch as ReturnType).mockImplementation(
() => new Promise(() => {})
);
renderWithRouter();
expect(screen.getByText('Loading shared quiz...')).toBeInTheDocument();
});
});
describe('error states', () => {
it('shows error when quiz not found (404)', async () => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: false,
status: 404,
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
expect(screen.getByText('This quiz is no longer available')).toBeInTheDocument();
});
});
it('shows generic error for other failures', async () => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: false,
status: 500,
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
expect(screen.getByText('Failed to load quiz')).toBeInTheDocument();
});
});
it('shows error on network failure', async () => {
(global.fetch as ReturnType).mockRejectedValueOnce(
new Error('Network error')
);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
});
});
it('navigates home when Go Home clicked on error', async () => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: false,
status: 404,
});
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Go Home')).toBeInTheDocument();
});
await user.click(screen.getByText('Go Home'));
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
describe('successful quiz load', () => {
beforeEach(() => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSharedQuiz),
});
});
it('displays quiz title', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Shared Test Quiz')).toBeInTheDocument();
});
});
it('displays question count', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/2 questions/)).toBeInTheDocument();
});
});
it('shows singular for 1 question', async () => {
(global.fetch as ReturnType).mockReset();
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ ...mockSharedQuiz, questionCount: 1, questions: [mockSharedQuiz.questions[0]] }),
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/1 question/)).toBeInTheDocument();
});
});
it('displays AI topic when present', async () => {
(global.fetch as ReturnType).mockReset();
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ ...mockSharedQuiz, source: 'ai_generated', aiTopic: 'Science' }),
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/Science/)).toBeInTheDocument();
});
});
it('shows Host Game button', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Host Game')).toBeInTheDocument();
});
});
it('calls onHostQuiz with quiz data when Host Game clicked', async () => {
const user = userEvent.setup();
const { onHostQuiz } = renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Host Game')).toBeInTheDocument();
});
await user.click(screen.getByText('Host Game'));
expect(onHostQuiz).toHaveBeenCalledWith({
title: 'Shared Test Quiz',
questions: mockSharedQuiz.questions,
config: undefined,
});
});
it('includes gameConfig in hosted quiz when present', async () => {
const gameConfig = { shuffleQuestions: true, shuffleAnswers: false };
(global.fetch as ReturnType).mockReset();
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ ...mockSharedQuiz, gameConfig }),
});
const user = userEvent.setup();
const { onHostQuiz } = renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Host Game')).toBeInTheDocument();
});
await user.click(screen.getByText('Host Game'));
expect(onHostQuiz).toHaveBeenCalledWith({
title: 'Shared Test Quiz',
questions: mockSharedQuiz.questions,
config: gameConfig,
});
});
it('shows Back to Home link', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('← Back to Home')).toBeInTheDocument();
});
});
it('navigates home when Back to Home clicked', async () => {
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('← Back to Home')).toBeInTheDocument();
});
await user.click(screen.getByText('← Back to Home'));
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
describe('question preview', () => {
beforeEach(() => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSharedQuiz),
});
});
it('shows Preview Questions button', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
});
});
it('expands questions when Preview Questions clicked', async () => {
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
});
await user.click(screen.getByText('Preview Questions'));
await waitFor(() => {
expect(screen.getByText('Hide Questions')).toBeInTheDocument();
expect(screen.getByText('Question 1')).toBeInTheDocument();
expect(screen.getByText('What is 2 + 2?')).toBeInTheDocument();
expect(screen.getByText('Question 2')).toBeInTheDocument();
expect(screen.getByText('What is the capital of France?')).toBeInTheDocument();
});
});
it('collapses questions when Hide Questions clicked', async () => {
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
});
await user.click(screen.getByText('Preview Questions'));
await waitFor(() => {
expect(screen.getByText('Hide Questions')).toBeInTheDocument();
});
await user.click(screen.getByText('Hide Questions'));
await waitFor(() => {
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
});
});
});
describe('save to library - unauthenticated user', () => {
beforeEach(() => {
mockIsAuthenticated = false;
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSharedQuiz),
});
});
it('shows Sign in to Save button for unauthenticated users', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
});
});
it('triggers sign in when Sign in to Save clicked', async () => {
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
});
await user.click(screen.getByText('Sign in to Save'));
expect(mockSigninRedirect).toHaveBeenCalled();
});
});
describe('save to library - authenticated user', () => {
beforeEach(() => {
mockIsAuthenticated = true;
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSharedQuiz),
});
});
it('shows Save to My Library button for authenticated users', async () => {
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
});
it('calls copy API when Save to My Library clicked', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'new-id', title: 'Shared Test Quiz' }),
});
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
await user.click(screen.getByText('Save to My Library'));
await waitFor(() => {
expect(mockAuthFetch).toHaveBeenCalledWith('/api/shared/valid-token/copy', {
method: 'POST',
});
});
});
it('shows saving state while copying', async () => {
let resolvePromise: (value: unknown) => void;
mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
resolvePromise = resolve;
}));
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
await user.click(screen.getByText('Save to My Library'));
await waitFor(() => {
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
resolvePromise!({
ok: true,
json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
});
});
it('shows success toast after saving', async () => {
const toast = await import('react-hot-toast');
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'new-id', title: 'My Quiz Copy' }),
});
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
await user.click(screen.getByText('Save to My Library'));
await waitFor(() => {
expect(toast.default.success).toHaveBeenCalledWith(
'"My Quiz Copy" saved to your library!'
);
});
});
it('shows error toast when save fails', async () => {
const toast = await import('react-hot-toast');
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
await user.click(screen.getByText('Save to My Library'));
await waitFor(() => {
expect(toast.default.error).toHaveBeenCalledWith(
'Failed to save quiz to library'
);
});
});
it('disables save button while saving', async () => {
let resolvePromise: (value: unknown) => void;
mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
resolvePromise = resolve;
}));
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
});
const saveButton = screen.getByText('Save to My Library').closest('button')!;
await user.click(saveButton);
await waitFor(() => {
const savingButton = screen.getByText('Saving...').closest('button')!;
expect(savingButton).toBeDisabled();
});
resolvePromise!({
ok: true,
json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
});
});
});
describe('fetches correct token from URL', () => {
it('fetches quiz with token from URL', async () => {
(global.fetch as ReturnType).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSharedQuiz),
});
renderWithRouter('my-special-token');
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/shared/my-special-token')
);
});
});
});
});