Add tests and edit works

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 23:52:04 -07:00
commit bc4b0e2df7
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
12 changed files with 2415 additions and 20 deletions

39
App.tsx
View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
@ -11,6 +11,7 @@ import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
import { SaveOptionsModal } from './components/SaveOptionsModal';
import type { Quiz } from './types';
const seededRandom = (seed: number) => {
@ -40,7 +41,9 @@ const FloatingShapes = React.memo(() => {
function App() {
const auth = useAuth();
const { saveQuiz } = useQuizLibrary();
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
const [showSaveOptions, setShowSaveOptions] = useState(false);
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
const {
role,
gameState,
@ -85,10 +88,31 @@ function App() {
const handleEditorSave = async (editedQuiz: Quiz) => {
updateQuizFromEditor(editedQuiz);
if (auth.isAuthenticated) {
if (sourceQuizId) {
// Quiz was loaded from library - show options modal
setPendingEditedQuiz(editedQuiz);
setShowSaveOptions(true);
} else {
// New quiz (AI-generated or manual) - save as new
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave?.topic || undefined;
await saveQuiz(editedQuiz, source, topic);
}
}
};
const handleOverwriteQuiz = async () => {
if (!pendingEditedQuiz || !sourceQuizId) return;
await updateQuiz(sourceQuizId, pendingEditedQuiz);
setShowSaveOptions(false);
setPendingEditedQuiz(null);
};
const handleSaveAsNew = async () => {
if (!pendingEditedQuiz) return;
await saveQuiz(pendingEditedQuiz, 'manual');
setShowSaveOptions(false);
setPendingEditedQuiz(null);
};
const currentQ = quiz?.questions[currentQuestionIndex];
@ -204,6 +228,17 @@ function App() {
/>
) : null}
</div>
<SaveOptionsModal
isOpen={showSaveOptions}
onClose={() => {
setShowSaveOptions(false);
setPendingEditedQuiz(null);
}}
onOverwrite={handleOverwriteQuiz}
onSaveNew={handleSaveAsNew}
isSaving={saving}
/>
</div>
);
}

View file

@ -13,7 +13,7 @@ interface QuizEditorProps {
onSave: (quiz: Quiz) => void;
onStartGame: (quiz: Quiz) => void;
onBack: () => void;
sourceQuizId?: string | null;
showSaveButton?: boolean;
isSaving?: boolean;
}
@ -22,7 +22,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
onSave,
onStartGame,
onBack,
sourceQuizId,
showSaveButton = true,
isSaving
}) => {
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
@ -146,14 +146,17 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
</p>
</div>
{showSaveButton && (
<button
onClick={() => onSave(quiz)}
disabled={isSaving}
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
title={sourceQuizId ? 'Save changes' : 'Save to library'}
title="Save to library"
>
<Save size={24} />
</button>
)}
{!showSaveButton && <div className="w-10" />}
</div>
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>

View file

@ -0,0 +1,83 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Save, Copy, X } from 'lucide-react';
interface SaveOptionsModalProps {
isOpen: boolean;
onClose: () => void;
onSaveNew: () => void;
onOverwrite: () => void;
isSaving?: boolean;
}
export const SaveOptionsModal: React.FC<SaveOptionsModalProps> = ({
isOpen,
onClose,
onSaveNew,
onOverwrite,
isSaving
}) => {
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-black text-gray-900">Save Quiz</h3>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition"
>
<X size={20} />
</button>
</div>
<p className="text-gray-600 mb-6">
This quiz was loaded from your library. How would you like to save your changes?
</p>
<div className="space-y-3">
<button
onClick={onOverwrite}
disabled={isSaving}
className="w-full flex items-center gap-3 p-4 rounded-xl border-2 border-theme-primary bg-theme-primary/5 hover:bg-theme-primary/10 transition disabled:opacity-50"
>
<div className="p-2 bg-theme-primary rounded-lg">
<Save size={20} className="text-white" />
</div>
<div className="text-left">
<p className="font-bold text-gray-900">Update existing quiz</p>
<p className="text-sm text-gray-500">Overwrite the original with your changes</p>
</div>
</button>
<button
onClick={onSaveNew}
disabled={isSaving}
className="w-full flex items-center gap-3 p-4 rounded-xl border-2 border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition disabled:opacity-50"
>
<div className="p-2 bg-gray-200 rounded-lg">
<Copy size={20} className="text-gray-600" />
</div>
<div className="text-left">
<p className="font-bold text-gray-900">Save as new quiz</p>
<p className="text-sm text-gray-500">Keep the original and create a copy</p>
</div>
</button>
</div>
</motion.div>
</motion.div>
);
};

View file

@ -13,6 +13,7 @@ interface UseQuizLibraryReturn {
fetchQuizzes: () => Promise<void>;
loadQuiz: (id: string) => Promise<SavedQuiz>;
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
deleteQuiz: (id: string) => Promise<void>;
retry: () => Promise<void>;
clearError: () => void;
@ -160,6 +161,48 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
}
}, [authFetch]);
const updateQuiz = useCallback(async (id: string, quiz: Quiz): Promise<void> => {
setSaving(true);
setError(null);
try {
const response = await authFetch(`/api/quizzes/${id}`, {
method: 'PUT',
body: JSON.stringify({
title: quiz.title,
questions: quiz.questions.map(q => ({
text: q.text,
timeLimit: q.timeLimit,
options: q.options.map(o => ({
text: o.text,
isCorrect: o.isCorrect,
shape: o.shape,
color: o.color,
reason: o.reason,
})),
})),
}),
});
if (!response.ok) {
const errorText = response.status === 404
? 'Quiz not found.'
: 'Failed to update quiz.';
throw new Error(errorText);
}
toast.success('Quiz updated!');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update quiz';
if (!message.includes('redirecting')) {
toast.error(message);
}
throw err;
} finally {
setSaving(false);
}
}, [authFetch]);
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
setDeletingQuizId(id);
setError(null);
@ -209,6 +252,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
fetchQuizzes,
loadQuiz,
saveQuiz,
updateQuiz,
deleteQuiz,
retry,
clearError,

1322
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -26,9 +29,15 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"vitest": "^4.0.17"
}
}

View file

@ -941,6 +941,322 @@ async function runTests() {
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
console.log('\nPUT Endpoint Edge Case Tests:');
await test('PUT /api/quizzes/:id with whitespace-only title returns 400', async () => {
const validQuiz = {
title: 'Quiz for PUT whitespace test',
source: 'manual',
questions: [
{
text: 'Question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const invalidUpdate = {
title: ' ',
questions: [
{
text: 'Q?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id with question without text returns 400', async () => {
const validQuiz = {
title: 'Quiz for PUT empty question test',
source: 'manual',
questions: [
{
text: 'Original question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const invalidUpdate = {
title: 'Updated title',
questions: [
{
text: '',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id with single option returns 400', async () => {
const validQuiz = {
title: 'Quiz for PUT single option test',
source: 'manual',
questions: [
{
text: 'Original question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const invalidUpdate = {
title: 'Updated title',
questions: [
{
text: 'Question with one option?',
options: [
{ text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id with no correct answer returns 400', async () => {
const validQuiz = {
title: 'Quiz for PUT no correct test',
source: 'manual',
questions: [
{
text: 'Original question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const invalidUpdate = {
title: 'Updated title',
questions: [
{
text: 'Question with no correct?',
options: [
{ text: 'A', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id with null questions returns 400', async () => {
const validQuiz = {
title: 'Quiz for PUT null questions test',
source: 'manual',
questions: [
{
text: 'Original question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const invalidUpdate = {
title: 'Updated title',
questions: null,
};
await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id preserves source and aiTopic', async () => {
const aiQuiz = {
title: 'AI Quiz for PUT preserve test',
source: 'ai_generated',
aiTopic: 'History',
questions: [
{
text: 'Original question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
const quizId = (data as { id: string }).id;
const update = {
title: 'Updated AI Quiz',
questions: [
{
text: 'New question?',
options: [
{ text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' },
{ text: 'Y', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, update);
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as Record<string, unknown>;
if (quiz.source !== 'ai_generated') throw new Error('Source should be preserved');
if (quiz.aiTopic !== 'History') throw new Error('aiTopic should be preserved');
if (quiz.title !== 'Updated AI Quiz') throw new Error('Title should be updated');
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id on another users quiz returns 404', async () => {
const quiz = {
title: 'User isolation test',
questions: [
{
text: 'Q?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
await request('PUT', '/api/quizzes/non-existent-user-quiz-id', quiz, 404);
});
await test('PUT /api/quizzes/:id with many questions succeeds', async () => {
const validQuiz = {
title: 'Quiz for PUT many questions test',
source: 'manual',
questions: [
{
text: 'Original?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const manyQuestions = Array.from({ length: 30 }, (_, i) => ({
text: `Updated question ${i + 1}?`,
timeLimit: 15,
options: [
{ text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' },
{ text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' },
],
}));
const update = {
title: 'Quiz with 30 questions',
questions: manyQuestions,
};
await request('PUT', `/api/quizzes/${quizId}`, update);
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as { questions: unknown[] };
if (quiz.questions.length !== 30) {
throw new Error(`Expected 30 questions, got ${quiz.questions.length}`);
}
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('PUT /api/quizzes/:id preserves reason fields in options', async () => {
const validQuiz = {
title: 'Quiz for PUT reason test',
source: 'manual',
questions: [
{
text: 'Original?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
const quizId = (data as { id: string }).id;
const updateWithReasons = {
title: 'Quiz with reasons',
questions: [
{
text: 'Why is the sky blue?',
options: [
{ text: 'Rayleigh scattering', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Light scatters in atmosphere' },
{ text: 'Paint', isCorrect: false, shape: 'diamond', color: 'blue', reason: 'That is not how it works' },
],
},
],
};
await request('PUT', `/api/quizzes/${quizId}`, updateWithReasons);
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as { questions: { options: { reason?: string }[] }[] };
const correctOpt = quiz.questions[0].options.find((o: any) => o.isCorrect);
if (correctOpt?.reason !== 'Light scatters in atmosphere') {
throw new Error('Reason not preserved on update');
}
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
await test('POST /api/quizzes with same data creates separate quizzes', async () => {

View file

@ -0,0 +1,200 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SaveOptionsModal } from '../../components/SaveOptionsModal';
describe('SaveOptionsModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSaveNew: vi.fn(),
onOverwrite: vi.fn(),
isSaving: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders nothing when isOpen is false', () => {
render(<SaveOptionsModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<SaveOptionsModal {...defaultProps} />);
expect(screen.getByText('Save Quiz')).toBeInTheDocument();
});
it('displays explanation text', () => {
render(<SaveOptionsModal {...defaultProps} />);
expect(screen.getByText(/loaded from your library/i)).toBeInTheDocument();
});
it('shows update existing button', () => {
render(<SaveOptionsModal {...defaultProps} />);
expect(screen.getByText('Update existing quiz')).toBeInTheDocument();
});
it('shows save as new button', () => {
render(<SaveOptionsModal {...defaultProps} />);
expect(screen.getByText('Save as new quiz')).toBeInTheDocument();
});
it('shows close button', () => {
render(<SaveOptionsModal {...defaultProps} />);
const closeButtons = screen.getAllByRole('button');
expect(closeButtons.length).toBeGreaterThanOrEqual(3);
});
});
describe('interactions - happy path', () => {
it('calls onOverwrite when update existing is clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} />);
await user.click(screen.getByText('Update existing quiz'));
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
});
it('calls onSaveNew when save as new is clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} />);
await user.click(screen.getByText('Save as new quiz'));
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
});
it('calls onClose when close button (X) is clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} />);
const closeButton = screen.getByRole('button', { name: '' });
await user.click(closeButton);
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when backdrop is clicked', async () => {
render(<SaveOptionsModal {...defaultProps} />);
const backdrop = document.querySelector('.fixed.inset-0');
expect(backdrop).toBeInTheDocument();
fireEvent.click(backdrop!);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('does not close when modal content is clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} />);
await user.click(screen.getByText('Save Quiz'));
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
describe('interactions - unhappy path / edge cases', () => {
it('disables buttons when isSaving is true', () => {
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
const updateButton = screen.getByText('Update existing quiz').closest('button');
const saveNewButton = screen.getByText('Save as new quiz').closest('button');
expect(updateButton).toBeDisabled();
expect(saveNewButton).toBeDisabled();
});
it('does not call onOverwrite when disabled and clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
const button = screen.getByText('Update existing quiz').closest('button')!;
await user.click(button);
expect(defaultProps.onOverwrite).not.toHaveBeenCalled();
});
it('does not call onSaveNew when disabled and clicked', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
const button = screen.getByText('Save as new quiz').closest('button')!;
await user.click(button);
expect(defaultProps.onSaveNew).not.toHaveBeenCalled();
});
it('handles rapid clicks gracefully', async () => {
const user = userEvent.setup();
render(<SaveOptionsModal {...defaultProps} />);
const button = screen.getByText('Update existing quiz');
await user.click(button);
await user.click(button);
await user.click(button);
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(3);
});
it('remains functional after re-opening', async () => {
const user = userEvent.setup();
const { rerender } = render(<SaveOptionsModal {...defaultProps} />);
await user.click(screen.getByText('Update existing quiz'));
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
rerender(<SaveOptionsModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
rerender(<SaveOptionsModal {...defaultProps} isOpen={true} />);
await user.click(screen.getByText('Save as new quiz'));
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
});
});
describe('accessibility', () => {
it('all interactive elements are focusable', () => {
render(<SaveOptionsModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).not.toHaveAttribute('tabindex', '-1');
});
});
it('buttons have descriptive text for screen readers', () => {
render(<SaveOptionsModal {...defaultProps} />);
expect(screen.getByText('Overwrite the original with your changes')).toBeInTheDocument();
expect(screen.getByText('Keep the original and create a copy')).toBeInTheDocument();
});
});
describe('state transitions', () => {
it('transitions from not saving to saving correctly', async () => {
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={false} />);
const updateButton = screen.getByText('Update existing quiz').closest('button');
expect(updateButton).not.toBeDisabled();
rerender(<SaveOptionsModal {...defaultProps} isSaving={true} />);
expect(updateButton).toBeDisabled();
});
it('transitions from saving back to not saving', async () => {
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
const updateButton = screen.getByText('Update existing quiz').closest('button');
expect(updateButton).toBeDisabled();
rerender(<SaveOptionsModal {...defaultProps} isSaving={false} />);
expect(updateButton).not.toBeDisabled();
});
});
});

View file

@ -0,0 +1,353 @@
import React from 'react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
import type { Quiz } from '../../types';
const mockAuthFetch = vi.fn();
const mockIsAuthenticated = vi.fn(() => true);
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
useAuthenticatedFetch: () => ({
authFetch: mockAuthFetch,
isAuthenticated: mockIsAuthenticated(),
}),
}));
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
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('useQuizLibrary - updateQuiz', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAuthenticated.mockReturnValue(true);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('happy path', () => {
it('successfully updates a quiz', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'quiz-123' }),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({ title: 'Updated Quiz' });
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
});
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
method: 'PUT',
body: expect.stringContaining('Updated Quiz'),
});
expect(result.current.saving).toBe(false);
});
it('sets saving to true during update', async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockAuthFetch.mockReturnValueOnce(pendingPromise);
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
act(() => {
result.current.updateQuiz('quiz-123', quiz);
});
await waitFor(() => {
expect(result.current.saving).toBe(true);
});
await act(async () => {
resolvePromise!({ ok: true, json: () => Promise.resolve({}) });
});
await waitFor(() => {
expect(result.current.saving).toBe(false);
});
});
it('sends correct request body structure', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({
title: 'My Quiz',
questions: [
{
id: 'q1',
text: 'Question 1',
timeLimit: 30,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
});
await act(async () => {
await result.current.updateQuiz('quiz-456', quiz);
});
const [, options] = mockAuthFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.title).toBe('My Quiz');
expect(body.questions).toHaveLength(1);
expect(body.questions[0].text).toBe('Question 1');
expect(body.questions[0].timeLimit).toBe(30);
expect(body.questions[0].options[0].reason).toBe('Correct!');
});
it('handles quiz with multiple questions', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({
questions: [
{
id: 'q1',
text: 'Q1',
timeLimit: 20,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
{
id: 'q2',
text: 'Q2',
timeLimit: 25,
options: [
{ text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: 'D', isCorrect: true, shape: 'square', color: 'green' },
],
},
],
});
await act(async () => {
await result.current.updateQuiz('quiz-789', quiz);
});
const [, options] = mockAuthFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.questions).toHaveLength(2);
});
});
describe('unhappy path - API errors', () => {
it('handles 404 not found error', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
await result.current.updateQuiz('non-existent', quiz);
})
).rejects.toThrow('Quiz not found');
expect(result.current.saving).toBe(false);
});
it('handles generic server error', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
})
).rejects.toThrow('Failed to update quiz');
});
it('handles network error', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
})
).rejects.toThrow('Network error');
expect(result.current.saving).toBe(false);
});
it('handles timeout/abort error', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
mockAuthFetch.mockRejectedValueOnce(abortError);
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
})
).rejects.toThrow();
expect(result.current.saving).toBe(false);
});
});
describe('unhappy path - edge cases', () => {
it('resets saving state even on error', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
try {
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
});
} catch {
// Expected to throw
}
expect(result.current.saving).toBe(false);
});
it('handles empty quiz ID', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
await result.current.updateQuiz('', quiz);
})
).rejects.toThrow();
});
it('handles quiz with empty title', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({ title: '' });
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
});
const [, options] = mockAuthFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.title).toBe('');
});
it('strips undefined reason fields to null', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({
questions: [
{
id: 'q1',
text: 'Q',
timeLimit: 20,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
});
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
});
const [, options] = mockAuthFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.questions[0].options[0]).not.toHaveProperty('reason');
});
});
describe('concurrent operations', () => {
it('allows update after save completes', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'new-quiz' }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await act(async () => {
await result.current.saveQuiz(quiz, 'manual');
});
await act(async () => {
await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' });
});
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
});
});
});

20
tests/setup.tsx Normal file
View file

@ -0,0 +1,20 @@
import React from 'react';
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
afterEach(() => {
cleanup();
});
vi.mock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion');
return {
...actual,
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
};
});

View file

@ -12,7 +12,8 @@
"skipLibCheck": true,
"types": [
"node",
"vite/client"
"vite/client",
"vitest/globals"
],
"moduleResolution": "bundler",
"isolatedModules": true,

View file

@ -18,6 +18,17 @@ export default defineConfig(({ mode }) => {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.tsx'],
include: ['tests/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['components/**', 'hooks/**'],
},
},
};
});