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
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