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
782 lines
24 KiB
TypeScript
782 lines
24 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|