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') ); }); }); }); });