Add tests and edit works
This commit is contained in:
parent
bfbba7b5ab
commit
bc4b0e2df7
12 changed files with 2415 additions and 20 deletions
45
App.tsx
45
App.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { useGame } from './hooks/useGame';
|
import { useGame } from './hooks/useGame';
|
||||||
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
||||||
|
|
@ -11,6 +11,7 @@ import { QuizCreator } from './components/QuizCreator';
|
||||||
import { RevealScreen } from './components/RevealScreen';
|
import { RevealScreen } from './components/RevealScreen';
|
||||||
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
||||||
import { QuizEditor } from './components/QuizEditor';
|
import { QuizEditor } from './components/QuizEditor';
|
||||||
|
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
||||||
import type { Quiz } from './types';
|
import type { Quiz } from './types';
|
||||||
|
|
||||||
const seededRandom = (seed: number) => {
|
const seededRandom = (seed: number) => {
|
||||||
|
|
@ -40,7 +41,9 @@ const FloatingShapes = React.memo(() => {
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const { saveQuiz } = useQuizLibrary();
|
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
||||||
|
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
||||||
|
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
|
||||||
const {
|
const {
|
||||||
role,
|
role,
|
||||||
gameState,
|
gameState,
|
||||||
|
|
@ -85,12 +88,33 @@ function App() {
|
||||||
const handleEditorSave = async (editedQuiz: Quiz) => {
|
const handleEditorSave = async (editedQuiz: Quiz) => {
|
||||||
updateQuizFromEditor(editedQuiz);
|
updateQuizFromEditor(editedQuiz);
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
|
if (sourceQuizId) {
|
||||||
const topic = pendingQuizToSave?.topic || undefined;
|
// Quiz was loaded from library - show options modal
|
||||||
await saveQuiz(editedQuiz, source, topic);
|
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];
|
const currentQ = quiz?.questions[currentQuestionIndex];
|
||||||
|
|
||||||
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
||||||
|
|
@ -204,6 +228,17 @@ function App() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SaveOptionsModal
|
||||||
|
isOpen={showSaveOptions}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveOptions(false);
|
||||||
|
setPendingEditedQuiz(null);
|
||||||
|
}}
|
||||||
|
onOverwrite={handleOverwriteQuiz}
|
||||||
|
onSaveNew={handleSaveAsNew}
|
||||||
|
isSaving={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ interface QuizEditorProps {
|
||||||
onSave: (quiz: Quiz) => void;
|
onSave: (quiz: Quiz) => void;
|
||||||
onStartGame: (quiz: Quiz) => void;
|
onStartGame: (quiz: Quiz) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
sourceQuizId?: string | null;
|
showSaveButton?: boolean;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
onSave,
|
onSave,
|
||||||
onStartGame,
|
onStartGame,
|
||||||
onBack,
|
onBack,
|
||||||
sourceQuizId,
|
showSaveButton = true,
|
||||||
isSaving
|
isSaving
|
||||||
}) => {
|
}) => {
|
||||||
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
||||||
|
|
@ -146,14 +146,17 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{showSaveButton && (
|
||||||
onClick={() => onSave(quiz)}
|
<button
|
||||||
disabled={isSaving}
|
onClick={() => onSave(quiz)}
|
||||||
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
|
disabled={isSaving}
|
||||||
title={sourceQuizId ? 'Save changes' : 'Save to library'}
|
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
|
||||||
>
|
title="Save to library"
|
||||||
<Save size={24} />
|
>
|
||||||
</button>
|
<Save size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showSaveButton && <div className="w-10" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>
|
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>
|
||||||
|
|
|
||||||
83
components/SaveOptionsModal.tsx
Normal file
83
components/SaveOptionsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -13,6 +13,7 @@ interface UseQuizLibraryReturn {
|
||||||
fetchQuizzes: () => Promise<void>;
|
fetchQuizzes: () => Promise<void>;
|
||||||
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
||||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||||
|
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||||
deleteQuiz: (id: string) => Promise<void>;
|
deleteQuiz: (id: string) => Promise<void>;
|
||||||
retry: () => Promise<void>;
|
retry: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
@ -160,6 +161,48 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [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> => {
|
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
||||||
setDeletingQuizId(id);
|
setDeletingQuizId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -209,6 +252,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
fetchQuizzes,
|
fetchQuizzes,
|
||||||
loadQuiz,
|
loadQuiz,
|
||||||
saveQuiz,
|
saveQuiz,
|
||||||
|
updateQuiz,
|
||||||
deleteQuiz,
|
deleteQuiz,
|
||||||
retry,
|
retry,
|
||||||
clearError,
|
clearError,
|
||||||
|
|
|
||||||
1322
package-lock.json
generated
1322
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -6,7 +6,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -26,9 +29,15 @@
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,322 @@ async function runTests() {
|
||||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
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:');
|
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
|
||||||
|
|
||||||
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
||||||
|
|
|
||||||
200
tests/components/SaveOptionsModal.test.tsx
Normal file
200
tests/components/SaveOptionsModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
tests/hooks/useQuizLibrary.test.tsx
Normal file
353
tests/hooks/useQuizLibrary.test.tsx
Normal 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
20
tests/setup.tsx
Normal 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}</>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": [
|
||||||
"node",
|
"node",
|
||||||
"vite/client"
|
"vite/client",
|
||||||
|
"vitest/globals"
|
||||||
],
|
],
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,17 @@ export default defineConfig(({ mode }) => {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': 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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue