import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('URL Routing and Navigation', () => { describe('URL Path Generation', () => { const getTargetPath = ( gameState: string, gamePin: string | null, role: 'HOST' | 'CLIENT', currentPath: string, sourceQuizId: string | null = null ): string | null => { if (currentPath === '/callback' || currentPath.startsWith('/shared/')) { return null; } switch (gameState) { case 'LANDING': if (gamePin && currentPath.startsWith('/play/')) { return `/play/${gamePin}`; } return '/'; case 'CREATING': case 'GENERATING': return '/create'; case 'EDITING': return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft'; case 'LOBBY': case 'COUNTDOWN': case 'QUESTION': case 'REVEAL': case 'SCOREBOARD': case 'PODIUM': case 'HOST_RECONNECTED': if (gamePin) { return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`; } return '/'; case 'DISCONNECTED': case 'WAITING_TO_REJOIN': if (gamePin) { return `/play/${gamePin}`; } return '/'; default: return '/'; } }; describe('Shared quiz routes', () => { it('should return null for /shared/:token routes to skip navigation', () => { expect(getTargetPath('LANDING', null, 'HOST', '/shared/abc123')).toBeNull(); }); it('should return null for /shared/:token with complex token', () => { expect(getTargetPath('LANDING', null, 'HOST', '/shared/6yC50NixzVuA-E_Z0JhEUA')).toBeNull(); }); it('should return null for /shared/:token regardless of gameState', () => { expect(getTargetPath('LOBBY', '123456', 'HOST', '/shared/test-token')).toBeNull(); expect(getTargetPath('EDITING', null, 'HOST', '/shared/test-token')).toBeNull(); expect(getTargetPath('PODIUM', '123456', 'HOST', '/shared/test-token')).toBeNull(); }); it('should return null for /callback route', () => { expect(getTargetPath('LANDING', null, 'HOST', '/callback')).toBeNull(); }); }); describe('LANDING state', () => { it('should return "/" for LANDING state without gamePin', () => { expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/'); }); it('should preserve /play/:pin URL when LANDING with gamePin (join flow)', () => { expect(getTargetPath('LANDING', '123456', 'CLIENT', '/play/123456')).toBe('/play/123456'); }); it('should return "/" when LANDING with gamePin but not on /play/ path', () => { expect(getTargetPath('LANDING', '123456', 'HOST', '/')).toBe('/'); }); }); describe('CREATING/GENERATING states', () => { it('should return "/create" for CREATING state', () => { expect(getTargetPath('CREATING', null, 'HOST', '/')).toBe('/create'); }); it('should return "/create" for GENERATING state', () => { expect(getTargetPath('GENERATING', null, 'HOST', '/')).toBe('/create'); }); }); describe('EDITING state', () => { it('should return "/edit/draft" for EDITING state without sourceQuizId', () => { expect(getTargetPath('EDITING', null, 'HOST', '/', null)).toBe('/edit/draft'); }); it('should return "/edit/:quizId" for EDITING state with sourceQuizId', () => { expect(getTargetPath('EDITING', null, 'HOST', '/', 'quiz-123')).toBe('/edit/quiz-123'); }); it('should handle UUID-style sourceQuizId', () => { expect(getTargetPath('EDITING', null, 'HOST', '/', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890')) .toBe('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890'); }); }); describe('In-game HOST states', () => { const hostStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM', 'HOST_RECONNECTED']; hostStates.forEach(state => { it(`should return "/host/:pin" for ${state} state as HOST`, () => { expect(getTargetPath(state, '123456', 'HOST', '/')).toBe('/host/123456'); }); }); it('should return "/" for HOST states without gamePin', () => { expect(getTargetPath('LOBBY', null, 'HOST', '/')).toBe('/'); }); }); describe('In-game CLIENT states', () => { const clientStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM']; clientStates.forEach(state => { it(`should return "/play/:pin" for ${state} state as CLIENT`, () => { expect(getTargetPath(state, '123456', 'CLIENT', '/')).toBe('/play/123456'); }); }); }); describe('Disconnected states', () => { it('should return "/play/:pin" for DISCONNECTED state with gamePin', () => { expect(getTargetPath('DISCONNECTED', '123456', 'CLIENT', '/')).toBe('/play/123456'); }); it('should return "/play/:pin" for WAITING_TO_REJOIN state with gamePin', () => { expect(getTargetPath('WAITING_TO_REJOIN', '123456', 'CLIENT', '/')).toBe('/play/123456'); }); it('should return "/" for DISCONNECTED state without gamePin', () => { expect(getTargetPath('DISCONNECTED', null, 'CLIENT', '/')).toBe('/'); }); }); describe('Unknown states', () => { it('should return "/" for unknown game state', () => { expect(getTargetPath('UNKNOWN_STATE', '123456', 'HOST', '/')).toBe('/'); }); }); }); describe('URL Initialization Logic', () => { interface StoredSession { pin: string; role: 'HOST' | 'CLIENT'; hostSecret?: string; playerName?: string; playerId?: string; } const simulateUrlInit = ( path: string, storedSession: StoredSession | null ): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => { if (path === '/callback' || path.startsWith('/shared/')) { return { action: 'skipInit' }; } const hostMatch = path.match(/^\/host\/(\d+)$/); const playMatch = path.match(/^\/play\/(\d+)$/); if (hostMatch) { const pin = hostMatch[1]; if (storedSession && storedSession.pin === pin && storedSession.role === 'HOST') { return { action: 'reconnectAsHost', shouldReconnect: true }; } return { action: 'navigateHome' }; } if (playMatch) { const pin = playMatch[1]; if (storedSession && storedSession.pin === pin && storedSession.role === 'CLIENT') { return { action: 'reconnectAsClient', shouldReconnect: true }; } return { action: 'setGamePin', gamePin: pin }; } if (path === '/create') { return { action: 'startCreating' }; } if (path === '/edit/draft') { return { action: 'restoreDraft' }; } const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/); if (editMatch) { const quizId = editMatch[1]; return { action: 'restoreLibraryQuiz', quizId }; } if (storedSession) { if (storedSession.role === 'HOST') { return { action: 'reconnectAsHost', shouldReconnect: true }; } return { action: 'reconnectAsClient', shouldReconnect: true }; } return { action: 'showLanding' }; }; describe('/shared/:token URL', () => { it('should skip initialization for shared quiz routes', () => { const result = simulateUrlInit('/shared/abc123', null); expect(result.action).toBe('skipInit'); }); it('should skip initialization for shared routes with complex tokens', () => { const result = simulateUrlInit('/shared/6yC50NixzVuA-E_Z0JhEUA', null); expect(result.action).toBe('skipInit'); }); it('should skip initialization for shared routes even with existing session', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/shared/test-token', session); expect(result.action).toBe('skipInit'); }); it('should skip initialization for /callback route', () => { const result = simulateUrlInit('/callback', null); expect(result.action).toBe('skipInit'); }); }); describe('/host/:pin URL', () => { it('should reconnect as host when session matches', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/host/123456', session); expect(result.action).toBe('reconnectAsHost'); expect(result.shouldReconnect).toBe(true); }); it('should navigate home when no session for host URL', () => { const result = simulateUrlInit('/host/123456', null); expect(result.action).toBe('navigateHome'); }); it('should navigate home when session pin does not match', () => { const session: StoredSession = { pin: '654321', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/host/123456', session); expect(result.action).toBe('navigateHome'); }); it('should navigate home when session role is CLIENT not HOST', () => { const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; const result = simulateUrlInit('/host/123456', session); expect(result.action).toBe('navigateHome'); }); }); describe('/play/:pin URL', () => { it('should reconnect as client when session matches', () => { const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; const result = simulateUrlInit('/play/123456', session); expect(result.action).toBe('reconnectAsClient'); expect(result.shouldReconnect).toBe(true); }); it('should set gamePin when no session (for join flow)', () => { const result = simulateUrlInit('/play/123456', null); expect(result.action).toBe('setGamePin'); expect(result.gamePin).toBe('123456'); }); it('should set gamePin when session pin does not match', () => { const session: StoredSession = { pin: '654321', role: 'CLIENT', playerName: 'Test' }; const result = simulateUrlInit('/play/123456', session); expect(result.action).toBe('setGamePin'); expect(result.gamePin).toBe('123456'); }); }); describe('/create URL', () => { it('should start creating mode', () => { const result = simulateUrlInit('/create', null); expect(result.action).toBe('startCreating'); }); it('should start creating even with existing session', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/create', session); expect(result.action).toBe('startCreating'); }); }); describe('/edit/draft URL', () => { it('should restore draft for AI-generated quizzes', () => { const result = simulateUrlInit('/edit/draft', null); expect(result.action).toBe('restoreDraft'); }); it('should restore draft regardless of session state', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/edit/draft', session); expect(result.action).toBe('restoreDraft'); }); }); describe('/edit/:quizId URL', () => { it('should restore library quiz with quizId', () => { const result = simulateUrlInit('/edit/quiz-abc-123', null); expect(result.action).toBe('restoreLibraryQuiz'); expect(result.quizId).toBe('quiz-abc-123'); }); it('should handle UUID-style quiz IDs', () => { const result = simulateUrlInit('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', null); expect(result.action).toBe('restoreLibraryQuiz'); expect(result.quizId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); }); it('should not match /edit without ID', () => { const result = simulateUrlInit('/edit', null); expect(result.action).toBe('showLanding'); }); }); describe('Root URL "/"', () => { it('should show landing when no session', () => { const result = simulateUrlInit('/', null); expect(result.action).toBe('showLanding'); }); it('should reconnect as host when HOST session exists', () => { const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; const result = simulateUrlInit('/', session); expect(result.action).toBe('reconnectAsHost'); }); it('should reconnect as client when CLIENT session exists', () => { const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; const result = simulateUrlInit('/', session); expect(result.action).toBe('reconnectAsClient'); }); }); }); describe('Navigation Functions', () => { describe('cancelCreation', () => { it('should set gameState to LANDING', () => { let gameState = 'CREATING'; const cancelCreation = () => { gameState = 'LANDING'; }; cancelCreation(); expect(gameState).toBe('LANDING'); }); it('should trigger URL change to "/"', () => { let gameState = 'CREATING'; let currentPath = '/create'; const cancelCreation = () => { gameState = 'LANDING'; }; cancelCreation(); if (gameState === 'LANDING') { currentPath = '/'; } expect(currentPath).toBe('/'); }); }); describe('endGame', () => { it('should clear session storage', () => { const storage = new Map(); storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'HOST' })); const clearStoredSession = () => storage.delete('kaboot_session'); clearStoredSession(); expect(storage.has('kaboot_session')).toBe(false); }); it('should reset gamePin to null', () => { let gamePin: string | null = '123456'; const endGame = () => { gamePin = null; }; endGame(); expect(gamePin).toBeNull(); }); it('should reset quiz to null', () => { let quiz: any = { title: 'Test Quiz', questions: [] }; const endGame = () => { quiz = null; }; endGame(); expect(quiz).toBeNull(); }); it('should reset players to empty array', () => { let players = [{ id: 'player1', name: 'Test', score: 100 }]; const endGame = () => { players = []; }; endGame(); expect(players).toEqual([]); }); it('should set gameState to LANDING', () => { let gameState = 'PODIUM'; const endGame = () => { gameState = 'LANDING'; }; endGame(); expect(gameState).toBe('LANDING'); }); it('should navigate to "/" with replace', () => { let navigatedTo: string | null = null; let replaceUsed = false; const navigate = (path: string, options?: { replace?: boolean }) => { navigatedTo = path; replaceUsed = options?.replace ?? false; }; navigate('/', { replace: true }); expect(navigatedTo).toBe('/'); expect(replaceUsed).toBe(true); }); }); describe('goHomeFromDisconnected', () => { it('should clear session storage', () => { const storage = new Map(); storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'CLIENT' })); const clearStoredSession = () => storage.delete('kaboot_session'); clearStoredSession(); expect(storage.has('kaboot_session')).toBe(false); }); it('should reset gamePin to null', () => { let gamePin: string | null = '123456'; const goHomeFromDisconnected = () => { gamePin = null; }; goHomeFromDisconnected(); expect(gamePin).toBeNull(); }); it('should set gameState to LANDING', () => { let gameState = 'DISCONNECTED'; const goHomeFromDisconnected = () => { gameState = 'LANDING'; }; goHomeFromDisconnected(); expect(gameState).toBe('LANDING'); }); it('should clear error state', () => { let error: string | null = 'Connection lost'; const goHomeFromDisconnected = () => { error = null; }; goHomeFromDisconnected(); expect(error).toBeNull(); }); it('should result in URL change to "/" (not stay on /play/:pin)', () => { let gameState = 'DISCONNECTED'; let gamePin: string | null = '123456'; const goHomeFromDisconnected = () => { gamePin = null; gameState = 'LANDING'; }; goHomeFromDisconnected(); const getTargetPath = () => { if (gameState === 'LANDING' && !gamePin) { return '/'; } return `/play/${gamePin}`; }; expect(getTargetPath()).toBe('/'); }); }); describe('backFromEditor', () => { it('should reset quiz to null', () => { let quiz: any = { title: 'Test', questions: [] }; const backFromEditor = () => { quiz = null; }; backFromEditor(); expect(quiz).toBeNull(); }); it('should reset pendingQuizToSave to null', () => { let pendingQuizToSave: any = { quiz: {}, topic: 'Test' }; const backFromEditor = () => { pendingQuizToSave = null; }; backFromEditor(); expect(pendingQuizToSave).toBeNull(); }); it('should reset sourceQuizId to null', () => { let sourceQuizId: string | null = 'quiz-123'; const backFromEditor = () => { sourceQuizId = null; }; backFromEditor(); expect(sourceQuizId).toBeNull(); }); it('should set gameState to LANDING', () => { let gameState = 'EDITING'; const backFromEditor = () => { gameState = 'LANDING'; }; backFromEditor(); expect(gameState).toBe('LANDING'); }); }); }); describe('Non-Happy Path Scenarios', () => { describe('Page reload on different URLs', () => { it('should NOT get stuck on /create after cancel (regression test)', () => { let gameState = 'CREATING'; let navigatedTo: string | null = null; const cancelCreation = () => { gameState = 'LANDING'; }; cancelCreation(); if (gameState === 'LANDING') { navigatedTo = '/'; } expect(gameState).toBe('LANDING'); expect(navigatedTo).toBe('/'); }); it('should NOT get stuck on /host/:pin after endGame (regression test)', () => { let gameState = 'PODIUM'; let gamePin: string | null = '123456'; let sessionCleared = false; let navigatedTo: string | null = null; const endGame = () => { sessionCleared = true; gamePin = null; gameState = 'LANDING'; navigatedTo = '/'; }; endGame(); expect(sessionCleared).toBe(true); expect(gamePin).toBeNull(); expect(gameState).toBe('LANDING'); expect(navigatedTo).toBe('/'); }); it('should NOT redirect /shared/:token to "/" (regression test)', () => { const currentPath = '/shared/6yC50NixzVuA-E_Z0JhEUA'; const gameState = 'LANDING'; const gamePin: string | null = null; const getTargetPath = () => { if (currentPath.startsWith('/shared/')) { return null; } if (gameState === 'LANDING' && !gamePin) { return '/'; } return currentPath; }; const targetPath = getTargetPath(); const shouldNavigate = targetPath !== null && currentPath !== targetPath; expect(targetPath).toBeNull(); expect(shouldNavigate).toBe(false); }); it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => { let gameState = 'DISCONNECTED'; let gamePin: string | null = '123456'; let sessionCleared = false; const goHomeFromDisconnected = () => { sessionCleared = true; gamePin = null; gameState = 'LANDING'; }; goHomeFromDisconnected(); expect(gamePin).toBeNull(); expect(gameState).toBe('LANDING'); }); }); describe('Missing or invalid sessions', () => { it('should redirect to home when accessing /host/:pin without host session', () => { const path = '/host/123456'; const session = null; const hostMatch = path.match(/^\/host\/(\d+)$/); let action = 'none'; if (hostMatch) { if (!session) { action = 'navigateHome'; } } expect(action).toBe('navigateHome'); }); it('should redirect to home when accessing /host/:pin with CLIENT session', () => { const path = '/host/123456'; const session: { pin: string; role: 'HOST' | 'CLIENT'; playerName?: string } = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; const hostMatch = path.match(/^\/host\/(\d+)$/); let action = 'none'; if (hostMatch) { const pin = hostMatch[1]; if (!session || session.role !== 'HOST' || session.pin !== pin) { action = 'navigateHome'; } } expect(action).toBe('navigateHome'); }); it('should show join form when accessing /play/:pin without session (shareable link)', () => { const path = '/play/123456'; const session = null; const playMatch = path.match(/^\/play\/(\d+)$/); let gamePin: string | null = null; if (playMatch) { const pin = playMatch[1]; if (!session) { gamePin = pin; } } expect(gamePin).toBe('123456'); }); it('should redirect to home when accessing /edit/draft without draft in storage', () => { const path = '/edit/draft'; const hasDraftInStorage = false; let action = 'none'; if (path === '/edit/draft') { if (!hasDraftInStorage) { action = 'navigateHome'; } else { action = 'restoreDraft'; } } expect(action).toBe('navigateHome'); }); it('should redirect to home when accessing /edit/:quizId with mismatched storage', () => { const path = '/edit/quiz-123'; const storedQuizId = 'quiz-456'; const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/); let action = 'none'; if (editMatch) { const urlQuizId = editMatch[1]; if (storedQuizId !== urlQuizId) { action = 'navigateHome'; } } expect(action).toBe('navigateHome'); }); }); describe('Stale session handling', () => { it('should handle mismatched PIN in session vs URL', () => { const urlPin = '123456'; const session = { pin: '654321', role: 'HOST' as const, hostSecret: 'secret' }; const shouldReconnect = session && session.pin === urlPin && session.role === 'HOST'; expect(shouldReconnect).toBe(false); }); it('should handle expired/deleted game on reconnection attempt', () => { const session = { pin: '123456', role: 'HOST' as const, hostSecret: 'secret' }; const gameExistsOnServer = false; let action = 'none'; if (session && !gameExistsOnServer) { action = 'clearSessionAndGoHome'; } expect(action).toBe('clearSessionAndGoHome'); }); }); describe('URL with search params preservation', () => { it('should preserve search params when navigating', () => { const currentPath = '/'; const currentSearch = '?modal=library'; const targetPath = '/'; const fullPath = targetPath + currentSearch; expect(fullPath).toBe('/?modal=library'); }); it('should preserve modal param when gameState changes', () => { const searchParams = new URLSearchParams('?modal=settings'); const modalParam = searchParams.get('modal'); expect(modalParam).toBe('settings'); }); }); describe('Race conditions', () => { it('should handle URL init completing before auth loads', () => { let authLoaded = false; let urlInitComplete = false; let gameState = 'LANDING'; urlInitComplete = true; setTimeout(() => { authLoaded = true; }, 100); expect(gameState).toBe('LANDING'); expect(urlInitComplete).toBe(true); }); it('should not navigate when isInitializingFromUrl is true', () => { let isInitializingFromUrl = true; let navigateCalled = false; const urlSyncEffect = () => { if (isInitializingFromUrl) return; navigateCalled = true; }; urlSyncEffect(); expect(navigateCalled).toBe(false); }); }); }); describe('Replace vs Push Navigation', () => { it('should use replace for transient states (COUNTDOWN, QUESTION, REVEAL, SCOREBOARD)', () => { const transientStates = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD']; transientStates.forEach(state => { const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state); expect(useReplace).toBe(true); }); }); it('should use push for non-transient states', () => { const nonTransientStates = ['LANDING', 'CREATING', 'EDITING', 'LOBBY', 'PODIUM']; nonTransientStates.forEach(state => { const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state); expect(useReplace).toBe(false); }); }); }); }); describe('Host/Join Mode URL Parameter Tests', () => { describe('Mode state from URL', () => { const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => { if (initialPin) return 'JOIN'; const modeParam = searchParams.get('mode'); return modeParam === 'host' ? 'HOST' : 'JOIN'; }; it('should return JOIN by default when no mode param', () => { const searchParams = new URLSearchParams(''); expect(getModeFromUrl(searchParams)).toBe('JOIN'); }); it('should return HOST when ?mode=host', () => { const searchParams = new URLSearchParams('?mode=host'); expect(getModeFromUrl(searchParams)).toBe('HOST'); }); it('should return JOIN when ?mode=join', () => { const searchParams = new URLSearchParams('?mode=join'); expect(getModeFromUrl(searchParams)).toBe('JOIN'); }); it('should return JOIN for invalid mode values', () => { const searchParams = new URLSearchParams('?mode=invalid'); expect(getModeFromUrl(searchParams)).toBe('JOIN'); }); it('should force JOIN mode when initialPin is provided', () => { const searchParams = new URLSearchParams('?mode=host'); expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN'); }); it('should force JOIN mode with initialPin even without mode param', () => { const searchParams = new URLSearchParams(''); expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN'); }); }); describe('Setting mode URL param', () => { it('should set ?mode=host when switching to HOST', () => { const searchParams = new URLSearchParams(); const setMode = (newMode: 'HOST' | 'JOIN') => { if (newMode === 'HOST') { searchParams.set('mode', 'host'); } else { searchParams.delete('mode'); } }; setMode('HOST'); expect(searchParams.get('mode')).toBe('host'); }); it('should remove mode param when switching to JOIN', () => { const searchParams = new URLSearchParams('?mode=host'); const setMode = (newMode: 'HOST' | 'JOIN') => { if (newMode === 'HOST') { searchParams.set('mode', 'host'); } else { searchParams.delete('mode'); } }; setMode('JOIN'); expect(searchParams.get('mode')).toBeNull(); }); it('should preserve other params when setting mode', () => { const searchParams = new URLSearchParams('?modal=library'); const setMode = (newMode: 'HOST' | 'JOIN') => { if (newMode === 'HOST') { searchParams.set('mode', 'host'); } else { searchParams.delete('mode'); } }; setMode('HOST'); expect(searchParams.get('mode')).toBe('host'); expect(searchParams.get('modal')).toBe('library'); }); }); describe('Mode with modal params', () => { it('should support both mode and modal params together', () => { const searchParams = new URLSearchParams('?mode=host&modal=settings'); expect(searchParams.get('mode')).toBe('host'); expect(searchParams.get('modal')).toBe('settings'); }); it('should preserve mode when opening modal', () => { const searchParams = new URLSearchParams('?mode=host'); searchParams.set('modal', 'library'); expect(searchParams.get('mode')).toBe('host'); expect(searchParams.get('modal')).toBe('library'); }); it('should preserve mode when closing modal', () => { const searchParams = new URLSearchParams('?mode=host&modal=library'); searchParams.delete('modal'); expect(searchParams.get('mode')).toBe('host'); expect(searchParams.get('modal')).toBeNull(); }); }); describe('URL representation', () => { it('should generate clean URL for JOIN mode (no mode param)', () => { const searchParams = new URLSearchParams(); const url = '/' + (searchParams.toString() ? '?' + searchParams.toString() : ''); expect(url).toBe('/'); }); it('should generate correct URL for HOST mode', () => { const searchParams = new URLSearchParams(); searchParams.set('mode', 'host'); const url = '/?' + searchParams.toString(); expect(url).toBe('/?mode=host'); }); it('should generate correct URL for HOST mode with modal', () => { const searchParams = new URLSearchParams(); searchParams.set('mode', 'host'); searchParams.set('modal', 'settings'); const url = '/?' + searchParams.toString(); expect(url).toContain('mode=host'); expect(url).toContain('modal=settings'); }); }); }); describe('Modal URL Parameter Tests', () => { describe('Modal state from URL', () => { it('should detect library modal from ?modal=library', () => { const searchParams = new URLSearchParams('?modal=library'); const modalParam = searchParams.get('modal'); const libraryOpen = modalParam === 'library'; expect(libraryOpen).toBe(true); }); it('should detect preferences modal from ?modal=preferences', () => { const searchParams = new URLSearchParams('?modal=preferences'); const modalParam = searchParams.get('modal'); const preferencesOpen = modalParam === 'preferences'; expect(preferencesOpen).toBe(true); }); it('should detect settings modal from ?modal=settings', () => { const searchParams = new URLSearchParams('?modal=settings'); const modalParam = searchParams.get('modal'); const settingsOpen = modalParam === 'settings'; expect(settingsOpen).toBe(true); }); it('should detect account modal from ?modal=account', () => { const searchParams = new URLSearchParams('?modal=account'); const modalParam = searchParams.get('modal'); const accountOpen = modalParam === 'account'; expect(accountOpen).toBe(true); }); it('should return false for all modals when no param', () => { const searchParams = new URLSearchParams(''); const modalParam = searchParams.get('modal'); expect(modalParam).toBeNull(); expect(modalParam === 'library').toBe(false); expect(modalParam === 'preferences').toBe(false); expect(modalParam === 'settings').toBe(false); expect(modalParam === 'account').toBe(false); }); it('should return false for all modals with unknown param value', () => { const searchParams = new URLSearchParams('?modal=unknown'); const modalParam = searchParams.get('modal'); expect(modalParam === 'library').toBe(false); expect(modalParam === 'preferences').toBe(false); expect(modalParam === 'settings').toBe(false); expect(modalParam === 'account').toBe(false); }); }); describe('Setting modal URL params', () => { it('should set ?modal=library when opening library', () => { const searchParams = new URLSearchParams(); const setLibraryOpen = (open: boolean) => { if (open) { searchParams.set('modal', 'library'); } else { searchParams.delete('modal'); } }; setLibraryOpen(true); expect(searchParams.get('modal')).toBe('library'); }); it('should clear modal param when closing modal', () => { const searchParams = new URLSearchParams('?modal=library'); const setLibraryOpen = (open: boolean) => { if (open) { searchParams.set('modal', 'library'); } else { searchParams.delete('modal'); } }; setLibraryOpen(false); expect(searchParams.get('modal')).toBeNull(); }); it('should replace existing modal param when opening different modal', () => { const searchParams = new URLSearchParams('?modal=library'); searchParams.set('modal', 'settings'); expect(searchParams.get('modal')).toBe('settings'); }); }); describe('Modal with /play/:pin URL', () => { it('should work with modal param on /play/:pin URL', () => { const pathname = '/play/123456'; const search = '?modal=preferences'; const fullUrl = pathname + search; expect(fullUrl).toBe('/play/123456?modal=preferences'); const searchParams = new URLSearchParams(search); expect(searchParams.get('modal')).toBe('preferences'); }); }); }); describe('OAuth Callback Handling', () => { describe('onSigninCallback redirect', () => { it('should redirect to "/" after successful OAuth callback', () => { let replacedPath: string | null = null; const mockReplaceState = (_data: unknown, _title: string, url: string) => { replacedPath = url; }; const onSigninCallback = () => { mockReplaceState({}, document.title, '/'); }; onSigninCallback(); expect(replacedPath).toBe('/'); }); it('should NOT stay on /callback path after signin', () => { let currentPath = '/callback'; const onSigninCallback = () => { currentPath = '/'; }; onSigninCallback(); expect(currentPath).not.toBe('/callback'); expect(currentPath).toBe('/'); }); it('should clear OAuth query params (code, state) from URL', () => { const urlWithOAuthParams = '/callback?code=abc123&state=xyz789'; let finalUrl: string | null = null; const onSigninCallback = () => { finalUrl = '/'; }; onSigninCallback(); expect(finalUrl).toBe('/'); expect(finalUrl).not.toContain('code='); expect(finalUrl).not.toContain('state='); }); }); describe('URL initialization skips /callback', () => { const shouldSkipUrlInit = (pathname: string): boolean => { return pathname === '/callback'; }; it('should skip URL initialization on /callback path', () => { expect(shouldSkipUrlInit('/callback')).toBe(true); }); it('should NOT skip URL initialization on other paths', () => { expect(shouldSkipUrlInit('/')).toBe(false); expect(shouldSkipUrlInit('/create')).toBe(false); expect(shouldSkipUrlInit('/edit/draft')).toBe(false); expect(shouldSkipUrlInit('/edit/quiz-123')).toBe(false); expect(shouldSkipUrlInit('/host/123456')).toBe(false); expect(shouldSkipUrlInit('/play/123456')).toBe(false); }); it('should not interfere with normal navigation when not on /callback', () => { let urlInitRan = false; const pathname: string = '/'; const initFromUrl = () => { if (pathname === '/callback') return; urlInitRan = true; }; initFromUrl(); expect(urlInitRan).toBe(true); }); it('should prevent URL sync from running while auth is loading', () => { let urlSyncRan = false; const authIsLoading = true; const syncUrl = () => { if (authIsLoading) return; urlSyncRan = true; }; syncUrl(); expect(urlSyncRan).toBe(false); }); it('should navigate away from /callback after auth completes', () => { const pathname = '/callback'; const authIsLoading = false; const getTargetPath = () => { if (pathname === '/callback') { return '/'; } return pathname; }; const targetPath = getTargetPath(); expect(targetPath).toBe('/'); }); }); describe('localStorage preservation during OAuth', () => { it('should NOT clear OIDC state from localStorage on signout', () => { const storage = new Map(); storage.set('kaboot_session', JSON.stringify({ pin: '123456' })); storage.set('oidc.user:https://auth.example.com:client-id', 'oidc-state'); storage.set('oidc.some-other-key', 'other-state'); const onRemoveUser = () => { storage.delete('kaboot_session'); }; onRemoveUser(); expect(storage.has('kaboot_session')).toBe(false); expect(storage.has('oidc.user:https://auth.example.com:client-id')).toBe(true); expect(storage.has('oidc.some-other-key')).toBe(true); }); it('should only clear kaboot_session on signout', () => { const storage = new Map(); storage.set('kaboot_session', 'session-data'); storage.set('other_app_data', 'preserved'); storage.set('user_preferences', 'also-preserved'); const onRemoveUser = () => { storage.delete('kaboot_session'); }; onRemoveUser(); expect(storage.has('kaboot_session')).toBe(false); expect(storage.get('other_app_data')).toBe('preserved'); expect(storage.get('user_preferences')).toBe('also-preserved'); }); }); describe('OAuth callback regression tests', () => { it('should NOT get stuck on /callback after login (regression test)', () => { let currentPath: string = '/callback?code=auth_code&state=random_state'; const onSigninCallback = () => { currentPath = '/'; }; onSigninCallback(); expect(currentPath).toBe('/'); expect(currentPath.includes('callback')).toBe(false); }); it('should NOT show "No matching state found" error by preserving OIDC storage', () => { const storage = new Map(); const oidcStateKey = 'oidc.state.abc123'; storage.set(oidcStateKey, JSON.stringify({ nonce: 'xyz', redirect_uri: '/callback' })); storage.set('kaboot_session', 'old-session'); const onRemoveUser = () => { storage.delete('kaboot_session'); }; onRemoveUser(); expect(storage.has(oidcStateKey)).toBe(true); }); it('should handle multiple rapid signin/signout cycles', () => { const storage = new Map(); let signinCount = 0; let signoutCount = 0; const onSigninCallback = () => { signinCount++; storage.set('kaboot_session', `session-${signinCount}`); }; const onRemoveUser = () => { signoutCount++; storage.delete('kaboot_session'); }; onSigninCallback(); onRemoveUser(); onSigninCallback(); onRemoveUser(); onSigninCallback(); expect(signinCount).toBe(3); expect(signoutCount).toBe(2); expect(storage.has('kaboot_session')).toBe(true); }); }); }); describe('/play/:pin URL Join Flow', () => { describe('Landing PIN sync (regression: PIN field empty on /play/:pin)', () => { it('should sync local PIN state when initialPin prop changes after async load', () => { let initialPin: string | null = null; let localPin = initialPin || ''; const syncPin = (newInitialPin: string | null) => { if (newInitialPin) { localPin = newInitialPin; } }; initialPin = 'TESTPIN'; syncPin(initialPin); expect(localPin).toBe('TESTPIN'); }); it('should preserve user-typed PIN when initialPin becomes null', () => { let localPin = 'USER_TYPED'; const syncPin = (newInitialPin: string | null) => { if (newInitialPin) { localPin = newInitialPin; } }; syncPin(null); expect(localPin).toBe('USER_TYPED'); }); it('should force JOIN mode when initialPin is provided via URL', () => { const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => { if (initialPin) return 'JOIN'; const modeParam = searchParams.get('mode'); return modeParam === 'host' ? 'HOST' : 'JOIN'; }; const searchParams = new URLSearchParams('?mode=host'); expect(getModeFromUrl(searchParams, 'TESTPIN')).toBe('JOIN'); }); }); }); describe('State Sync with Async Data Loading', () => { describe('Settings modal data sync', () => { it('should update editingDefaultConfig when defaultConfig loads', () => { const DEFAULT_CONFIG = { shuffleQuestions: false }; const LOADED_CONFIG = { shuffleQuestions: true, streakBonusEnabled: true }; let defaultConfig = DEFAULT_CONFIG; let editingDefaultConfig = DEFAULT_CONFIG; let defaultConfigOpen = true; defaultConfig = LOADED_CONFIG; if (defaultConfigOpen) { editingDefaultConfig = defaultConfig; } expect(editingDefaultConfig).toEqual(LOADED_CONFIG); }); it('should not update editingDefaultConfig when modal is closed', () => { const DEFAULT_CONFIG = { shuffleQuestions: false }; const LOADED_CONFIG = { shuffleQuestions: true }; let defaultConfig = DEFAULT_CONFIG; let editingDefaultConfig: any = null; let defaultConfigOpen = false; defaultConfig = LOADED_CONFIG; if (defaultConfigOpen) { editingDefaultConfig = defaultConfig; } else { editingDefaultConfig = null; } expect(editingDefaultConfig).toBeNull(); }); }); describe('Preferences modal data sync', () => { it('should update localPrefs when preferences loads', () => { const DEFAULT_PREFS = { colorScheme: 'blue' }; const LOADED_PREFS = { colorScheme: 'purple', geminiApiKey: 'key123' }; let preferences = DEFAULT_PREFS; let localPrefs = DEFAULT_PREFS; let isOpen = true; preferences = LOADED_PREFS; if (isOpen) { localPrefs = preferences; } expect(localPrefs).toEqual(LOADED_PREFS); }); }); });