Add server security hardening and draft quiz persistence
Security: - Add AES-256-GCM encryption for user PII (email, API keys, config) - Add rate limiting (helmet + express-rate-limit) - Require auth for file uploads UX: - Persist draft quizzes to sessionStorage (survives refresh) - Add URL-based edit routes (/edit/draft, /edit/:quizId) - Fix QuizEditor async defaultConfig race condition - Fix URL param accumulation in Landing
This commit is contained in:
parent
75c496e68f
commit
e480ad06df
18 changed files with 1775 additions and 94 deletions
|
|
@ -3,6 +3,7 @@
|
|||
# ==============================================================================
|
||||
PG_PASS=
|
||||
AUTHENTIK_SECRET_KEY=
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ==============================================================================
|
||||
# HOST CONFIGURATION
|
||||
|
|
|
|||
|
|
@ -39,11 +39,13 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
||||
setModeState(newMode);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
const newParams = new URLSearchParams();
|
||||
const modalParam = searchParams.get('modal');
|
||||
if (modalParam) {
|
||||
newParams.set('modal', modalParam);
|
||||
}
|
||||
if (newMode === 'HOST') {
|
||||
newParams.set('mode', 'host');
|
||||
} else {
|
||||
newParams.delete('mode');
|
||||
}
|
||||
setSearchParams(newParams, { replace: true });
|
||||
};
|
||||
|
|
@ -58,44 +60,33 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
const defaultConfigOpen = modalParam === 'settings';
|
||||
const accountSettingsOpen = modalParam === 'account';
|
||||
|
||||
const setLibraryOpen = (open: boolean) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (open) {
|
||||
newParams.set('modal', 'library');
|
||||
} else {
|
||||
newParams.delete('modal');
|
||||
const buildCleanParams = (updates: Record<string, string | null>) => {
|
||||
const newParams = new URLSearchParams();
|
||||
const modeParam = searchParams.get('mode');
|
||||
if (modeParam) newParams.set('mode', modeParam);
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== null) {
|
||||
newParams.set(key, value);
|
||||
}
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
return newParams;
|
||||
};
|
||||
|
||||
const setLibraryOpen = (open: boolean) => {
|
||||
setSearchParams(buildCleanParams({ modal: open ? 'library' : null }));
|
||||
};
|
||||
|
||||
const setPreferencesOpen = (open: boolean) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (open) {
|
||||
newParams.set('modal', 'preferences');
|
||||
} else {
|
||||
newParams.delete('modal');
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null }));
|
||||
};
|
||||
|
||||
const setDefaultConfigOpen = (open: boolean) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (open) {
|
||||
newParams.set('modal', 'settings');
|
||||
} else {
|
||||
newParams.delete('modal');
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
setSearchParams(buildCleanParams({ modal: open ? 'settings' : null }));
|
||||
};
|
||||
|
||||
const setAccountSettingsOpen = (open: boolean) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (open) {
|
||||
newParams.set('modal', 'account');
|
||||
} else {
|
||||
newParams.delete('modal');
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
setSearchParams(buildCleanParams({ modal: open ? 'account' : null }));
|
||||
};
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
|
|
|||
|
|
@ -39,11 +39,20 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|||
const [config, setConfig] = useState<GameConfig>(
|
||||
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
|
||||
);
|
||||
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
|
||||
setConfig(defaultConfig);
|
||||
setHasAppliedDefaultConfig(true);
|
||||
}
|
||||
}, [defaultConfig, hasAppliedDefaultConfig]);
|
||||
|
||||
useBodyScrollLock(!!showDeleteConfirm);
|
||||
|
||||
const handleConfigChange = useCallback((newConfig: GameConfig) => {
|
||||
setConfig(newConfig);
|
||||
setHasAppliedDefaultConfig(true);
|
||||
onConfigChange?.(newConfig);
|
||||
}, [onConfigChange]);
|
||||
|
||||
|
|
@ -191,7 +200,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|||
|
||||
{showSaveButton && (
|
||||
<button
|
||||
onClick={() => onSave(quiz)}
|
||||
onClick={() => onSave({ ...quiz, config })}
|
||||
disabled={isSaving}
|
||||
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
|
||||
title="Save to library"
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_PATH: /data/kaboot.db
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||
OIDC_JWKS_URI: ${OIDC_JWKS_URI}
|
||||
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_PATH: /data/kaboot.db
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required}
|
||||
OIDC_ISSUER: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/
|
||||
OIDC_JWKS_URI: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/jwks/
|
||||
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
|
||||
import { generateQuiz } from '../services/geminiService';
|
||||
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
|
||||
|
|
@ -8,6 +9,7 @@ import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generato
|
|||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||
const SESSION_STORAGE_KEY = 'kaboot_session';
|
||||
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
|
||||
const STATE_SYNC_INTERVAL = 5000;
|
||||
|
||||
interface StoredSession {
|
||||
|
|
@ -35,9 +37,33 @@ const clearStoredSession = () => {
|
|||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
};
|
||||
|
||||
interface DraftQuiz {
|
||||
quiz: Quiz;
|
||||
topic?: string;
|
||||
sourceQuizId?: string;
|
||||
}
|
||||
|
||||
const getDraftQuiz = (): DraftQuiz | null => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(DRAFT_QUIZ_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeDraftQuiz = (draft: DraftQuiz) => {
|
||||
sessionStorage.setItem(DRAFT_QUIZ_KEY, JSON.stringify(draft));
|
||||
};
|
||||
|
||||
const clearDraftQuiz = () => {
|
||||
sessionStorage.removeItem(DRAFT_QUIZ_KEY);
|
||||
};
|
||||
|
||||
export const useGame = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const auth = useAuth();
|
||||
|
||||
const [role, setRole] = useState<GameRole>('HOST');
|
||||
const [gameState, setGameState] = useState<GameState>('LANDING');
|
||||
|
|
@ -95,9 +121,13 @@ export const useGame = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (isInitializingFromUrl.current) return;
|
||||
if (location.pathname === '/callback') return;
|
||||
if (auth.isLoading) return;
|
||||
|
||||
const getTargetPath = () => {
|
||||
if (location.pathname === '/callback') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
switch (gameState) {
|
||||
case 'LANDING':
|
||||
if (gamePin && location.pathname.startsWith('/play/')) {
|
||||
|
|
@ -108,7 +138,7 @@ export const useGame = () => {
|
|||
case 'GENERATING':
|
||||
return '/create';
|
||||
case 'EDITING':
|
||||
return '/edit';
|
||||
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
|
||||
case 'LOBBY':
|
||||
case 'COUNTDOWN':
|
||||
case 'QUESTION':
|
||||
|
|
@ -136,7 +166,19 @@ export const useGame = () => {
|
|||
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
|
||||
navigate(targetPath + location.search, { replace: useReplace });
|
||||
}
|
||||
}, [gameState, gamePin, role, navigate, location.pathname, location.search]);
|
||||
}, [gameState, gamePin, role, navigate, location.pathname, location.search, sourceQuizId, auth.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isLoading) return;
|
||||
|
||||
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(location.pathname) &&
|
||||
location.pathname !== '/edit/draft';
|
||||
|
||||
if (isLibraryQuizRoute && !auth.isAuthenticated) {
|
||||
clearDraftQuiz();
|
||||
auth.signinRedirect();
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, location.pathname]);
|
||||
|
||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||
|
||||
|
|
@ -486,9 +528,36 @@ export const useGame = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (path === '/edit') {
|
||||
const session = getStoredSession();
|
||||
if (!session) {
|
||||
if (path === '/edit/draft') {
|
||||
const draft = getDraftQuiz();
|
||||
if (draft) {
|
||||
isInitializingFromUrl.current = true;
|
||||
setRole('HOST');
|
||||
setQuiz(draft.quiz);
|
||||
setSourceQuizId(null);
|
||||
if (draft.topic !== undefined) {
|
||||
setPendingQuizToSave({ quiz: draft.quiz, topic: draft.topic });
|
||||
}
|
||||
setGameState('EDITING');
|
||||
isInitializingFromUrl.current = false;
|
||||
} else {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
|
||||
if (editMatch) {
|
||||
const quizId = editMatch[1];
|
||||
const draft = getDraftQuiz();
|
||||
if (draft && draft.sourceQuizId === quizId) {
|
||||
isInitializingFromUrl.current = true;
|
||||
setRole('HOST');
|
||||
setQuiz(draft.quiz);
|
||||
setSourceQuizId(quizId);
|
||||
setGameState('EDITING');
|
||||
isInitializingFromUrl.current = false;
|
||||
} else {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
return;
|
||||
|
|
@ -554,6 +623,7 @@ export const useGame = () => {
|
|||
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
|
||||
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
|
||||
setQuiz(generatedQuiz);
|
||||
storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel });
|
||||
setGameState('EDITING');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
|
||||
|
|
@ -586,17 +656,25 @@ export const useGame = () => {
|
|||
setRole('HOST');
|
||||
setQuiz(savedQuiz);
|
||||
setSourceQuizId(quizId || null);
|
||||
storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId });
|
||||
setGameState('EDITING');
|
||||
};
|
||||
|
||||
const updateQuizFromEditor = (updatedQuiz: Quiz) => {
|
||||
setQuiz(updatedQuiz);
|
||||
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
||||
const currentDraft = getDraftQuiz();
|
||||
storeDraftQuiz({
|
||||
quiz: updatedQuiz,
|
||||
topic: currentDraft?.topic,
|
||||
sourceQuizId: currentDraft?.sourceQuizId
|
||||
});
|
||||
};
|
||||
|
||||
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
|
||||
setQuiz(finalQuiz);
|
||||
setGameConfig(config);
|
||||
clearDraftQuiz();
|
||||
initializeHostGame(finalQuiz, config.hostParticipates);
|
||||
};
|
||||
|
||||
|
|
@ -604,6 +682,7 @@ export const useGame = () => {
|
|||
setQuiz(null);
|
||||
setPendingQuizToSave(null);
|
||||
setSourceQuizId(null);
|
||||
clearDraftQuiz();
|
||||
setGameState('LANDING');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,12 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
: 'Failed to load quiz.';
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const data = await response.json();
|
||||
toast.success('Quiz loaded!');
|
||||
return response.json();
|
||||
return {
|
||||
...data,
|
||||
config: data.gameConfig,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load quiz';
|
||||
if (!message.includes('redirecting')) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ PG_PASS=$(openssl rand -base64 36 | tr -d '\n')
|
|||
AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -base64 36 | tr -d '\n')
|
||||
ENCRYPTION_KEY=$(openssl rand -base64 36 | tr -d '\n')
|
||||
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
|
||||
|
|
@ -54,12 +55,14 @@ if [[ "$OSTYPE" == "darwin"* ]]; then
|
|||
sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
|
||||
sed -i '' "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE"
|
||||
sed -i '' "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE"
|
||||
sed -i '' "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" "$ENV_FILE"
|
||||
sed -i '' "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE"
|
||||
else
|
||||
sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE"
|
||||
sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
|
||||
sed -i "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE"
|
||||
sed -i "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE"
|
||||
sed -i "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" "$ENV_FILE"
|
||||
sed -i "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
|
|
|
|||
50
server/package-lock.json
generated
50
server/package-lock.json
generated
|
|
@ -11,6 +11,8 @@
|
|||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"multer": "^2.0.2",
|
||||
|
|
@ -21,6 +23,7 @@
|
|||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-rate-limit": "^5.1.3",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
|
|
@ -778,6 +781,16 @@
|
|||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-rate-limit": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz",
|
||||
"integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
|
|
@ -1396,6 +1409,7 @@
|
|||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
|
|
@ -1437,6 +1451,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||
|
|
@ -1618,6 +1650,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -1688,6 +1729,15 @@
|
|||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"multer": "^2.0.2",
|
||||
|
|
@ -24,6 +26,7 @@
|
|||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-rate-limit": "^5.1.3",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { db } from './db/connection.js';
|
||||
import quizzesRouter from './routes/quizzes.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
|
|
@ -9,12 +11,38 @@ import gamesRouter from './routes/games.js';
|
|||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: isDev ? 1000 : 500,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
skip: (req) => req.path === '/health',
|
||||
});
|
||||
|
||||
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim());
|
||||
app.use(cors({
|
||||
origin: corsOrigins.length === 1 ? corsOrigins[0] : corsOrigins,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(apiLimiter);
|
||||
|
||||
const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true';
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { db } from '../db/connection.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const gameCreationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: isDev ? 100 : 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many game creations, please try again later.' },
|
||||
});
|
||||
|
||||
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);
|
||||
|
||||
interface GameSession {
|
||||
|
|
@ -34,7 +44,7 @@ const cleanupExpiredSessions = () => {
|
|||
|
||||
setInterval(cleanupExpiredSessions, 60 * 1000);
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
router.post('/', gameCreationLimiter, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { pin, hostPeerId, quiz, gameConfig } = req.body;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { processDocument, SUPPORTED_TYPES } from '../services/documentParser.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
const upload = multer({
|
||||
|
|
|
|||
|
|
@ -1,26 +1,55 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { db } from '../db/connection.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { encryptForUser, decryptForUser, encryptJsonForUser, decryptJsonForUser } from '../services/encryption.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
defaultGameConfig: string | null;
|
||||
colorScheme: string | null;
|
||||
geminiApiKey: string | null;
|
||||
createdAt: string | null;
|
||||
lastLogin: string | null;
|
||||
}
|
||||
|
||||
function decryptUserData(row: UserRow, userSub: string) {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: decryptForUser(row.email, userSub),
|
||||
displayName: decryptForUser(row.displayName, userSub),
|
||||
defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub),
|
||||
colorScheme: row.colorScheme,
|
||||
geminiApiKey: decryptForUser(row.geminiApiKey, userSub),
|
||||
createdAt: row.createdAt,
|
||||
lastLogin: row.lastLogin,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = db.prepare(`
|
||||
const userSub = req.user!.sub;
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
|
||||
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
|
||||
created_at as createdAt, last_login as lastLogin
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(req.user!.sub) as Record<string, unknown> | undefined;
|
||||
`).get(userSub) as UserRow | undefined;
|
||||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
|
||||
if (!user) {
|
||||
if (!row) {
|
||||
res.json({
|
||||
id: req.user!.sub,
|
||||
id: userSub,
|
||||
username: req.user!.preferred_username,
|
||||
email: req.user!.email,
|
||||
displayName: req.user!.name,
|
||||
|
|
@ -35,26 +64,23 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
let parsedConfig = null;
|
||||
if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') {
|
||||
try {
|
||||
parsedConfig = JSON.parse(user.defaultGameConfig);
|
||||
} catch {
|
||||
parsedConfig = null;
|
||||
}
|
||||
}
|
||||
const decrypted = decryptUserData(row, userSub);
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
defaultGameConfig: parsedConfig,
|
||||
...decrypted,
|
||||
hasAIAccess,
|
||||
isNew: false
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
||||
const userSub = req.user!.sub;
|
||||
const { defaultGameConfig } = req.body;
|
||||
|
||||
const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub);
|
||||
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
|
||||
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
|
|
@ -63,40 +89,45 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
|||
last_login = CURRENT_TIMESTAMP
|
||||
`);
|
||||
|
||||
const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
|
||||
|
||||
upsertUser.run(
|
||||
req.user!.sub,
|
||||
userSub,
|
||||
req.user!.preferred_username,
|
||||
req.user!.email || null,
|
||||
req.user!.name || null,
|
||||
configJson,
|
||||
configJson
|
||||
encryptedEmail,
|
||||
encryptedDisplayName,
|
||||
encryptedConfig,
|
||||
encryptedConfig
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||
const userSub = req.user!.sub;
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
|
||||
`).get(userSub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
|
||||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
|
||||
res.json({
|
||||
colorScheme: user?.colorScheme || 'blue',
|
||||
geminiApiKey: user?.geminiApiKey || null,
|
||||
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
|
||||
hasAIAccess,
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||
const userSub = req.user!.sub;
|
||||
const { colorScheme, geminiApiKey } = req.body;
|
||||
|
||||
const encryptedApiKey = encryptForUser(geminiApiKey || null, userSub);
|
||||
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
|
||||
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
|
|
@ -107,14 +138,14 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
|||
`);
|
||||
|
||||
upsertUser.run(
|
||||
req.user!.sub,
|
||||
userSub,
|
||||
req.user!.preferred_username,
|
||||
req.user!.email || null,
|
||||
req.user!.name || null,
|
||||
encryptedEmail,
|
||||
encryptedDisplayName,
|
||||
colorScheme || 'blue',
|
||||
geminiApiKey || null,
|
||||
encryptedApiKey,
|
||||
colorScheme || 'blue',
|
||||
geminiApiKey || null
|
||||
encryptedApiKey
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
|
|
|
|||
123
server/src/services/encryption.ts
Normal file
123
server/src/services/encryption.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const SALT_LENGTH = 16;
|
||||
|
||||
let encryptionWarningShown = false;
|
||||
|
||||
function getMasterKey(): Buffer | null {
|
||||
const key = process.env.ENCRYPTION_KEY;
|
||||
if (!key) {
|
||||
if (!encryptionWarningShown) {
|
||||
console.warn('[SECURITY WARNING] ENCRYPTION_KEY not set. User data will NOT be encrypted.');
|
||||
console.warn('Generate one with: openssl rand -base64 36');
|
||||
encryptionWarningShown = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return createHash('sha256').update(key).digest();
|
||||
}
|
||||
|
||||
function deriveUserKey(userSub: string, salt: Buffer): Buffer | null {
|
||||
const masterKey = getMasterKey();
|
||||
if (!masterKey) return null;
|
||||
|
||||
return Buffer.from(
|
||||
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptForUser(plaintext: string | null | undefined, userSub: string): string | null {
|
||||
if (plaintext === null || plaintext === undefined || plaintext === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const salt = randomBytes(SALT_LENGTH);
|
||||
const key = deriveUserKey(userSub, salt);
|
||||
|
||||
if (!key) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const combined = Buffer.concat([salt, iv, authTag, encrypted]);
|
||||
return combined.toString('base64');
|
||||
}
|
||||
|
||||
export function decryptForUser(ciphertext: string | null | undefined, userSub: string): string | null {
|
||||
if (ciphertext === null || ciphertext === undefined || ciphertext === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const masterKey = getMasterKey();
|
||||
if (!masterKey) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
try {
|
||||
const combined = Buffer.from(ciphertext, 'base64');
|
||||
|
||||
if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
const salt = combined.subarray(0, SALT_LENGTH);
|
||||
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
||||
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const key = Buffer.from(
|
||||
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
|
||||
);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch {
|
||||
return ciphertext;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEncrypted(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
return decoded.length >= SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptJsonForUser(data: object | null | undefined, userSub: string): string | null {
|
||||
if (data === null || data === undefined) {
|
||||
return null;
|
||||
}
|
||||
return encryptForUser(JSON.stringify(data), userSub);
|
||||
}
|
||||
|
||||
export function decryptJsonForUser<T>(ciphertext: string | null | undefined, userSub: string): T | null {
|
||||
const plaintext = decryptForUser(ciphertext, userSub);
|
||||
if (!plaintext) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(plaintext) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
495
tests/components/QuizEditorAsyncConfig.test.tsx
Normal file
495
tests/components/QuizEditorAsyncConfig.test.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QuizEditor } from '../../components/QuizEditor';
|
||||
import { DEFAULT_GAME_CONFIG } from '../../types';
|
||||
import type { Quiz, GameConfig } from '../../types';
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9),
|
||||
}));
|
||||
|
||||
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
||||
title: 'Test Quiz',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'What is 2+2?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('QuizEditor - Async Default Config Loading', () => {
|
||||
const mockOnSave = vi.fn();
|
||||
const mockOnStartGame = vi.fn();
|
||||
const mockOnConfigChange = vi.fn();
|
||||
const mockOnBack = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderEditor = (props: Partial<React.ComponentProps<typeof QuizEditor>> = {}) => {
|
||||
const defaultProps = {
|
||||
quiz: createMockQuiz(),
|
||||
onSave: mockOnSave,
|
||||
onStartGame: mockOnStartGame,
|
||||
onConfigChange: mockOnConfigChange,
|
||||
onBack: mockOnBack,
|
||||
showSaveButton: true,
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
return render(<QuizEditor {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe('Initial config state', () => {
|
||||
it('should use DEFAULT_GAME_CONFIG when quiz has no config and no defaultConfig prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditor();
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: DEFAULT_GAME_CONFIG,
|
||||
}),
|
||||
DEFAULT_GAME_CONFIG
|
||||
);
|
||||
});
|
||||
|
||||
it('should use quiz.config when provided, ignoring defaultConfig prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
const quizConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: false,
|
||||
};
|
||||
|
||||
renderEditor({
|
||||
quiz: createMockQuiz({ config: quizConfig }),
|
||||
defaultConfig: {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleAnswers: true,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: false,
|
||||
shuffleAnswers: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Async defaultConfig loading (race condition fix)', () => {
|
||||
it('should apply defaultConfig when it loads after initial render', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
streakBonusEnabled: true,
|
||||
};
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={userDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: true,
|
||||
streakBonusEnabled: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT override quiz.config when defaultConfig loads later', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const quizConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: false,
|
||||
hostParticipates: false,
|
||||
};
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: true,
|
||||
};
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
quiz: createMockQuiz({ config: quizConfig }),
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz({ config: quizConfig })}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={userDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: false,
|
||||
hostParticipates: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should only apply defaultConfig once (not on every change)', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
};
|
||||
|
||||
const updatedDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
shuffleAnswers: true,
|
||||
};
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={userDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={updatedDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: true,
|
||||
shuffleAnswers: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT apply defaultConfig if it equals DEFAULT_GAME_CONFIG', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: undefined,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={DEFAULT_GAME_CONFIG}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: DEFAULT_GAME_CONFIG,
|
||||
}),
|
||||
DEFAULT_GAME_CONFIG
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User config modifications after async load', () => {
|
||||
it('should preserve user modifications after defaultConfig loads', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
};
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Game Settings'));
|
||||
await user.click(screen.getByText('Host Participates'));
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={userDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
hostParticipates: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page refresh simulation', () => {
|
||||
it('should correctly initialize config after page refresh with quiz.config', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const savedConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
streakBonusEnabled: true,
|
||||
hostParticipates: false,
|
||||
};
|
||||
|
||||
renderEditor({
|
||||
quiz: createMockQuiz({ config: savedConfig }),
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: true,
|
||||
streakBonusEnabled: true,
|
||||
hostParticipates: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply user defaults on refresh when quiz has no config', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleAnswers: true,
|
||||
penaltyForWrongAnswer: true,
|
||||
};
|
||||
|
||||
renderEditor({
|
||||
quiz: createMockQuiz(),
|
||||
defaultConfig: userDefaultConfig,
|
||||
});
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleAnswers: true,
|
||||
penaltyForWrongAnswer: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-happy path scenarios', () => {
|
||||
it('should handle undefined defaultConfig prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderEditor({
|
||||
defaultConfig: undefined,
|
||||
});
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: DEFAULT_GAME_CONFIG,
|
||||
}),
|
||||
DEFAULT_GAME_CONFIG
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rapid defaultConfig changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const configs = [
|
||||
{ ...DEFAULT_GAME_CONFIG, shuffleQuestions: true },
|
||||
{ ...DEFAULT_GAME_CONFIG, shuffleAnswers: true },
|
||||
{ ...DEFAULT_GAME_CONFIG, hostParticipates: false },
|
||||
];
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
for (const config of configs) {
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply defaultConfig even when component rerenders', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const userDefaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: false,
|
||||
};
|
||||
|
||||
const { rerender } = renderEditor({
|
||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz()}
|
||||
onSave={mockOnSave}
|
||||
onStartGame={mockOnStartGame}
|
||||
onConfigChange={mockOnConfigChange}
|
||||
onBack={mockOnBack}
|
||||
showSaveButton={true}
|
||||
defaultConfig={userDefaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
expect(mockOnStartGame).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: false,
|
||||
}),
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuizEditor - Config Priority Order', () => {
|
||||
const mockOnStartGame = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should follow priority: quiz.config > defaultConfig > DEFAULT_GAME_CONFIG', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const quizConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
};
|
||||
|
||||
const defaultConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleAnswers: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<QuizEditor
|
||||
quiz={createMockQuiz({ config: quizConfig })}
|
||||
onSave={vi.fn()}
|
||||
onStartGame={mockOnStartGame}
|
||||
onBack={vi.fn()}
|
||||
defaultConfig={defaultConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/Start Game/));
|
||||
|
||||
const calledConfig = mockOnStartGame.mock.calls[0][1];
|
||||
expect(calledConfig.shuffleQuestions).toBe(true);
|
||||
expect(calledConfig.shuffleAnswers).toBe(false);
|
||||
});
|
||||
});
|
||||
782
tests/hooks/useGame.draftPersistence.test.tsx
Normal file
782
tests/hooks/useGame.draftPersistence.test.tsx
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Quiz } from '../../types';
|
||||
import { DEFAULT_GAME_CONFIG } from '../../types';
|
||||
|
||||
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
|
||||
|
||||
interface DraftQuiz {
|
||||
quiz: Quiz;
|
||||
topic?: string;
|
||||
sourceQuizId?: string;
|
||||
}
|
||||
|
||||
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
||||
title: 'Test Quiz',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'What is 2+2?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Draft Quiz Persistence', () => {
|
||||
let storage: Map<string, string>;
|
||||
|
||||
const getDraftQuiz = (): DraftQuiz | null => {
|
||||
try {
|
||||
const stored = storage.get(DRAFT_QUIZ_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeDraftQuiz = (draft: DraftQuiz) => {
|
||||
storage.set(DRAFT_QUIZ_KEY, JSON.stringify(draft));
|
||||
};
|
||||
|
||||
const clearDraftQuiz = () => {
|
||||
storage.delete(DRAFT_QUIZ_KEY);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new Map<string, string>();
|
||||
});
|
||||
|
||||
describe('Draft Storage Operations', () => {
|
||||
it('should store a draft quiz', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, topic: 'Math' });
|
||||
|
||||
expect(storage.has(DRAFT_QUIZ_KEY)).toBe(true);
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Test Quiz');
|
||||
expect(stored?.topic).toBe('Math');
|
||||
});
|
||||
|
||||
it('should store draft quiz with sourceQuizId for library quizzes', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, sourceQuizId: 'quiz-abc-123' });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.sourceQuizId).toBe('quiz-abc-123');
|
||||
expect(stored?.topic).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should retrieve null when no draft exists', () => {
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear draft quiz', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz });
|
||||
|
||||
clearDraftQuiz();
|
||||
|
||||
expect(storage.has(DRAFT_QUIZ_KEY)).toBe(false);
|
||||
expect(getDraftQuiz()).toBeNull();
|
||||
});
|
||||
|
||||
it('should overwrite existing draft', () => {
|
||||
const quiz1 = createMockQuiz({ title: 'First Quiz' });
|
||||
const quiz2 = createMockQuiz({ title: 'Second Quiz' });
|
||||
|
||||
storeDraftQuiz({ quiz: quiz1, topic: 'Topic 1' });
|
||||
storeDraftQuiz({ quiz: quiz2, topic: 'Topic 2' });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Second Quiz');
|
||||
expect(stored?.topic).toBe('Topic 2');
|
||||
});
|
||||
|
||||
it('should preserve quiz config in draft', () => {
|
||||
const quizWithConfig = createMockQuiz({
|
||||
config: {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
hostParticipates: false,
|
||||
},
|
||||
});
|
||||
|
||||
storeDraftQuiz({ quiz: quizWithConfig });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.config?.shuffleQuestions).toBe(true);
|
||||
expect(stored?.quiz.config?.hostParticipates).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Path Generation for Editing', () => {
|
||||
const getEditTargetPath = (sourceQuizId: string | null): string => {
|
||||
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
|
||||
};
|
||||
|
||||
it('should return /edit/draft when no sourceQuizId', () => {
|
||||
expect(getEditTargetPath(null)).toBe('/edit/draft');
|
||||
});
|
||||
|
||||
it('should return /edit/:quizId when sourceQuizId exists', () => {
|
||||
expect(getEditTargetPath('quiz-123')).toBe('/edit/quiz-123');
|
||||
});
|
||||
|
||||
it('should handle UUID-style quiz IDs', () => {
|
||||
expect(getEditTargetPath('a1b2c3d4-e5f6-7890-abcd-ef1234567890'))
|
||||
.toBe('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
});
|
||||
|
||||
it('should handle short alphanumeric IDs', () => {
|
||||
expect(getEditTargetPath('abc123')).toBe('/edit/abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Initialization for Edit Routes', () => {
|
||||
interface InitResult {
|
||||
action: string;
|
||||
quiz?: Quiz;
|
||||
sourceQuizId?: string | null;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
const simulateEditUrlInit = (
|
||||
path: string,
|
||||
draftQuiz: DraftQuiz | null
|
||||
): InitResult => {
|
||||
if (path === '/edit/draft') {
|
||||
if (draftQuiz) {
|
||||
return {
|
||||
action: 'restoreDraft',
|
||||
quiz: draftQuiz.quiz,
|
||||
sourceQuizId: null,
|
||||
topic: draftQuiz.topic,
|
||||
};
|
||||
}
|
||||
return { action: 'navigateHome' };
|
||||
}
|
||||
|
||||
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
|
||||
if (editMatch) {
|
||||
const quizId = editMatch[1];
|
||||
if (draftQuiz && draftQuiz.sourceQuizId === quizId) {
|
||||
return {
|
||||
action: 'restoreLibraryQuiz',
|
||||
quiz: draftQuiz.quiz,
|
||||
sourceQuizId: quizId,
|
||||
};
|
||||
}
|
||||
return { action: 'navigateHome' };
|
||||
}
|
||||
|
||||
return { action: 'noMatch' };
|
||||
};
|
||||
|
||||
describe('/edit/draft URL', () => {
|
||||
it('should restore draft when draft exists in storage', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, topic: 'Science' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/draft', draft);
|
||||
|
||||
expect(result.action).toBe('restoreDraft');
|
||||
expect(result.quiz?.title).toBe('Test Quiz');
|
||||
expect(result.sourceQuizId).toBeNull();
|
||||
expect(result.topic).toBe('Science');
|
||||
});
|
||||
|
||||
it('should navigate home when no draft exists', () => {
|
||||
const result = simulateEditUrlInit('/edit/draft', null);
|
||||
|
||||
expect(result.action).toBe('navigateHome');
|
||||
});
|
||||
|
||||
it('should restore draft without topic (manual creation)', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/draft', draft);
|
||||
|
||||
expect(result.action).toBe('restoreDraft');
|
||||
expect(result.topic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/edit/:quizId URL', () => {
|
||||
it('should restore library quiz when quizId matches', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz-456' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/quiz-456', draft);
|
||||
|
||||
expect(result.action).toBe('restoreLibraryQuiz');
|
||||
expect(result.quiz?.title).toBe('Test Quiz');
|
||||
expect(result.sourceQuizId).toBe('quiz-456');
|
||||
});
|
||||
|
||||
it('should navigate home when quizId does not match stored draft', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz-789' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/quiz-456', draft);
|
||||
|
||||
expect(result.action).toBe('navigateHome');
|
||||
});
|
||||
|
||||
it('should navigate home when no draft exists', () => {
|
||||
const result = simulateEditUrlInit('/edit/quiz-456', null);
|
||||
|
||||
expect(result.action).toBe('navigateHome');
|
||||
});
|
||||
|
||||
it('should navigate home when draft has no sourceQuizId', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, topic: 'AI Generated' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/quiz-456', draft);
|
||||
|
||||
expect(result.action).toBe('navigateHome');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL pattern matching', () => {
|
||||
it('should match UUID-style quiz IDs', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, sourceQuizId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', draft);
|
||||
|
||||
expect(result.action).toBe('restoreLibraryQuiz');
|
||||
});
|
||||
|
||||
it('should match alphanumeric IDs', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, sourceQuizId: 'abc123XYZ' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/abc123XYZ', draft);
|
||||
|
||||
expect(result.action).toBe('restoreLibraryQuiz');
|
||||
});
|
||||
|
||||
it('should not match IDs with special characters', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz@123' };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/quiz@123', draft);
|
||||
|
||||
expect(result.action).toBe('noMatch');
|
||||
});
|
||||
|
||||
it('should not match /edit without ID', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz };
|
||||
|
||||
const result = simulateEditUrlInit('/edit', draft);
|
||||
|
||||
expect(result.action).toBe('noMatch');
|
||||
});
|
||||
|
||||
it('should not match /edit/ with trailing slash only', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const draft: DraftQuiz = { quiz };
|
||||
|
||||
const result = simulateEditUrlInit('/edit/', draft);
|
||||
|
||||
expect(result.action).toBe('noMatch');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft Lifecycle Operations', () => {
|
||||
describe('loadSavedQuiz (library quiz)', () => {
|
||||
it('should store quiz with sourceQuizId when loading from library', () => {
|
||||
const quiz = createMockQuiz({ title: 'Library Quiz' });
|
||||
const quizId = 'lib-quiz-123';
|
||||
|
||||
storeDraftQuiz({ quiz, sourceQuizId: quizId });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Library Quiz');
|
||||
expect(stored?.sourceQuizId).toBe(quizId);
|
||||
expect(stored?.topic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startQuizGen (AI generated quiz)', () => {
|
||||
it('should store quiz with topic when AI generates quiz', () => {
|
||||
const quiz = createMockQuiz({ title: 'AI Quiz' });
|
||||
const topic = 'Ancient History';
|
||||
|
||||
storeDraftQuiz({ quiz, topic });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('AI Quiz');
|
||||
expect(stored?.topic).toBe(topic);
|
||||
expect(stored?.sourceQuizId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should store quiz with file names as topic', () => {
|
||||
const quiz = createMockQuiz();
|
||||
const topic = 'document1.pdf, document2.pdf';
|
||||
|
||||
storeDraftQuiz({ quiz, topic });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.topic).toBe('document1.pdf, document2.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateQuizFromEditor', () => {
|
||||
it('should update stored draft when quiz is edited', () => {
|
||||
const originalQuiz = createMockQuiz({ title: 'Original' });
|
||||
storeDraftQuiz({ quiz: originalQuiz, topic: 'Topic' });
|
||||
|
||||
const updatedQuiz = createMockQuiz({ title: 'Updated' });
|
||||
const currentDraft = getDraftQuiz();
|
||||
storeDraftQuiz({
|
||||
quiz: updatedQuiz,
|
||||
topic: currentDraft?.topic,
|
||||
sourceQuizId: currentDraft?.sourceQuizId,
|
||||
});
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Updated');
|
||||
expect(stored?.topic).toBe('Topic');
|
||||
});
|
||||
|
||||
it('should preserve sourceQuizId when updating library quiz', () => {
|
||||
const originalQuiz = createMockQuiz({ title: 'Library Original' });
|
||||
storeDraftQuiz({ quiz: originalQuiz, sourceQuizId: 'lib-123' });
|
||||
|
||||
const updatedQuiz = createMockQuiz({ title: 'Library Updated' });
|
||||
const currentDraft = getDraftQuiz();
|
||||
storeDraftQuiz({
|
||||
quiz: updatedQuiz,
|
||||
topic: currentDraft?.topic,
|
||||
sourceQuizId: currentDraft?.sourceQuizId,
|
||||
});
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Library Updated');
|
||||
expect(stored?.sourceQuizId).toBe('lib-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startGameFromEditor', () => {
|
||||
it('should clear draft when starting game', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, topic: 'Test' });
|
||||
|
||||
expect(getDraftQuiz()).not.toBeNull();
|
||||
|
||||
clearDraftQuiz();
|
||||
|
||||
expect(getDraftQuiz()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backFromEditor', () => {
|
||||
it('should clear draft when going back from editor', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, sourceQuizId: 'lib-456' });
|
||||
|
||||
expect(getDraftQuiz()).not.toBeNull();
|
||||
|
||||
clearDraftQuiz();
|
||||
|
||||
expect(getDraftQuiz()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Happy Path Scenarios', () => {
|
||||
describe('Corrupted storage', () => {
|
||||
it('should return null for corrupted JSON', () => {
|
||||
storage.set(DRAFT_QUIZ_KEY, 'not valid json {{{');
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
storage.set(DRAFT_QUIZ_KEY, '');
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle storage with wrong structure gracefully', () => {
|
||||
storage.set(DRAFT_QUIZ_KEY, JSON.stringify({ wrong: 'structure' }));
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.quiz).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle quiz with empty questions array', () => {
|
||||
const quiz = createMockQuiz({ questions: [] });
|
||||
storeDraftQuiz({ quiz });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.questions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle quiz with empty title', () => {
|
||||
const quiz = createMockQuiz({ title: '' });
|
||||
storeDraftQuiz({ quiz });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty topic string', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, topic: '' });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.topic).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long quiz content', () => {
|
||||
const longText = 'A'.repeat(10000);
|
||||
const quiz = createMockQuiz({
|
||||
title: longText,
|
||||
questions: Array(100).fill(null).map((_, i) => ({
|
||||
id: `q${i}`,
|
||||
text: longText,
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: longText, isCorrect: true, shape: 'triangle' as const, color: 'red' as const },
|
||||
],
|
||||
})),
|
||||
});
|
||||
|
||||
storeDraftQuiz({ quiz });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe(longText);
|
||||
expect(stored?.quiz.questions).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stale draft detection', () => {
|
||||
it('should detect when URL quizId does not match stored sourceQuizId', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, sourceQuizId: 'old-quiz-id' });
|
||||
|
||||
const draft = getDraftQuiz();
|
||||
const urlQuizId = 'new-quiz-id';
|
||||
|
||||
const isStale = draft?.sourceQuizId !== urlQuizId;
|
||||
expect(isStale).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect when accessing /edit/:id but draft is for AI quiz (no sourceQuizId)', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, topic: 'AI Generated' });
|
||||
|
||||
const draft = getDraftQuiz();
|
||||
const urlQuizId = 'some-quiz-id';
|
||||
|
||||
const canRestore = draft?.sourceQuizId === urlQuizId;
|
||||
expect(canRestore).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect when accessing /edit/draft but draft has sourceQuizId', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz, sourceQuizId: 'lib-quiz' });
|
||||
|
||||
const draft = getDraftQuiz();
|
||||
|
||||
const isDraftRoute = true;
|
||||
const hasSourceId = !!draft?.sourceQuizId;
|
||||
|
||||
expect(hasSourceId).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple browser tabs', () => {
|
||||
it('should overwrite draft from another tab', () => {
|
||||
const quiz1 = createMockQuiz({ title: 'Tab 1 Quiz' });
|
||||
storeDraftQuiz({ quiz: quiz1, topic: 'Tab 1' });
|
||||
|
||||
const quiz2 = createMockQuiz({ title: 'Tab 2 Quiz' });
|
||||
storeDraftQuiz({ quiz: quiz2, topic: 'Tab 2' });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored?.quiz.title).toBe('Tab 2 Quiz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page refresh timing', () => {
|
||||
it('should restore draft immediately on page load', () => {
|
||||
const quiz = createMockQuiz({ title: 'Refreshed Quiz' });
|
||||
storeDraftQuiz({ quiz, topic: 'Refresh Test' });
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.quiz.title).toBe('Refreshed Quiz');
|
||||
});
|
||||
|
||||
it('should handle refresh when storage was cleared externally', () => {
|
||||
const quiz = createMockQuiz();
|
||||
storeDraftQuiz({ quiz });
|
||||
|
||||
storage.clear();
|
||||
|
||||
const stored = getDraftQuiz();
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: Full Edit Flow', () => {
|
||||
it('should support full AI quiz edit flow: generate -> edit -> refresh -> continue', () => {
|
||||
const generatedQuiz = createMockQuiz({ title: 'AI Generated Quiz' });
|
||||
storeDraftQuiz({ quiz: generatedQuiz, topic: 'Physics' });
|
||||
|
||||
const afterRefresh = getDraftQuiz();
|
||||
expect(afterRefresh?.quiz.title).toBe('AI Generated Quiz');
|
||||
expect(afterRefresh?.topic).toBe('Physics');
|
||||
expect(afterRefresh?.sourceQuizId).toBeUndefined();
|
||||
|
||||
const editedQuiz = createMockQuiz({ title: 'AI Generated Quiz - Edited' });
|
||||
storeDraftQuiz({
|
||||
quiz: editedQuiz,
|
||||
topic: afterRefresh?.topic,
|
||||
sourceQuizId: afterRefresh?.sourceQuizId,
|
||||
});
|
||||
|
||||
const afterEdit = getDraftQuiz();
|
||||
expect(afterEdit?.quiz.title).toBe('AI Generated Quiz - Edited');
|
||||
|
||||
clearDraftQuiz();
|
||||
expect(getDraftQuiz()).toBeNull();
|
||||
});
|
||||
|
||||
it('should support full library quiz edit flow: load -> edit -> refresh -> continue', () => {
|
||||
const libraryQuiz = createMockQuiz({ title: 'My Saved Quiz' });
|
||||
const quizId = 'saved-quiz-abc';
|
||||
storeDraftQuiz({ quiz: libraryQuiz, sourceQuizId: quizId });
|
||||
|
||||
const afterRefresh = getDraftQuiz();
|
||||
expect(afterRefresh?.quiz.title).toBe('My Saved Quiz');
|
||||
expect(afterRefresh?.sourceQuizId).toBe(quizId);
|
||||
|
||||
const editedQuiz = createMockQuiz({ title: 'My Saved Quiz - Updated' });
|
||||
storeDraftQuiz({
|
||||
quiz: editedQuiz,
|
||||
topic: afterRefresh?.topic,
|
||||
sourceQuizId: afterRefresh?.sourceQuizId,
|
||||
});
|
||||
|
||||
const afterEdit = getDraftQuiz();
|
||||
expect(afterEdit?.quiz.title).toBe('My Saved Quiz - Updated');
|
||||
expect(afterEdit?.sourceQuizId).toBe(quizId);
|
||||
|
||||
clearDraftQuiz();
|
||||
expect(getDraftQuiz()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Route Authentication Protection', () => {
|
||||
interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const shouldRedirectToAuth = (
|
||||
pathname: string,
|
||||
auth: AuthState
|
||||
): { redirect: boolean; action?: 'signin' | 'wait' | 'allow' } => {
|
||||
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(pathname) &&
|
||||
pathname !== '/edit/draft';
|
||||
|
||||
if (!isLibraryQuizRoute) {
|
||||
return { redirect: false, action: 'allow' };
|
||||
}
|
||||
|
||||
if (auth.isLoading) {
|
||||
return { redirect: false, action: 'wait' };
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return { redirect: true, action: 'signin' };
|
||||
}
|
||||
|
||||
return { redirect: false, action: 'allow' };
|
||||
};
|
||||
|
||||
describe('Library quiz routes (/edit/:quizId)', () => {
|
||||
it('should require auth for library quiz routes', () => {
|
||||
const result = shouldRedirectToAuth('/edit/quiz-123', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(true);
|
||||
expect(result.action).toBe('signin');
|
||||
});
|
||||
|
||||
it('should allow authenticated users to view library quizzes', () => {
|
||||
const result = shouldRedirectToAuth('/edit/quiz-123', {
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('should wait while auth is loading', () => {
|
||||
const result = shouldRedirectToAuth('/edit/quiz-123', {
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('wait');
|
||||
});
|
||||
|
||||
it('should require auth for UUID-style quiz IDs', () => {
|
||||
const result = shouldRedirectToAuth('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(true);
|
||||
expect(result.action).toBe('signin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft routes (/edit/draft)', () => {
|
||||
it('should NOT require auth for draft route', () => {
|
||||
const result = shouldRedirectToAuth('/edit/draft', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('should allow unauthenticated users to view drafts', () => {
|
||||
const result = shouldRedirectToAuth('/edit/draft', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-edit routes', () => {
|
||||
it('should NOT require auth for landing page', () => {
|
||||
const result = shouldRedirectToAuth('/', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('should NOT require auth for create route', () => {
|
||||
const result = shouldRedirectToAuth('/create', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('should NOT require auth for play route', () => {
|
||||
const result = shouldRedirectToAuth('/play/123456', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('should NOT require auth for host route', () => {
|
||||
const result = shouldRedirectToAuth('/host/123456', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth state transitions', () => {
|
||||
it('should redirect after auth finishes loading (unauthenticated)', () => {
|
||||
const auth = { isLoading: true, isAuthenticated: false };
|
||||
|
||||
const beforeLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
|
||||
expect(beforeLoad.action).toBe('wait');
|
||||
|
||||
auth.isLoading = false;
|
||||
|
||||
const afterLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
|
||||
expect(afterLoad.redirect).toBe(true);
|
||||
expect(afterLoad.action).toBe('signin');
|
||||
});
|
||||
|
||||
it('should allow access after auth finishes loading (authenticated)', () => {
|
||||
const auth = { isLoading: true, isAuthenticated: false };
|
||||
|
||||
const beforeLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
|
||||
expect(beforeLoad.action).toBe('wait');
|
||||
|
||||
auth.isLoading = false;
|
||||
auth.isAuthenticated = true;
|
||||
|
||||
const afterLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
|
||||
expect(afterLoad.redirect).toBe(false);
|
||||
expect(afterLoad.action).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle /edit without trailing path', () => {
|
||||
const result = shouldRedirectToAuth('/edit', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle /edit/ with trailing slash', () => {
|
||||
const result = shouldRedirectToAuth('/edit/', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle special characters in URL (should not match)', () => {
|
||||
const result = shouldRedirectToAuth('/edit/quiz@123', {
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(result.redirect).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,8 @@ describe('URL Routing and Navigation', () => {
|
|||
gameState: string,
|
||||
gamePin: string | null,
|
||||
role: 'HOST' | 'CLIENT',
|
||||
currentPath: string
|
||||
currentPath: string,
|
||||
sourceQuizId: string | null = null
|
||||
): string => {
|
||||
switch (gameState) {
|
||||
case 'LANDING':
|
||||
|
|
@ -18,7 +19,7 @@ describe('URL Routing and Navigation', () => {
|
|||
case 'GENERATING':
|
||||
return '/create';
|
||||
case 'EDITING':
|
||||
return '/edit';
|
||||
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
|
||||
case 'LOBBY':
|
||||
case 'COUNTDOWN':
|
||||
case 'QUESTION':
|
||||
|
|
@ -66,8 +67,17 @@ describe('URL Routing and Navigation', () => {
|
|||
});
|
||||
|
||||
describe('EDITING state', () => {
|
||||
it('should return "/edit" for EDITING state', () => {
|
||||
expect(getTargetPath('EDITING', null, 'HOST', '/')).toBe('/edit');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -128,7 +138,7 @@ describe('URL Routing and Navigation', () => {
|
|||
const simulateUrlInit = (
|
||||
path: string,
|
||||
storedSession: StoredSession | null
|
||||
): { action: string; gamePin?: string; shouldReconnect?: boolean } => {
|
||||
): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
|
||||
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
||||
const playMatch = path.match(/^\/play\/(\d+)$/);
|
||||
|
||||
|
|
@ -152,11 +162,14 @@ describe('URL Routing and Navigation', () => {
|
|||
return { action: 'startCreating' };
|
||||
}
|
||||
|
||||
if (path === '/edit') {
|
||||
if (!storedSession) {
|
||||
return { action: 'navigateHome' };
|
||||
}
|
||||
return { action: 'continueEditing' };
|
||||
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) {
|
||||
|
|
@ -230,16 +243,35 @@ describe('URL Routing and Navigation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('/edit URL', () => {
|
||||
it('should navigate home when no session', () => {
|
||||
const result = simulateUrlInit('/edit', null);
|
||||
expect(result.action).toBe('navigateHome');
|
||||
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 continue editing when session exists', () => {
|
||||
it('should restore draft regardless of session state', () => {
|
||||
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
||||
const result = simulateUrlInit('/edit', session);
|
||||
expect(result.action).toBe('continueEditing');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -592,14 +624,33 @@ describe('URL Routing and Navigation', () => {
|
|||
expect(gamePin).toBe('123456');
|
||||
});
|
||||
|
||||
it('should redirect to home when accessing /edit without any session', () => {
|
||||
const path = '/edit';
|
||||
const session = null;
|
||||
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') {
|
||||
if (!session) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1012,7 +1063,8 @@ describe('OAuth Callback Handling', () => {
|
|||
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('/edit/draft')).toBe(false);
|
||||
expect(shouldSkipUrlInit('/edit/quiz-123')).toBe(false);
|
||||
expect(shouldSkipUrlInit('/host/123456')).toBe(false);
|
||||
expect(shouldSkipUrlInit('/play/123456')).toBe(false);
|
||||
});
|
||||
|
|
@ -1031,12 +1083,12 @@ describe('OAuth Callback Handling', () => {
|
|||
expect(urlInitRan).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent URL sync from running during OAuth flow', () => {
|
||||
it('should prevent URL sync from running while auth is loading', () => {
|
||||
let urlSyncRan = false;
|
||||
const pathname = '/callback';
|
||||
const authIsLoading = true;
|
||||
|
||||
const syncUrl = () => {
|
||||
if (pathname === '/callback') return;
|
||||
if (authIsLoading) return;
|
||||
urlSyncRan = true;
|
||||
};
|
||||
|
||||
|
|
@ -1044,6 +1096,21 @@ describe('OAuth Callback Handling', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue