import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useUserConfig } from '../../hooks/useUserConfig'; import { DEFAULT_GAME_CONFIG } from '../../types'; import type { GameConfig } from '../../types'; const mockAuthFetch = vi.fn(); const mockIsAuthenticated = vi.fn(() => true); vi.mock('../../hooks/useAuthenticatedFetch', () => ({ useAuthenticatedFetch: () => ({ authFetch: mockAuthFetch, isAuthenticated: mockIsAuthenticated(), }), })); vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), error: vi.fn(), }, })); describe('useUserConfig', () => { beforeEach(() => { vi.clearAllMocks(); mockIsAuthenticated.mockReturnValue(true); }); afterEach(() => { vi.resetAllMocks(); }); describe('initialization', () => { it('returns DEFAULT_GAME_CONFIG initially', () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }); const { result } = renderHook(() => useUserConfig()); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); it('fetches default config on mount when authenticated', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }); renderHook(() => useUserConfig()); await waitFor(() => { expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me'); }); }); it('does not fetch when not authenticated', () => { mockIsAuthenticated.mockReturnValue(false); renderHook(() => useUserConfig()); expect(mockAuthFetch).not.toHaveBeenCalled(); }); }); describe('fetchDefaultConfig - happy path', () => { it('updates defaultConfig when server returns config', async () => { const serverConfig: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true, streakBonusEnabled: true, }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: serverConfig }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.defaultConfig).toEqual(serverConfig); }); }); 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(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(true); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }); }); await waitFor(() => { expect(result.current.loading).toBe(false); }); }); it('keeps DEFAULT_GAME_CONFIG when server returns null config', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); }); describe('fetchDefaultConfig - unhappy path', () => { it('handles non-ok response gracefully', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); it('handles network error gracefully', async () => { mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); it('handles 401 unauthorized gracefully', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: false, status: 401, }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); }); describe('saveDefaultConfig - happy path', () => { it('successfully saves config to server', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const newConfig: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true, hostParticipates: false, }; await act(async () => { await result.current.saveDefaultConfig(newConfig); }); expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/users/me/default-config', { method: 'PUT', body: JSON.stringify({ defaultGameConfig: newConfig }), }); }); it('updates local state after successful save', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const newConfig: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true, }; await act(async () => { await result.current.saveDefaultConfig(newConfig); }); expect(result.current.defaultConfig).toEqual(newConfig); }); it('sets saving to true during save', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); let resolvePromise: (value: unknown) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockAuthFetch.mockReturnValueOnce(pendingPromise); act(() => { result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); }); await waitFor(() => { expect(result.current.saving).toBe(true); }); await act(async () => { resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true }), }); }); await waitFor(() => { expect(result.current.saving).toBe(false); }); }); }); describe('saveDefaultConfig - unhappy path', () => { it('throws error when save fails with non-ok response', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); await expect( act(async () => { await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); }) ).rejects.toThrow('Failed to save defaults'); }); it('throws error when network fails', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockRejectedValueOnce(new Error('Network error')); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); await expect( act(async () => { await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); }) ).rejects.toThrow('Network error'); }); it('resets saving state on error', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockRejectedValueOnce(new Error('Server error')); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); try { await act(async () => { await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); }); } catch { // Expected to throw } expect(result.current.saving).toBe(false); }); it('does not update local state on save failure', async () => { const initialConfig: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true, }; mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: initialConfig }), }) .mockResolvedValueOnce({ ok: false, status: 500, }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.defaultConfig).toEqual(initialConfig); }); const newConfig: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true, }; try { await act(async () => { await result.current.saveDefaultConfig(newConfig); }); } catch { // Expected to throw } expect(result.current.defaultConfig).toEqual(initialConfig); }); }); describe('concurrent operations', () => { it('handles multiple save calls correctly', async () => { mockAuthFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: null }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); const config1: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true }; const config2: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true }; await act(async () => { await result.current.saveDefaultConfig(config1); }); await act(async () => { await result.current.saveDefaultConfig(config2); }); expect(result.current.defaultConfig).toEqual(config2); }); }); describe('edge cases', () => { it('handles partial config from server', async () => { const partialConfig = { shuffleQuestions: true, }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: partialConfig }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(partialConfig); }); it('handles empty response body', async () => { mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); }); it('handles config with extra fields from server', async () => { const serverConfigWithExtras = { ...DEFAULT_GAME_CONFIG, unknownField: 'value', anotherUnknown: 123, }; mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: serverConfigWithExtras }), }); const { result } = renderHook(() => useUserConfig()); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.defaultConfig).toEqual(serverConfigWithExtras); }); }); describe('re-authentication scenarios', () => { it('re-fetches config when authentication status changes', async () => { mockIsAuthenticated.mockReturnValue(false); const { result, rerender } = renderHook(() => useUserConfig()); expect(mockAuthFetch).not.toHaveBeenCalled(); mockIsAuthenticated.mockReturnValue(true); mockAuthFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ defaultGameConfig: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true } }), }); rerender(); await waitFor(() => { expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me'); }); }); }); });