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:
Joey Yakimowich-Payne 2026-01-15 10:12:05 -07:00
commit e480ad06df
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 1775 additions and 94 deletions

View 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);
});
});

View 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);
});
});
});

View file

@ -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', () => {