import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useQuizLibrary } from '../../hooks/useQuizLibrary'; import type { Quiz } from '../../types'; const mockAuthFetch = vi.fn(); vi.mock('../../hooks/useAuthenticatedFetch', () => ({ useAuthenticatedFetch: () => ({ authFetch: mockAuthFetch, isAuthenticated: true, }), })); vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), error: vi.fn(), }, })); const createMockQuiz = (overrides?: Partial): Quiz => ({ title: 'Test Quiz', 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' }, { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: '6', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], ...overrides, }); describe('useQuizLibrary', () => { beforeEach(() => { vi.clearAllMocks(); mockAuthFetch.mockReset(); }); describe('fetchQuizzes', () => { it('fetches and stores quizzes', async () => { const mockQuizzes = [ { id: '1', title: 'Quiz 1', source: 'manual', questionCount: 5, createdAt: '2024-01-01', updatedAt: '2024-01-01' }, { id: '2', title: 'Quiz 2', source: 'ai_generated', questionCount: 10, createdAt: '2024-01-02', updatedAt: '2024-01-02' }, ]; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizzes), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); expect(result.current.quizzes).toEqual(mockQuizzes); expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); }); it('sets loading to true during fetch', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.fetchQuizzes(); }); await waitFor(() => { expect(result.current.loading).toBe(true); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve([]) }); }); await waitFor(() => { expect(result.current.loading).toBe(false); }); }); it('handles 500 server error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); expect(result.current.error).toBe('Server error. Please try again.'); expect(result.current.loading).toBe(false); }); it('handles generic error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 400, }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); expect(result.current.error).toBe('Failed to load your quizzes.'); }); }); describe('loadQuiz', () => { it('loads and returns a quiz', async () => { const mockQuiz = { id: 'quiz-123', title: 'Loaded Quiz', source: 'manual', questions: [], createdAt: '2024-01-01', updatedAt: '2024-01-01', }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuiz), }); const { result } = renderHook(() => useQuizLibrary()); let loadedQuiz; await act(async () => { loadedQuiz = await result.current.loadQuiz('quiz-123'); }); expect(loadedQuiz).toEqual(mockQuiz); expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123'); }); it('sets and clears loadingQuizId', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.loadQuiz('quiz-123'); }); await waitFor(() => { expect(result.current.loadingQuizId).toBe('quiz-123'); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({}) }); }); await waitFor(() => { expect(result.current.loadingQuizId).toBeNull(); }); }); it('handles 404 not found', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 404, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.loadQuiz('non-existent'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz not found. It may have been deleted.'); } expect(result.current.loadingQuizId).toBeNull(); }); it('handles generic error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.loadQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to load quiz.'); } }); }); describe('saveQuiz', () => { it('saves a quiz and returns the ID', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'new-quiz-id' }), }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); let savedId; await act(async () => { savedId = await result.current.saveQuiz(quiz, 'manual'); }); expect(savedId).toBe('new-quiz-id'); expect(result.current.saving).toBe(false); }); it('sends correct request body for manual quiz', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id' }), }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ title: 'Manual Quiz' }); await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); const [, options] = mockAuthFetch.mock.calls[0]; const body = JSON.parse(options.body); expect(body.title).toBe('Manual Quiz'); expect(body.source).toBe('manual'); expect(body.aiTopic).toBeUndefined(); }); it('sends aiTopic for AI generated quiz', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id' }), }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); await act(async () => { await result.current.saveQuiz(quiz, 'ai_generated', 'Science'); }); const [, options] = mockAuthFetch.mock.calls[0]; const body = JSON.parse(options.body); expect(body.source).toBe('ai_generated'); expect(body.aiTopic).toBe('Science'); }); it('includes gameConfig when present', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id' }), }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ config: { shuffleQuestions: true, shuffleAnswers: false, hostParticipates: true, randomNamesEnabled: false, streakBonusEnabled: false, streakThreshold: 3, streakMultiplier: 1.1, comebackBonusEnabled: false, comebackBonusPoints: 50, penaltyForWrongAnswer: false, penaltyPercent: 25, firstCorrectBonusEnabled: false, firstCorrectBonusPoints: 50, }, }); await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); const [, options] = mockAuthFetch.mock.calls[0]; const body = JSON.parse(options.body); expect(body.gameConfig.shuffleQuestions).toBe(true); }); it('rejects quiz without title', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ title: '' }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz must have a title'); } expect(mockAuthFetch).not.toHaveBeenCalled(); }); it('rejects quiz with whitespace-only title', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ title: ' ' }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz must have a title'); } }); it('rejects quiz without questions', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ questions: [] }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz must have at least one question'); } }); it('rejects quiz with question without text', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ questions: [{ id: 'q1', text: '', timeLimit: 20, options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }], }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('All questions must have text'); } }); it('rejects question with less than 2 options', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ questions: [{ id: 'q1', text: 'Question?', timeLimit: 20, options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, ], }], }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Each question must have at least 2 options'); } }); it('rejects question without correct answer', async () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz({ questions: [{ id: 'q1', text: 'Question?', timeLimit: 20, options: [ { text: 'A', isCorrect: false, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }], }); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Each question must have a correct answer'); } }); it('prevents double save when already saving', async () => { let resolveFirstSave: (value: unknown) => void; const firstSavePromise = new Promise((resolve) => { resolveFirstSave = resolve; }); mockAuthFetch.mockReturnValueOnce(firstSavePromise); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); let firstSaveResult: Promise; act(() => { firstSaveResult = result.current.saveQuiz(quiz, 'manual'); }); await waitFor(() => { expect(result.current.saving).toBe(true); }); let secondSaveError: Error | null = null; await act(async () => { try { await result.current.saveQuiz(quiz, 'manual'); } catch (e) { secondSaveError = e as Error; } }); expect(secondSaveError).not.toBeNull(); expect(secondSaveError!.message).toBe('Save already in progress'); expect(mockAuthFetch).toHaveBeenCalledTimes(1); await act(async () => { resolveFirstSave!({ ok: true, json: () => Promise.resolve({ id: 'test-id' }) }); await firstSaveResult!; }); expect(result.current.saving).toBe(false); }); it('handles 400 validation error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 400, }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Invalid quiz data. Please check and try again.'); } }); it('handles generic server error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); try { await act(async () => { await result.current.saveQuiz(quiz, 'manual'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to save quiz.'); } }); }); describe('deleteQuiz', () => { it('handles 404 not found', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 404, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.deleteQuiz('non-existent'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz not found.'); } }); it('handles generic server error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.deleteQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to delete quiz.'); } }); }); describe('updateQuizConfig', () => { it('throws error on failure', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.updateQuizConfig('quiz-123', {} as any); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to update config'); } }); }); describe('updateQuiz', () => { it('handles generic server error', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); try { await act(async () => { await result.current.updateQuiz('quiz-123', quiz); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to update quiz.'); } }); it('handles empty quiz ID', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 404, }); const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); try { await act(async () => { await result.current.updateQuiz('', quiz); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz not found.'); } }); }); });