import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useQuizLibrary } from '../../hooks/useQuizLibrary'; import type { Quiz, QuizListItem } 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.'); } }); }); describe('exportQuizzes', () => { let mockCreateObjectURL: ReturnType; let mockRevokeObjectURL: ReturnType; let mockClick: ReturnType; let capturedBlob: Blob | null = null; let originalCreateElement: typeof document.createElement; let originalCreateObjectURL: typeof URL.createObjectURL; let originalRevokeObjectURL: typeof URL.revokeObjectURL; beforeEach(() => { capturedBlob = null; mockCreateObjectURL = vi.fn((blob: Blob) => { capturedBlob = blob; return 'blob:test-url'; }); mockRevokeObjectURL = vi.fn(); mockClick = vi.fn(); originalCreateObjectURL = global.URL.createObjectURL; originalRevokeObjectURL = global.URL.revokeObjectURL; global.URL.createObjectURL = mockCreateObjectURL as typeof URL.createObjectURL; global.URL.revokeObjectURL = mockRevokeObjectURL as typeof URL.revokeObjectURL; originalCreateElement = document.createElement.bind(document); vi.spyOn(document, 'createElement').mockImplementation((tag) => { if (tag === 'a') { return { href: '', download: '', click: mockClick, } as unknown as HTMLAnchorElement; } return originalCreateElement(tag); }); vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node); vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => node); }); afterEach(() => { global.URL.createObjectURL = originalCreateObjectURL; global.URL.revokeObjectURL = originalRevokeObjectURL; vi.restoreAllMocks(); }); it('exports selected quizzes as JSON file', async () => { const mockQuizData = { id: 'quiz-1', title: 'Test Quiz', source: 'manual', questions: [ { id: 'q1', text: 'Question 1', timeLimit: 20, options: [] }, ], }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizData), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.exportQuizzes(['quiz-1']); }); expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-1'); expect(mockCreateObjectURL).toHaveBeenCalled(); expect(mockClick).toHaveBeenCalled(); expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url'); expect(result.current.exporting).toBe(false); }); it('exports multiple quizzes', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questions: [] }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'ai_generated', aiTopic: 'Science', questions: [] }), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.exportQuizzes(['quiz-1', 'quiz-2']); }); expect(mockAuthFetch).toHaveBeenCalledTimes(2); expect(mockCreateObjectURL).toHaveBeenCalled(); }); it('sets exporting to true during export', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.exportQuizzes(['quiz-1']); }); await waitFor(() => { expect(result.current.exporting).toBe(true); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'quiz-1', title: 'Q', source: 'manual', questions: [] }) }); }); await waitFor(() => { expect(result.current.exporting).toBe(false); }); }); it('handles empty quiz IDs array', async () => { const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.exportQuizzes([]); }); expect(mockAuthFetch).not.toHaveBeenCalled(); expect(mockCreateObjectURL).not.toHaveBeenCalled(); }); it('skips quizzes that fail to load', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: false, status: 404 }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questions: [] }), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.exportQuizzes(['quiz-1', 'quiz-2']); }); expect(mockCreateObjectURL).toHaveBeenCalled(); expect(capturedBlob).toBeInstanceOf(Blob); }); it('handles network error during export', async () => { mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.exportQuizzes(['quiz-1']); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Network error'); } expect(result.current.exporting).toBe(false); }); it('creates blob with correct mime type', async () => { const mockQuizData = { id: 'quiz-1', title: 'Test Quiz', source: 'ai_generated', aiTopic: 'History', gameConfig: { shuffleQuestions: true }, questions: [ { id: 'q1', text: 'What year?', timeLimit: 30, options: [ { text: '1990', isCorrect: false, shape: 'triangle', color: 'red' }, { text: '2000', isCorrect: true, shape: 'diamond', color: 'blue' }, ], }, ], }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizData), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.exportQuizzes(['quiz-1']); }); expect(capturedBlob).not.toBeNull(); expect(capturedBlob!.type).toBe('application/json'); }); }); describe('parseImportFile', () => { const originalText = File.prototype.text; beforeEach(() => { // JSDOM doesn't implement File.prototype.text, so define it using FileReader File.prototype.text = function() { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsText(this); }); }; }); afterEach(() => { File.prototype.text = originalText; }); it('parses valid export file', async () => { const validExportData = { version: 1, exportedAt: '2024-01-01T00:00:00.000Z', quizzes: [ { title: 'Quiz 1', source: 'manual', questions: [] }, ], }; const file = new File( [JSON.stringify(validExportData)], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); let parsed; await act(async () => { parsed = await result.current.parseImportFile(file); }); expect(parsed).toEqual(validExportData); }); it('rejects file without version field', async () => { const invalidData = { exportedAt: '2024-01-01T00:00:00.000Z', quizzes: [], }; const file = new File( [JSON.stringify(invalidData)], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.parseImportFile(file); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Invalid export file format'); } }); it('rejects file without quizzes array', async () => { const invalidData = { version: 1, exportedAt: '2024-01-01T00:00:00.000Z', }; const file = new File( [JSON.stringify(invalidData)], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.parseImportFile(file); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Invalid export file format'); } }); it('rejects file with quizzes as non-array', async () => { const invalidData = { version: 1, exportedAt: '2024-01-01T00:00:00.000Z', quizzes: 'not-an-array', }; const file = new File( [JSON.stringify(invalidData)], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.parseImportFile(file); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Invalid export file format'); } }); it('rejects invalid JSON', async () => { const file = new File( ['not valid json {'], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.parseImportFile(file); }); expect.fail('Should have thrown'); } catch (e) { expect(e).toBeInstanceOf(SyntaxError); } }); it('handles empty file', async () => { const file = new File( [''], 'export.json', { type: 'application/json' } ); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.parseImportFile(file); }); expect.fail('Should have thrown'); } catch (e) { expect(e).toBeInstanceOf(SyntaxError); } }); }); describe('importQuizzes', () => { it('imports single quiz successfully', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'new-quiz-1' }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]), }); const { result } = renderHook(() => useQuizLibrary()); const quizzesToImport = [ { title: 'Imported Quiz', source: 'manual' as const, questions: [ { id: 'q1', text: 'Question?', timeLimit: 20, options: [ { text: 'A', isCorrect: true, shape: 'triangle' as const, color: 'red' as const }, { text: 'B', isCorrect: false, shape: 'diamond' as const, color: 'blue' as const }, ], }, ], }, ]; await act(async () => { await result.current.importQuizzes(quizzesToImport); }); expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes', expect.objectContaining({ method: 'POST', })); expect(result.current.importing).toBe(false); }); it('imports multiple quizzes sequentially', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q2' }) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); const { result } = renderHook(() => useQuizLibrary()); const quizzesToImport = [ { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, { title: 'Quiz 2', source: 'ai_generated' as const, aiTopic: 'Science', questions: createMockQuiz().questions }, ]; await act(async () => { await result.current.importQuizzes(quizzesToImport); }); expect(mockAuthFetch).toHaveBeenCalledTimes(3); }); it('sets importing to true during import', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.importQuizzes([ { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, ]); }); await waitFor(() => { expect(result.current.importing).toBe(true); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'id' }) }); }); }); it('handles empty quizzes array', async () => { const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.importQuizzes([]); }); expect(mockAuthFetch).not.toHaveBeenCalled(); }); it('reports partial success when some imports fail', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) .mockResolvedValueOnce({ ok: false, status: 400 }); const { result } = renderHook(() => useQuizLibrary()); const quizzesToImport = [ { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, { title: 'Quiz 2', source: 'manual' as const, questions: createMockQuiz().questions }, ]; try { await act(async () => { await result.current.importQuizzes(quizzesToImport); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toContain('Invalid quiz data'); } expect(result.current.importing).toBe(false); }); it('refreshes quiz list after successful import', async () => { const mockQuizList = [{ id: 'q1', title: 'Quiz 1', source: 'manual', questionCount: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }]; mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizList) }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.importQuizzes([ { title: 'Quiz 1', source: 'manual', questions: createMockQuiz().questions }, ]); }); expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/quizzes'); expect(result.current.quizzes).toEqual(mockQuizList); }); it('preserves aiTopic for AI-generated quizzes', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.importQuizzes([ { title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Space', questions: createMockQuiz().questions }, ]); }); const [, options] = mockAuthFetch.mock.calls[0]; const body = JSON.parse(options.body); expect(body.source).toBe('ai_generated'); expect(body.aiTopic).toBe('Space'); }); it('preserves game config during import', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); const { result } = renderHook(() => useQuizLibrary()); const config = { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: false, randomNamesEnabled: true, streakBonusEnabled: true, streakThreshold: 5, streakMultiplier: 1.5, comebackBonusEnabled: false, comebackBonusPoints: 100, penaltyForWrongAnswer: true, penaltyPercent: 10, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 25, }; await act(async () => { await result.current.importQuizzes([ { title: 'Quiz', source: 'manual', config, questions: createMockQuiz().questions }, ]); }); const [, options] = mockAuthFetch.mock.calls[0]; const body = JSON.parse(options.body); expect(body.gameConfig).toEqual(config); }); it('handles network error during import', async () => { mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.importQuizzes([ { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, ]); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Network error'); } expect(result.current.importing).toBe(false); }); }); describe('shareQuiz', () => { it('shares a quiz and returns the share token', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'abc123token' }), }); const { result } = renderHook(() => useQuizLibrary()); let token; await act(async () => { token = await result.current.shareQuiz('quiz-123'); }); expect(token).toBe('abc123token'); expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', { method: 'POST', }); }); it('sets and clears sharingQuizId during share operation', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.shareQuiz('quiz-123'); }); await waitFor(() => { expect(result.current.sharingQuizId).toBe('quiz-123'); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ shareToken: 'token' }) }); }); await waitFor(() => { expect(result.current.sharingQuizId).toBeNull(); }); }); it('updates quiz in local state with shareToken and isShared', async () => { const initialQuizzes: QuizListItem[] = [ { id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, isShared: false, createdAt: '2024-01-01', updatedAt: '2024-01-01' }, ]; mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'newtoken' }) }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); await act(async () => { await result.current.shareQuiz('quiz-123'); }); expect(result.current.quizzes[0].shareToken).toBe('newtoken'); expect(result.current.quizzes[0].isShared).toBe(true); }); it('handles 404 not found when sharing non-existent quiz', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 404, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.shareQuiz('non-existent'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz not found.'); } expect(result.current.sharingQuizId).toBeNull(); }); it('handles generic server error when sharing', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.shareQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to share quiz.'); } }); it('handles network error when sharing', async () => { mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.shareQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Network error'); } expect(result.current.sharingQuizId).toBeNull(); }); it('returns existing token if quiz is already shared', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'existing-token' }), }); const { result } = renderHook(() => useQuizLibrary()); let token; await act(async () => { token = await result.current.shareQuiz('quiz-123'); }); expect(token).toBe('existing-token'); }); }); describe('unshareQuiz', () => { it('unshares a quiz successfully', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.unshareQuiz('quiz-123'); }); expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', { method: 'DELETE', }); }); it('sets and clears sharingQuizId during unshare operation', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); act(() => { result.current.unshareQuiz('quiz-123'); }); await waitFor(() => { expect(result.current.sharingQuizId).toBe('quiz-123'); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true }) }); }); await waitFor(() => { expect(result.current.sharingQuizId).toBeNull(); }); }); it('updates quiz in local state to remove shareToken and set isShared false', async () => { const initialQuizzes: QuizListItem[] = [ { id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, shareToken: 'oldtoken', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' }, ]; mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); expect(result.current.quizzes[0].isShared).toBe(true); await act(async () => { await result.current.unshareQuiz('quiz-123'); }); expect(result.current.quizzes[0].shareToken).toBeUndefined(); expect(result.current.quizzes[0].isShared).toBe(false); }); it('handles 404 not found when unsharing non-existent quiz', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 404, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.unshareQuiz('non-existent'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Quiz not found.'); } expect(result.current.sharingQuizId).toBeNull(); }); it('handles generic server error when unsharing', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.unshareQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Failed to stop sharing quiz.'); } }); it('handles network error when unsharing', async () => { mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useQuizLibrary()); try { await act(async () => { await result.current.unshareQuiz('quiz-123'); }); expect.fail('Should have thrown'); } catch (e) { expect((e as Error).message).toBe('Network error'); } expect(result.current.sharingQuizId).toBeNull(); }); it('does not affect other quizzes in the list when unsharing', async () => { const initialQuizzes: QuizListItem[] = [ { id: 'quiz-1', title: 'Quiz 1', source: 'manual', questionCount: 5, shareToken: 'token1', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' }, { id: 'quiz-2', title: 'Quiz 2', source: 'manual', questionCount: 3, shareToken: 'token2', isShared: true, createdAt: '2024-01-02', updatedAt: '2024-01-02' }, ]; mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) }); const { result } = renderHook(() => useQuizLibrary()); await act(async () => { await result.current.fetchQuizzes(); }); await act(async () => { await result.current.unshareQuiz('quiz-1'); }); expect(result.current.quizzes[0].isShared).toBe(false); expect(result.current.quizzes[1].isShared).toBe(true); expect(result.current.quizzes[1].shareToken).toBe('token2'); }); }); describe('fetchQuizzes with sharing info', () => { it('fetches quizzes with isShared and shareToken fields', async () => { const mockQuizzes: QuizListItem[] = [ { id: '1', title: 'Shared Quiz', source: 'manual', questionCount: 5, shareToken: 'token123', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' }, { id: '2', title: 'Private Quiz', source: 'ai_generated', questionCount: 10, isShared: false, 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[0].isShared).toBe(true); expect(result.current.quizzes[0].shareToken).toBe('token123'); expect(result.current.quizzes[1].isShared).toBe(false); expect(result.current.quizzes[1].shareToken).toBeUndefined(); }); }); });