Phase 4 complete

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 15:39:02 -07:00
commit 66f15b49b2
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
9 changed files with 710 additions and 37 deletions

View file

@ -46,6 +46,7 @@ function App() {
startQuizGen, startQuizGen,
startManualCreation, startManualCreation,
finalizeManualQuiz, finalizeManualQuiz,
loadSavedQuiz,
joinGame, joinGame,
startGame, startGame,
handleAnswer, handleAnswer,
@ -76,6 +77,7 @@ function App() {
<Landing <Landing
onGenerate={startQuizGen} onGenerate={startQuizGen}
onCreateManual={startManualCreation} onCreateManual={startManualCreation}
onLoadQuiz={loadSavedQuiz}
onJoin={joinGame} onJoin={joinGame}
isLoading={gameState === 'GENERATING'} isLoading={gameState === 'GENERATING'}
error={error} error={error}

View file

@ -194,39 +194,42 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
## Phase 4: Quiz Library Feature ## Phase 4: Quiz Library Feature
### 4.1 Quiz Library Hook ### 4.1 Quiz Library Hook
- [ ] Create `src/hooks/useQuizLibrary.ts`: - [x] Create `hooks/useQuizLibrary.ts`:
- [ ] State: `quizzes`, `loading`, `error` - [x] State: `quizzes`, `loading`, `error`
- [ ] `fetchQuizzes()` - GET /api/quizzes - [x] `fetchQuizzes()` - GET /api/quizzes
- [ ] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz - [x] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
- [ ] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes - [x] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
- [ ] `deleteQuiz(id)` - DELETE /api/quizzes/:id - [x] `deleteQuiz(id)` - DELETE /api/quizzes/:id
- [ ] Handle loading and error states - [x] Handle loading and error states
### 4.2 Quiz Library UI ### 4.2 Quiz Library UI
- [ ] Create `src/components/QuizLibrary.tsx`: - [x] Create `components/QuizLibrary.tsx`:
- [ ] Modal overlay design (consistent with app style) - [x] Modal overlay design (consistent with app style)
- [ ] Header: "My Quizzes" with close button - [x] Header: "My Library" with close button
- [ ] Search/filter input (optional, future enhancement) - [x] Quiz list:
- [ ] Quiz list: - [x] Show title, question count, source badge (AI/Manual), date
- [ ] Show title, question count, source badge (AI/Manual), date - [x] Click to load quiz
- [ ] Click to select - [x] Delete button with confirmation
- [ ] Delete button with confirmation - [x] Empty state: "No saved quizzes yet"
- [ ] Footer: "Load Selected" and "Cancel" buttons - [x] Loading state: Spinner
- [ ] Empty state: "No saved quizzes yet"
- [ ] Loading state: Skeleton/spinner
### 4.3 Landing Page Integration ### 4.3 Landing Page Integration
- [ ] Modify `src/components/Landing.tsx`: - [x] Modify `components/Landing.tsx`:
- [ ] Add "My Quizzes" button (only visible when authenticated) - [x] Add "My Quizzes" button (only visible when authenticated)
- [ ] Add state for quiz library modal visibility - [x] Add state for quiz library modal visibility
- [ ] Render `<QuizLibrary />` modal when open - [x] Render `<QuizLibrary />` modal when open
- [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz - [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
### 4.4 Types Update ### 4.4 Types Update
- [ ] Modify `src/types.ts`: - [x] Modify `types.ts`:
- [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) - [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
- [ ] Add `QuizListItem` interface (for list view) - [x] Add `QuizListItem` interface (for list view)
- [ ] Add `QuizSource` type: `'manual' | 'ai_generated'` - [x] Add `QuizSource` type: `'manual' | 'ai_generated'`
### 4.5 Game Hook Integration
- [x] Modify `hooks/useGame.ts`:
- [x] Add `loadSavedQuiz(quiz)` function
- [x] Export for App.tsx to consume
--- ---
@ -403,7 +406,7 @@ kaboot/
| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | | Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs |
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | | Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
| Phase 4 | Not Started | | | Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration |
| Phase 5 | Not Started | | | Phase 5 | Not Started | |
| Phase 6 | Not Started | | | Phase 6 | Not Started | |
| Phase 7 | Not Started | | | Phase 7 | Not Started | |

View file

@ -1,21 +1,36 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react'; import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton'; import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import type { Quiz } from '../types';
interface LandingProps { interface LandingProps {
onGenerate: (topic: string) => void; onGenerate: (topic: string) => void;
onCreateManual: () => void; onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz) => void;
onJoin: (pin: string, name: string) => void; onJoin: (pin: string, name: string) => void;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => { export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
const auth = useAuth();
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
const [topic, setTopic] = useState(''); const [topic, setTopic] = useState('');
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [libraryOpen, setLibraryOpen] = useState(false);
const { quizzes, loading: libraryLoading, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz } = useQuizLibrary();
useEffect(() => {
if (libraryOpen && auth.isAuthenticated) {
fetchQuizzes();
}
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
const handleHostSubmit = (e: React.FormEvent) => { const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -27,6 +42,12 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
if (pin.trim() && name.trim()) onJoin(pin, name); if (pin.trim() && name.trim()) onJoin(pin, name);
}; };
const handleLoadQuiz = async (id: string) => {
const quiz = await loadQuiz(id);
setLibraryOpen(false);
onLoadQuiz(quiz);
};
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative"> <div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4">
@ -93,6 +114,15 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
> >
<PenTool size={20} /> Create Manually <PenTool size={20} /> Create Manually
</button> </button>
{auth.isAuthenticated && (
<button
onClick={() => setLibraryOpen(true)}
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
>
<BookOpen size={20} /> My Quizzes
</button>
)}
</div> </div>
) : ( ) : (
<form onSubmit={handleJoinSubmit} className="space-y-4"> <form onSubmit={handleJoinSubmit} className="space-y-4">
@ -126,6 +156,16 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</motion.div> </motion.div>
)} )}
</motion.div> </motion.div>
<QuizLibrary
isOpen={libraryOpen}
onClose={() => setLibraryOpen(false)}
quizzes={quizzes}
loading={libraryLoading}
error={libraryError}
onLoadQuiz={handleLoadQuiz}
onDeleteQuiz={deleteQuiz}
/>
</div> </div>
); );
}; };

190
components/QuizLibrary.tsx Normal file
View file

@ -0,0 +1,190 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react';
import { QuizListItem } from '../types';
interface QuizLibraryProps {
isOpen: boolean;
onClose: () => void;
quizzes: QuizListItem[];
loading: boolean;
error: string | null;
onLoadQuiz: (id: string) => void;
onDeleteQuiz: (id: string) => void;
}
export const QuizLibrary: React.FC<QuizLibraryProps> = ({
isOpen,
onClose,
quizzes,
loading,
error,
onLoadQuiz,
onDeleteQuiz,
}) => {
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeletingId(id);
};
const confirmDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (deletingId) {
onDeleteQuiz(deletingId);
setDeletingId(null);
}
};
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setDeletingId(null);
};
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch (e) {
return dateString;
}
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", bounce: 0.4 }}
onClick={(e) => e.stopPropagation()}
className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
>
<div className="p-6 border-b-2 border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h2 className="text-3xl font-black text-gray-900 tracking-tight">My Library</h2>
<p className="text-gray-500 font-bold text-sm">Select a quiz to play</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600"
>
<X size={24} strokeWidth={3} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50">
{loading && (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<Loader2 size={48} className="animate-spin mb-4 text-theme-primary" />
<p className="font-bold">Loading your quizzes...</p>
</div>
)}
{!loading && error && (
<div className="bg-red-50 border-2 border-red-100 p-4 rounded-2xl text-red-500 font-bold text-center">
{error}
</div>
)}
{!loading && !error && quizzes.length === 0 && (
<div className="text-center py-12 space-y-4">
<div className="bg-gray-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4">
<BrainCircuit size={40} className="text-gray-300" />
</div>
<h3 className="text-xl font-black text-gray-400">No saved quizzes yet</h3>
<p className="text-gray-400 font-medium">Create or generate a quiz to save it here!</p>
</div>
)}
{!loading && !error && quizzes.map((quiz) => (
<motion.div
key={quiz.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all cursor-pointer relative overflow-hidden"
onClick={() => onLoadQuiz(quiz.id)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{quiz.source === 'ai_generated' ? (
<span className="bg-purple-100 text-purple-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
<BrainCircuit size={12} /> AI
</span>
) : (
<span className="bg-blue-100 text-blue-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
<PenTool size={12} /> Manual
</span>
)}
<span className="text-gray-400 text-xs font-bold flex items-center gap-1">
<Calendar size={12} /> {formatDate(quiz.createdAt)}
</span>
</div>
<h3 className="text-xl font-black text-gray-800 mb-1 group-hover:text-theme-primary transition-colors">
{quiz.title}
</h3>
<p className="text-gray-500 font-medium text-sm">
{quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''}
{quiz.aiTopic && <span className="text-gray-400"> Topic: {quiz.aiTopic}</span>}
</p>
</div>
<div className="flex items-center gap-3 pl-4">
{deletingId === quiz.id ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={confirmDelete}
className="bg-red-500 text-white px-3 py-2 rounded-xl text-sm font-bold shadow-[0_3px_0_#991b1b] active:shadow-none active:translate-y-[3px] transition-all"
>
Confirm
</button>
<button
onClick={cancelDelete}
className="bg-gray-200 text-gray-600 px-3 py-2 rounded-xl text-sm font-bold hover:bg-gray-300 transition-all"
>
Cancel
</button>
</div>
) : (
<>
<button
onClick={(e) => handleDeleteClick(e, quiz.id)}
className="p-3 rounded-xl text-gray-300 hover:bg-red-50 hover:text-red-500 transition-colors"
title="Delete quiz"
>
<Trash2 size={20} />
</button>
<div className="bg-theme-primary/10 p-3 rounded-xl text-theme-primary group-hover:bg-theme-primary group-hover:text-white transition-all">
<Play size={24} fill="currentColor" />
</div>
</>
)}
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View file

@ -93,20 +93,73 @@ Note these endpoints (you'll need them for frontend configuration):
2. You should see a JSON response with all OIDC endpoints 2. You should see a JSON response with all OIDC endpoints
## Step 6: Create a Test User (Optional) ## Step 6: Create a Test User
Create a regular user for manual browser testing.
1. Go to **Directory** > **Users** 1. Go to **Directory** > **Users**
2. Click **Create** 2. Click **Create**
3. Fill in user details: 3. Fill in user details:
- Username: `testuser` | Field | Value |
- Name: `Test User` |-------|-------|
- Email: `test@example.com` | Username | `kaboottest` |
| Name | `Kaboot Test` |
| Email | `kaboottest@test.com` |
4. After creation, click on the user and go to the **Credentials** tab 4. After creation, click on the user and go to the **Credentials** tab
5. Click **Set password** to create a password 5. Click **Set password** and set it to `kaboottest`
6. **Bind the user to the Kaboot application**:
- Go to **Applications** > **Applications** > **Kaboot**
- Click the **Policy / Group / User Bindings** tab
- Click **Bind existing user**
- Select `kaboottest` and click **Bind**
## Step 7: Create a Service Account for API Testing
Create a service account that can obtain tokens programmatically for automated tests.
1. Go to **Directory** > **Users**
2. Click **Create Service Account**
3. Fill in details:
| Field | Value |
|-------|-------|
| Username | `kaboot-test-service` |
| Create group | Unchecked |
4. Click **Create**
5. **Create an App Password** for the service account:
- Click on the newly created `kaboot-test-service` user
- Go to the **App passwords** tab
- Click **Create App Password**
- Name it `api-tests`
- Copy the generated password (you won't see it again!)
6. **Bind the service account to the Kaboot application**:
- Go to **Applications** > **Applications** > **Kaboot**
- Click the **Policy / Group / User Bindings** tab
- Click **Bind existing user**
- Select `kaboot-test-service` and click **Bind**
7. **Save credentials to `server/.env.test`**:
```bash
TEST_USERNAME=kaboot-test-service
TEST_PASSWORD=<paste-app-password-here>
```
8. **Verify token generation works**:
```bash
cd server
npm run test:get-token
```
You should see "Token obtained successfully" and the access token printed.
## Environment Variables ## Environment Variables

View file

@ -64,6 +64,10 @@ export const useGame = () => {
initializeHostGame(manualQuiz); initializeHostGame(manualQuiz);
}; };
const loadSavedQuiz = (savedQuiz: Quiz) => {
initializeHostGame(savedQuiz);
};
// We use a ref to hold the current handleHostData function // We use a ref to hold the current handleHostData function
// This prevents stale closures in the PeerJS event listeners // This prevents stale closures in the PeerJS event listeners
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
@ -379,6 +383,6 @@ export const useGame = () => {
return { return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
}; };
}; };

103
hooks/useQuizLibrary.ts Normal file
View file

@ -0,0 +1,103 @@
import { useState, useCallback } from 'react';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
interface UseQuizLibraryReturn {
quizzes: QuizListItem[];
loading: boolean;
error: string | null;
fetchQuizzes: () => Promise<void>;
loadQuiz: (id: string) => Promise<SavedQuiz>;
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
deleteQuiz: (id: string) => Promise<void>;
}
export const useQuizLibrary = (): UseQuizLibraryReturn => {
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
const [quizzes, setQuizzes] = useState<QuizListItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchQuizzes = useCallback(async () => {
if (!isAuthenticated) return;
setLoading(true);
setError(null);
try {
const response = await authFetch('/api/quizzes');
if (!response.ok) {
throw new Error('Failed to fetch quizzes');
}
const data = await response.json();
setQuizzes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch quizzes');
} finally {
setLoading(false);
}
}, [authFetch, isAuthenticated]);
const loadQuiz = useCallback(async (id: string): Promise<SavedQuiz> => {
const response = await authFetch(`/api/quizzes/${id}`);
if (!response.ok) {
throw new Error('Failed to load quiz');
}
return response.json();
}, [authFetch]);
const saveQuiz = useCallback(async (
quiz: Quiz,
source: QuizSource,
aiTopic?: string
): Promise<string> => {
const response = await authFetch('/api/quizzes', {
method: 'POST',
body: JSON.stringify({
title: quiz.title,
source,
aiTopic,
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) {
throw new Error('Failed to save quiz');
}
const data = await response.json();
return data.id;
}, [authFetch]);
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
const response = await authFetch(`/api/quizzes/${id}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
throw new Error('Failed to delete quiz');
}
setQuizzes(prev => prev.filter(q => q.id !== id));
}, [authFetch]);
return {
quizzes,
loading,
error,
fetchQuizzes,
loadQuiz,
saveQuiz,
deleteQuiz,
};
};

View file

@ -168,6 +168,264 @@ async function runTests() {
await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404);
}); });
console.log('\nQuiz Validation Tests:');
await test('POST /api/quizzes without title returns 400', async () => {
const invalidQuiz = {
source: 'manual',
questions: [
{
text: 'Question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
],
},
],
};
await request('POST', '/api/quizzes', invalidQuiz, 400);
});
await test('POST /api/quizzes without source returns 400', async () => {
const invalidQuiz = {
title: 'Missing Source Quiz',
questions: [
{
text: 'Question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
],
},
],
};
await request('POST', '/api/quizzes', invalidQuiz, 400);
});
await test('POST /api/quizzes without questions returns 400', async () => {
const invalidQuiz = {
title: 'No Questions Quiz',
source: 'manual',
questions: [],
};
await request('POST', '/api/quizzes', invalidQuiz, 400);
});
await test('POST /api/quizzes with empty body returns 400', async () => {
await request('POST', '/api/quizzes', {}, 400);
});
console.log('\nQuiz Not Found Tests:');
await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => {
await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404);
});
await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => {
const quiz = {
title: 'Update Non-Existent',
questions: [
{
text: 'Q?',
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
},
],
};
await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404);
});
await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => {
await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404);
});
console.log('\nQuiz Source Types Tests:');
let aiQuizId: string | null = null;
await test('POST /api/quizzes with ai_generated source and aiTopic', async () => {
const aiQuiz = {
title: 'AI Generated Quiz',
source: 'ai_generated',
aiTopic: 'Space Exploration',
questions: [
{
text: 'What planet is known as the Red Planet?',
timeLimit: 20,
options: [
{ text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' },
{ text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
const result = data as { id: string };
if (!result.id) throw new Error('Missing quiz id');
aiQuizId = result.id;
});
await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => {
if (!aiQuizId) throw new Error('No AI quiz created');
const { data } = await request('GET', `/api/quizzes/${aiQuizId}`);
const quiz = data as Record<string, unknown>;
if (quiz.source !== 'ai_generated') throw new Error('Wrong source');
if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic');
});
await test('GET /api/quizzes list includes source and questionCount', async () => {
const { data } = await request('GET', '/api/quizzes');
const quizzes = data as Record<string, unknown>[];
if (quizzes.length === 0) throw new Error('Expected at least one quiz');
const quiz = quizzes.find((q) => q.id === aiQuizId);
if (!quiz) throw new Error('AI quiz not in list');
if (quiz.source !== 'ai_generated') throw new Error('Missing source in list');
if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount');
if (quiz.questionCount !== 1) throw new Error('Wrong questionCount');
});
await test('DELETE cleanup AI quiz', async () => {
if (!aiQuizId) throw new Error('No AI quiz to delete');
await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204);
});
console.log('\nQuiz with Multiple Questions Tests:');
let multiQuizId: string | null = null;
await test('POST /api/quizzes with multiple questions', async () => {
const multiQuiz = {
title: 'Multi-Question Quiz',
source: 'manual',
questions: [
{
text: 'Question 1?',
timeLimit: 15,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
{
text: 'Question 2?',
timeLimit: 25,
options: [
{ text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: 'Y', isCorrect: true, shape: 'square', color: 'green' },
],
},
{
text: 'Question 3?',
timeLimit: 30,
options: [
{ text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' },
{ text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', multiQuiz, 201);
const result = data as { id: string };
multiQuizId = result.id;
});
await test('GET /api/quizzes/:id returns all questions with correct order', async () => {
if (!multiQuizId) throw new Error('No multi-question quiz created');
const { data } = await request('GET', `/api/quizzes/${multiQuizId}`);
const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] };
if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`);
if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1');
if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2');
if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3');
if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1');
if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field');
});
await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => {
const { data } = await request('GET', '/api/quizzes');
const quizzes = data as Record<string, unknown>[];
const quiz = quizzes.find((q) => q.id === multiQuizId);
if (!quiz) throw new Error('Multi-question quiz not in list');
if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`);
});
await test('PUT /api/quizzes/:id replaces all questions', async () => {
if (!multiQuizId) throw new Error('No multi-question quiz created');
const updatedQuiz = {
title: 'Updated Multi Quiz',
questions: [
{
text: 'Only One Question Now',
timeLimit: 10,
options: [
{ text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' },
],
},
],
};
await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz);
const { data } = await request('GET', `/api/quizzes/${multiQuizId}`);
const quiz = data as { title: string; questions: unknown[] };
if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated');
if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`);
});
await test('DELETE cleanup multi-question quiz', async () => {
if (!multiQuizId) throw new Error('No multi-question quiz to delete');
await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204);
});
console.log('\nTimestamp Tests:');
let timestampQuizId: string | null = null;
await test('POST /api/quizzes returns quiz with timestamps', async () => {
const quiz = {
title: 'Timestamp Test Quiz',
source: 'manual',
questions: [
{
text: 'Timestamp Q?',
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
},
],
};
const { data: createData } = await request('POST', '/api/quizzes', quiz, 201);
timestampQuizId = (createData as { id: string }).id;
const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`);
const result = data as Record<string, unknown>;
if (!result.createdAt) throw new Error('Missing createdAt');
if (!result.updatedAt) throw new Error('Missing updatedAt');
});
await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => {
if (!timestampQuizId) throw new Error('No timestamp quiz created');
const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`);
const beforeUpdatedAt = (beforeData as Record<string, unknown>).updatedAt;
await new Promise((resolve) => setTimeout(resolve, 1100));
await request('PUT', `/api/quizzes/${timestampQuizId}`, {
title: 'Updated Timestamp Quiz',
questions: [
{
text: 'Updated Q?',
options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }],
},
],
});
const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`);
const afterUpdatedAt = (afterData as Record<string, unknown>).updatedAt;
if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed');
});
await test('DELETE cleanup timestamp quiz', async () => {
if (!timestampQuizId) throw new Error('No timestamp quiz to delete');
await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204);
});
console.log('\n=== Results ==='); console.log('\n=== Results ===');
const passed = results.filter((r) => r.passed).length; const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length; const failed = results.filter((r) => !r.passed).length;

View file

@ -31,6 +31,26 @@ export interface Quiz {
questions: Question[]; questions: Question[];
} }
export type QuizSource = 'manual' | 'ai_generated';
export interface SavedQuiz extends Quiz {
id: string;
source: QuizSource;
aiTopic?: string;
createdAt: string;
updatedAt: string;
}
export interface QuizListItem {
id: string;
title: string;
source: QuizSource;
aiTopic?: string;
questionCount: number;
createdAt: string;
updatedAt: string;
}
export interface Player { export interface Player {
id: string; id: string;
name: string; name: string;