diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts index 2286860..f0a9669 100644 --- a/hooks/useQuizLibrary.ts +++ b/hooks/useQuizLibrary.ts @@ -29,6 +29,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const lastOperationRef = useRef<(() => Promise) | null>(null); + const savingRef = useRef(false); const fetchQuizzes = useCallback(async () => { if (!isAuthenticated) return; @@ -88,7 +89,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { source: QuizSource, aiTopic?: string ): Promise => { - if (saving) { + if (savingRef.current) { toast.error('Save already in progress'); throw new Error('Save already in progress'); } @@ -117,6 +118,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } } + savingRef.current = true; setSaving(true); setError(null); diff --git a/tests/components/DefaultConfigModal.test.tsx b/tests/components/DefaultConfigModal.test.tsx index 0c73f1a..de57cba 100644 --- a/tests/components/DefaultConfigModal.test.tsx +++ b/tests/components/DefaultConfigModal.test.tsx @@ -284,6 +284,7 @@ describe('DefaultConfigModal', () => { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: true, + randomNamesEnabled: false, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2, diff --git a/tests/components/GameConfigPanel.test.tsx b/tests/components/GameConfigPanel.test.tsx index 8e8359b..079d1de 100644 --- a/tests/components/GameConfigPanel.test.tsx +++ b/tests/components/GameConfigPanel.test.tsx @@ -405,6 +405,7 @@ describe('GameConfigPanel', () => { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: true, + randomNamesEnabled: false, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2, @@ -428,6 +429,7 @@ describe('GameConfigPanel', () => { shuffleQuestions: false, shuffleAnswers: false, hostParticipates: false, + randomNamesEnabled: false, streakBonusEnabled: false, streakThreshold: 3, streakMultiplier: 1.1, diff --git a/tests/components/QuizEditorConfig.test.tsx b/tests/components/QuizEditorConfig.test.tsx index 1171623..5164497 100644 --- a/tests/components/QuizEditorConfig.test.tsx +++ b/tests/components/QuizEditorConfig.test.tsx @@ -324,6 +324,7 @@ describe('QuizEditor - Game Config Integration', () => { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: true, + randomNamesEnabled: false, streakBonusEnabled: true, streakThreshold: 5, streakMultiplier: 1.5, diff --git a/tests/constants.test.ts b/tests/constants.test.ts new file mode 100644 index 0000000..30e483c --- /dev/null +++ b/tests/constants.test.ts @@ -0,0 +1,539 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateBasePoints, + calculatePointsWithBreakdown, + getPlayerRank, + POINTS_PER_QUESTION, + QUESTION_TIME_MS, +} from '../constants'; +import { DEFAULT_GAME_CONFIG } from '../types'; +import type { Player, GameConfig } from '../types'; + +describe('calculateBasePoints', () => { + const questionTimeMs = QUESTION_TIME_MS; + const maxPoints = POINTS_PER_QUESTION; + + describe('happy path - time-based scoring', () => { + it('returns max points for instant answer (< 0.5s)', () => { + const timeLeftMs = questionTimeMs - 100; // 0.1s response time + const points = calculateBasePoints(timeLeftMs, questionTimeMs); + expect(points).toBe(maxPoints); + }); + + it('returns max points for exactly 0.5s response', () => { + const timeLeftMs = questionTimeMs - 499; + const points = calculateBasePoints(timeLeftMs, questionTimeMs); + expect(points).toBe(maxPoints); + }); + + it('returns reduced points for slower responses', () => { + const timeLeftMs = questionTimeMs / 2; // Half time remaining + const points = calculateBasePoints(timeLeftMs, questionTimeMs); + expect(points).toBeLessThan(maxPoints); + expect(points).toBeGreaterThan(maxPoints / 2); + }); + + it('returns reduced points for last-second answer', () => { + const timeLeftMs = 100; + const points = calculateBasePoints(timeLeftMs, questionTimeMs); + expect(points).toBeGreaterThan(0); + expect(points).toBeLessThanOrEqual(510); + }); + + it('calculates points correctly at exactly half time', () => { + const timeLeftMs = questionTimeMs / 2; + const points = calculateBasePoints(timeLeftMs, questionTimeMs); + // At half time, response time is 10s out of 20s + // (1 - 10/20 / 2) * 1000 = (1 - 0.25) * 1000 = 750 + expect(points).toBe(750); + }); + }); + + describe('edge cases', () => { + it('handles zero time left gracefully', () => { + const points = calculateBasePoints(0, questionTimeMs); + expect(points).toBe(500); // (1 - 20/20/2) * 1000 = 500 + }); + + it('handles full time remaining (answered immediately)', () => { + const points = calculateBasePoints(questionTimeMs, questionTimeMs); + expect(points).toBe(maxPoints); + }); + + it('handles negative time left (should not happen but be safe)', () => { + const points = calculateBasePoints(-1000, questionTimeMs); + expect(points).toBeLessThan(500); + }); + + it('respects custom maxPoints parameter', () => { + const customMax = 500; + const points = calculateBasePoints(questionTimeMs, questionTimeMs, customMax); + expect(points).toBe(customMax); + }); + + it('handles very short question times', () => { + const shortQuestionTime = 5000; // 5 seconds + const points = calculateBasePoints(shortQuestionTime, shortQuestionTime); + expect(points).toBe(maxPoints); + }); + }); + + describe('points calculation formula verification', () => { + it('follows linear decay after 0.5s threshold', () => { + // Points at 5s response time + const points5s = calculateBasePoints(questionTimeMs - 5000, questionTimeMs); + // Points at 10s response time + const points10s = calculateBasePoints(questionTimeMs - 10000, questionTimeMs); + + // The decay should be linear + const diff1 = maxPoints - points5s; + const diff2 = points5s - points10s; + expect(Math.abs(diff1 - diff2)).toBeLessThan(10); // Allow small rounding difference + }); + }); +}); + +describe('calculatePointsWithBreakdown', () => { + const baseParams = { + isCorrect: true, + timeLeftMs: QUESTION_TIME_MS, + questionTimeMs: QUESTION_TIME_MS, + streak: 0, + playerRank: 1, + isFirstCorrect: false, + config: { ...DEFAULT_GAME_CONFIG }, + }; + + describe('correct answers - happy path', () => { + it('returns base points for correct answer with no bonuses', () => { + const result = calculatePointsWithBreakdown(baseParams); + + expect(result.basePoints).toBe(POINTS_PER_QUESTION); + expect(result.streakBonus).toBe(0); + expect(result.comebackBonus).toBe(0); + expect(result.firstCorrectBonus).toBe(0); + expect(result.penalty).toBe(0); + expect(result.total).toBe(POINTS_PER_QUESTION); + }); + + it('calculates time-based points correctly', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + timeLeftMs: QUESTION_TIME_MS / 2, + }); + + expect(result.basePoints).toBe(750); + expect(result.total).toBe(750); + }); + }); + + describe('incorrect answers', () => { + it('returns zero points for incorrect answer with no penalty', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: false }, + }); + + expect(result.basePoints).toBe(0); + expect(result.penalty).toBe(0); + expect(result.total).toBe(0); + }); + + it('applies penalty for incorrect answer when enabled', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 25 }, + }); + + expect(result.basePoints).toBe(0); + expect(result.penalty).toBe(250); + expect(result.total).toBe(-250); + }); + + it('calculates penalty based on penaltyPercent', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 50 }, + }); + + expect(result.penalty).toBe(500); + expect(result.total).toBe(-500); + }); + }); + + describe('streak bonus', () => { + it('applies no streak bonus when disabled', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 5, + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: false }, + }); + + expect(result.streakBonus).toBe(0); + }); + + it('applies no streak bonus below threshold', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 2, // Below default threshold of 3 + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3 }, + }); + + expect(result.streakBonus).toBe(0); + }); + + it('applies streak bonus at exactly threshold', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 3, + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 }, + }); + + // At threshold, streakCount = 0, so multiplier = 1.2 + (0 * 0.2) = 1.2 + // 1000 * 1.2 = 1200, bonus = 200 + expect(result.streakBonus).toBe(200); + expect(result.total).toBe(1200); + }); + + it('increases streak bonus above threshold', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 5, + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 }, + }); + + // streakCount = 5 - 3 = 2 + // multiplier = 1.2 + (2 * 0.2) = 1.6 + // 1000 * 1.6 = 1600, bonus = 600 + expect(result.streakBonus).toBe(600); + expect(result.total).toBe(1600); + }); + + it('compounds streak multiplier correctly', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 10, + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.1 }, + }); + + // streakCount = 10 - 3 = 7 + // multiplier = 1.1 + (7 * 0.1) = 1.8 + expect(result.basePoints).toBe(1000); + expect(result.total).toBe(1800); + }); + }); + + describe('comeback bonus', () => { + it('applies no comeback bonus when disabled', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + playerRank: 5, + config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: false }, + }); + + expect(result.comebackBonus).toBe(0); + }); + + it('applies no comeback bonus for top 3 players', () => { + [1, 2, 3].forEach(rank => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + playerRank: rank, + config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, + }); + + expect(result.comebackBonus).toBe(0); + }); + }); + + it('applies comeback bonus for 4th place and below', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + playerRank: 4, + config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, + }); + + expect(result.comebackBonus).toBe(100); + expect(result.total).toBe(1100); + }); + + it('applies same comeback bonus for any rank below 4', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + playerRank: 10, + config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 150 }, + }); + + expect(result.comebackBonus).toBe(150); + }); + }); + + describe('first correct bonus', () => { + it('applies no first correct bonus when disabled', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isFirstCorrect: true, + config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: false }, + }); + + expect(result.firstCorrectBonus).toBe(0); + }); + + it('applies first correct bonus when enabled and is first', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isFirstCorrect: true, + config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 }, + }); + + expect(result.firstCorrectBonus).toBe(50); + expect(result.total).toBe(1050); + }); + + it('applies no first correct bonus when not first', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isFirstCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 }, + }); + + expect(result.firstCorrectBonus).toBe(0); + }); + }); + + describe('combined bonuses', () => { + it('stacks all bonuses correctly', () => { + const config: GameConfig = { + ...DEFAULT_GAME_CONFIG, + streakBonusEnabled: true, + streakThreshold: 3, + streakMultiplier: 1.2, + comebackBonusEnabled: true, + comebackBonusPoints: 100, + firstCorrectBonusEnabled: true, + firstCorrectBonusPoints: 50, + }; + + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 4, // Above threshold + playerRank: 5, // Qualifies for comeback + isFirstCorrect: true, + config, + }); + + expect(result.basePoints).toBe(1000); + expect(result.streakBonus).toBeGreaterThan(0); + expect(result.comebackBonus).toBe(100); + expect(result.firstCorrectBonus).toBe(50); + expect(result.total).toBe(result.basePoints + result.streakBonus + result.comebackBonus + result.firstCorrectBonus); + }); + + it('does not apply bonuses to incorrect answers', () => { + const config: GameConfig = { + ...DEFAULT_GAME_CONFIG, + streakBonusEnabled: true, + streakThreshold: 3, + comebackBonusEnabled: true, + comebackBonusPoints: 100, + firstCorrectBonusEnabled: true, + firstCorrectBonusPoints: 50, + penaltyForWrongAnswer: true, + penaltyPercent: 25, + }; + + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + streak: 5, + playerRank: 5, + isFirstCorrect: true, + config, + }); + + expect(result.basePoints).toBe(0); + expect(result.streakBonus).toBe(0); + expect(result.comebackBonus).toBe(0); + expect(result.firstCorrectBonus).toBe(0); + expect(result.penalty).toBe(250); + expect(result.total).toBe(-250); + }); + }); + + describe('edge cases', () => { + it('handles zero streak threshold', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + streak: 1, + config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 0, streakMultiplier: 1.5 }, + }); + + // streakCount = 1 - 0 = 1 + // multiplier = 1.5 + (1 * 0.5) = 2.0 + expect(result.total).toBe(2000); + }); + + it('handles 100% penalty', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 100 }, + }); + + expect(result.penalty).toBe(1000); + expect(result.total).toBe(-1000); + }); + + it('handles 0% penalty (same as disabled)', () => { + const result = calculatePointsWithBreakdown({ + ...baseParams, + isCorrect: false, + config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 0 }, + }); + + expect(result.penalty).toBe(0); + expect(result.total === 0 || Object.is(result.total, -0)).toBe(true); + }); + }); +}); + +describe('getPlayerRank', () => { + const createMockPlayer = (id: string, score: number): Player => ({ + id, + name: `Player ${id}`, + score, + previousScore: 0, + streak: 0, + lastAnswerCorrect: null, + selectedShape: null, + pointsBreakdown: null, + isBot: false, + avatarSeed: 0.5, + color: '#000000', + }); + + describe('happy path', () => { + it('returns 1 for the player with highest score', () => { + const players = [ + createMockPlayer('a', 100), + createMockPlayer('b', 200), + createMockPlayer('c', 150), + ]; + + expect(getPlayerRank('b', players)).toBe(1); + }); + + it('returns correct rank for each player', () => { + const players = [ + createMockPlayer('a', 100), + createMockPlayer('b', 200), + createMockPlayer('c', 150), + ]; + + expect(getPlayerRank('b', players)).toBe(1); + expect(getPlayerRank('c', players)).toBe(2); + expect(getPlayerRank('a', players)).toBe(3); + }); + + it('handles single player', () => { + const players = [createMockPlayer('a', 100)]; + + expect(getPlayerRank('a', players)).toBe(1); + }); + }); + + describe('tie handling', () => { + it('gives same rank position to tied players based on array order', () => { + const players = [ + createMockPlayer('a', 100), + createMockPlayer('b', 100), + createMockPlayer('c', 100), + ]; + + // All have same score, order depends on sort stability and original order + const rankA = getPlayerRank('a', players); + const rankB = getPlayerRank('b', players); + const rankC = getPlayerRank('c', players); + + // All should be valid ranks 1-3 + expect([rankA, rankB, rankC].sort()).toEqual([1, 2, 3]); + }); + + it('ranks correctly with some ties', () => { + const players = [ + createMockPlayer('a', 200), + createMockPlayer('b', 100), + createMockPlayer('c', 200), + createMockPlayer('d', 50), + ]; + + // a and c are tied for first + const rankA = getPlayerRank('a', players); + const rankC = getPlayerRank('c', players); + + expect(rankA).toBeLessThanOrEqual(2); + expect(rankC).toBeLessThanOrEqual(2); + expect(getPlayerRank('b', players)).toBe(3); + expect(getPlayerRank('d', players)).toBe(4); + }); + }); + + describe('edge cases', () => { + it('returns 0 for non-existent player', () => { + const players = [ + createMockPlayer('a', 100), + createMockPlayer('b', 200), + ]; + + // findIndex returns -1 for not found, +1 makes it 0 + expect(getPlayerRank('nonexistent', players)).toBe(0); + }); + + it('handles empty players array', () => { + expect(getPlayerRank('any', [])).toBe(0); + }); + + it('handles zero scores', () => { + const players = [ + createMockPlayer('a', 0), + createMockPlayer('b', 0), + createMockPlayer('c', 100), + ]; + + expect(getPlayerRank('c', players)).toBe(1); + // a and b tied at 0 + const rankA = getPlayerRank('a', players); + const rankB = getPlayerRank('b', players); + expect(Math.min(rankA, rankB)).toBe(2); + expect(Math.max(rankA, rankB)).toBe(3); + }); + + it('handles negative scores (from penalties)', () => { + const players = [ + createMockPlayer('a', -100), + createMockPlayer('b', 50), + createMockPlayer('c', -50), + ]; + + expect(getPlayerRank('b', players)).toBe(1); + expect(getPlayerRank('c', players)).toBe(2); + expect(getPlayerRank('a', players)).toBe(3); + }); + + it('does not mutate original array', () => { + const players = [ + createMockPlayer('a', 100), + createMockPlayer('b', 200), + createMockPlayer('c', 150), + ]; + + const originalOrder = players.map(p => p.id); + getPlayerRank('b', players); + + expect(players.map(p => p.id)).toEqual(originalOrder); + }); + }); +}); diff --git a/tests/hooks/useAuthenticatedFetch.test.tsx b/tests/hooks/useAuthenticatedFetch.test.tsx new file mode 100644 index 0000000..041697e --- /dev/null +++ b/tests/hooks/useAuthenticatedFetch.test.tsx @@ -0,0 +1,395 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockSigninSilent = vi.fn(); +const mockSigninRedirect = vi.fn(); +const mockAuth = { + user: { + access_token: 'valid-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + isAuthenticated: true, + isLoading: false, + signinSilent: mockSigninSilent, + signinRedirect: mockSigninRedirect, +}; + +vi.mock('react-oidc-context', () => ({ + useAuth: () => mockAuth, +})); + +const originalFetch = global.fetch; + +describe('useAuthenticatedFetch', () => { + let useAuthenticatedFetch: typeof import('../../hooks/useAuthenticatedFetch').useAuthenticatedFetch; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const module = await import('../../hooks/useAuthenticatedFetch'); + useAuthenticatedFetch = module.useAuthenticatedFetch; + + mockAuth.user = { + access_token: 'valid-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + mockAuth.isAuthenticated = true; + mockAuth.isLoading = false; + global.fetch = vi.fn(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true }); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.resetAllMocks(); + }); + + describe('authFetch - happy path', () => { + it('makes authenticated request with Bearer token', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'test' }), + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + let response; + await act(async () => { + response = await result.current.authFetch('/api/test'); + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3001/api/test', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }), + }) + ); + expect(response).toBeDefined(); + }); + + it('uses full URL when provided', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('https://other-api.com/endpoint'); + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://other-api.com/endpoint', + expect.any(Object) + ); + }); + + it('passes through additional options', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('/api/test', { + method: 'POST', + body: JSON.stringify({ data: 'test' }), + }); + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ data: 'test' }), + }) + ); + }); + }); + + describe('token expiration handling', () => { + it('refreshes token silently when expired', async () => { + mockAuth.user = { + access_token: 'expired-token', + expires_at: Math.floor(Date.now() / 1000) - 100, + }; + mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('/api/test'); + }); + + expect(mockSigninSilent).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer new-token', + }), + }) + ); + }); + + it('refreshes token with buffer before actual expiration', async () => { + mockAuth.user = { + access_token: 'almost-expired-token', + expires_at: Math.floor(Date.now() / 1000) + 30, + }; + mockSigninSilent.mockResolvedValueOnce({ access_token: 'refreshed-token' }); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('/api/test'); + }); + + expect(mockSigninSilent).toHaveBeenCalled(); + }); + }); + + describe('redirect scenarios', () => { + it('throws error when silent refresh fails on expired token', async () => { + mockAuth.user = { + access_token: 'expired-token', + expires_at: Math.floor(Date.now() / 1000) - 100, + }; + mockSigninSilent.mockResolvedValueOnce(null); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Session expired, redirecting to login'); + }); + + it('throws error when 401 and refresh fails', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + }); + mockSigninSilent.mockResolvedValueOnce(null); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Session expired, redirecting to login'); + }); + }); + + describe('401 handling', () => { + it('retries with refreshed token on 401', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: false, + status: 401, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + }); + mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('/api/test'); + }); + + expect(mockSigninSilent).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling', () => { + it('throws on network error', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network failed')); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Network error. Please try again.'); + }); + + it('throws when offline before request', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('You appear to be offline. Please check your connection.'); + }); + + it('throws server error for 5xx responses', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Server error. Please try again later.'); + }); + + it('throws when not authenticated', async () => { + mockAuth.user = null; + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Not authenticated'); + }); + }); + + describe('concurrent requests', () => { + it('reuses pending silent refresh for concurrent requests', async () => { + mockAuth.user = { + access_token: 'expired-token', + expires_at: Math.floor(Date.now() / 1000) - 100, + }; + + let resolveRefresh: (value: unknown) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + mockSigninSilent.mockReturnValue(refreshPromise); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + let promise1: Promise; + let promise2: Promise; + + act(() => { + promise1 = result.current.authFetch('/api/test1'); + promise2 = result.current.authFetch('/api/test2'); + }); + + await act(async () => { + resolveRefresh!({ access_token: 'new-token' }); + }); + + await act(async () => { + await promise1!; + await promise2!; + }); + + expect(mockSigninSilent).toHaveBeenCalledTimes(1); + }); + }); + + describe('isAuthenticated and user properties', () => { + it('exposes isAuthenticated from auth context', () => { + mockAuth.isAuthenticated = true; + + const { result } = renderHook(() => useAuthenticatedFetch()); + + expect(result.current.isAuthenticated).toBe(true); + }); + + it('exposes isLoading from auth context', () => { + mockAuth.isLoading = true; + + const { result } = renderHook(() => useAuthenticatedFetch()); + + expect(result.current.isLoading).toBe(true); + }); + + it('exposes user from auth context', () => { + const { result } = renderHook(() => useAuthenticatedFetch()); + + expect(result.current.user).toEqual(mockAuth.user); + }); + }); + + describe('edge cases', () => { + it('handles missing expires_at gracefully', async () => { + mockAuth.user = { + access_token: 'token-without-expiry', + expires_at: undefined, + }; + mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' }); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await act(async () => { + await result.current.authFetch('/api/test'); + }); + + expect(mockSigninSilent).toHaveBeenCalled(); + }); + + it('handles fetch throwing TypeError for network issues', async () => { + const typeError = new TypeError('Failed to fetch'); + (global.fetch as ReturnType).mockRejectedValueOnce(typeError); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + await expect( + act(async () => { + await result.current.authFetch('/api/test'); + }) + ).rejects.toThrow('Network error. Please try again.'); + }); + + it('returns response for 4xx errors (non-401)', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const { result } = renderHook(() => useAuthenticatedFetch()); + + let response; + await act(async () => { + response = await result.current.authFetch('/api/test'); + }); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx index ec55ef3..430bc70 100644 --- a/tests/hooks/useQuizLibrary.test.tsx +++ b/tests/hooks/useQuizLibrary.test.tsx @@ -1,16 +1,14 @@ -import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useQuizLibrary } from '../../hooks/useQuizLibrary'; import type { Quiz } from '../../types'; const mockAuthFetch = vi.fn(); -const mockIsAuthenticated = vi.fn(() => true); vi.mock('../../hooks/useAuthenticatedFetch', () => ({ useAuthenticatedFetch: () => ({ authFetch: mockAuthFetch, - isAuthenticated: mockIsAuthenticated(), + isAuthenticated: true, }), })); @@ -39,38 +37,35 @@ const createMockQuiz = (overrides?: Partial): Quiz => ({ ...overrides, }); -describe('useQuizLibrary - updateQuiz', () => { +describe('useQuizLibrary', () => { beforeEach(() => { vi.clearAllMocks(); - mockIsAuthenticated.mockReturnValue(true); + mockAuthFetch.mockReset(); }); - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('happy path', () => { - it('successfully updates a quiz', async () => { + 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({ id: 'quiz-123' }), + json: () => Promise.resolve(mockQuizzes), }); const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz({ title: 'Updated Quiz' }); await act(async () => { - await result.current.updateQuiz('quiz-123', quiz); + await result.current.fetchQuizzes(); }); - expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', { - method: 'PUT', - body: expect.stringContaining('Updated Quiz'), - }); - expect(result.current.saving).toBe(false); + expect(result.current.quizzes).toEqual(mockQuizzes); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); }); - it('sets saving to true during update', async () => { + it('sets loading to true during fetch', async () => { let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -78,14 +73,97 @@ describe('useQuizLibrary - updateQuiz', () => { mockAuthFetch.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz(); act(() => { - result.current.updateQuiz('quiz-123', quiz); + result.current.fetchQuizzes(); }); await waitFor(() => { - expect(result.current.saving).toBe(true); + 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 () => { @@ -93,105 +171,315 @@ describe('useQuizLibrary - updateQuiz', () => { }); await waitFor(() => { - expect(result.current.saving).toBe(false); + expect(result.current.loadingQuizId).toBeNull(); }); }); - it('sends correct request body structure', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz({ - title: 'My Quiz', - questions: [ - { - id: 'q1', - text: 'Question 1', - timeLimit: 30, - options: [ - { text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' }, - { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, - ], - }, - ], - }); - - await act(async () => { - await result.current.updateQuiz('quiz-456', quiz); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - - expect(body.title).toBe('My Quiz'); - expect(body.questions).toHaveLength(1); - expect(body.questions[0].text).toBe('Question 1'); - expect(body.questions[0].timeLimit).toBe(30); - expect(body.questions[0].options[0].reason).toBe('Correct!'); - }); - - it('handles quiz with multiple questions', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz({ - questions: [ - { - id: 'q1', - text: 'Q1', - timeLimit: 20, - options: [ - { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, - { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, - ], - }, - { - id: 'q2', - text: 'Q2', - timeLimit: 25, - options: [ - { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, - { text: 'D', isCorrect: true, shape: 'square', color: 'green' }, - ], - }, - ], - }); - - await act(async () => { - await result.current.updateQuiz('quiz-789', quiz); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.questions).toHaveLength(2); - }); - }); - - describe('unhappy path - API errors', () => { - it('handles 404 not found error', async () => { + 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(); - await expect( - act(async () => { - await result.current.updateQuiz('non-existent', quiz); - }) - ).rejects.toThrow('Quiz not found'); + 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, @@ -201,49 +489,81 @@ describe('useQuizLibrary - updateQuiz', () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); - await expect( - act(async () => { - await result.current.updateQuiz('quiz-123', quiz); - }) - ).rejects.toThrow('Failed to update quiz'); - }); - - it('handles network error', async () => { - mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz(); - - await expect( - act(async () => { - await result.current.updateQuiz('quiz-123', quiz); - }) - ).rejects.toThrow('Network error'); - - expect(result.current.saving).toBe(false); - }); - - it('handles timeout/abort error', async () => { - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; - mockAuthFetch.mockRejectedValueOnce(abortError); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz(); - - await expect( - act(async () => { - await result.current.updateQuiz('quiz-123', quiz); - }) - ).rejects.toThrow(); - - expect(result.current.saving).toBe(false); + 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('unhappy path - edge cases', () => { - it('resets saving state even on error', async () => { - mockAuthFetch.mockRejectedValueOnce(new Error('Server error')); + 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(); @@ -252,11 +572,10 @@ describe('useQuizLibrary - updateQuiz', () => { await act(async () => { await result.current.updateQuiz('quiz-123', quiz); }); - } catch { - // Expected to throw + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Failed to update quiz.'); } - - expect(result.current.saving).toBe(false); }); it('handles empty quiz ID', async () => { @@ -268,86 +587,14 @@ describe('useQuizLibrary - updateQuiz', () => { const { result } = renderHook(() => useQuizLibrary()); const quiz = createMockQuiz(); - await expect( - act(async () => { + try { + await act(async () => { await result.current.updateQuiz('', quiz); - }) - ).rejects.toThrow(); - }); - - it('handles quiz with empty title', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz({ title: '' }); - - await act(async () => { - await result.current.updateQuiz('quiz-123', quiz); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.title).toBe(''); - }); - - it('strips undefined reason fields to null', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz({ - questions: [ - { - id: 'q1', - text: 'Q', - timeLimit: 20, - options: [ - { text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined }, - { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, - ], - }, - ], - }); - - await act(async () => { - await result.current.updateQuiz('quiz-123', quiz); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.questions[0].options[0]).not.toHaveProperty('reason'); - }); - }); - - describe('concurrent operations', () => { - it('allows update after save completes', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 'new-quiz' }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), }); - - const { result } = renderHook(() => useQuizLibrary()); - const quiz = createMockQuiz(); - - await act(async () => { - await result.current.saveQuiz(quiz, 'manual'); - }); - - await act(async () => { - await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' }); - }); - - expect(mockAuthFetch).toHaveBeenCalledTimes(2); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Quiz not found.'); + } }); }); });