kaboot/tests/hooks/useGame.navigation.test.tsx

1199 lines
37 KiB
TypeScript

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
): string => {
switch (gameState) {
case 'LANDING':
if (gamePin && currentPath.startsWith('/play/')) {
return `/play/${gamePin}`;
}
return '/';
case 'CREATING':
case 'GENERATING':
return '/create';
case 'EDITING':
return '/edit';
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('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" for EDITING state', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/')).toBe('/edit');
});
});
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; shouldReconnect?: boolean } => {
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') {
if (!storedSession) {
return { action: 'navigateHome' };
}
return { action: 'continueEditing' };
}
if (storedSession) {
if (storedSession.role === 'HOST') {
return { action: 'reconnectAsHost', shouldReconnect: true };
}
return { action: 'reconnectAsClient', shouldReconnect: true };
}
return { action: 'showLanding' };
};
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 URL', () => {
it('should navigate home when no session', () => {
const result = simulateUrlInit('/edit', null);
expect(result.action).toBe('navigateHome');
});
it('should continue editing when session exists', () => {
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
const result = simulateUrlInit('/edit', session);
expect(result.action).toBe('continueEditing');
});
});
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<string, string>();
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<string, string>();
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 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 without any session', () => {
const path = '/edit';
const session = null;
let action = 'none';
if (path === '/edit') {
if (!session) {
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')).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 during OAuth flow', () => {
let urlSyncRan = false;
const pathname = '/callback';
const syncUrl = () => {
if (pathname === '/callback') return;
urlSyncRan = true;
};
syncUrl();
expect(urlSyncRan).toBe(false);
});
});
describe('localStorage preservation during OAuth', () => {
it('should NOT clear OIDC state from localStorage on signout', () => {
const storage = new Map<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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('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);
});
});
});