diff --git a/App.tsx b/App.tsx
index 4f8c606..263c737 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
+import { useLocation } from 'react-router-dom';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
import { useUserConfig } from './hooks/useUserConfig';
@@ -16,6 +17,7 @@ import { SaveOptionsModal } from './components/SaveOptionsModal';
import { DisconnectedScreen } from './components/DisconnectedScreen';
import { WaitingToRejoin } from './components/WaitingToRejoin';
import { HostReconnected } from './components/HostReconnected';
+import { SharedQuizView } from './components/SharedQuizView';
import type { Quiz, GameConfig } from './types';
const seededRandom = (seed: number) => {
@@ -45,6 +47,7 @@ const FloatingShapes = React.memo(() => {
function App() {
const auth = useAuth();
+ const location = useLocation();
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
const { defaultConfig } = useUserConfig();
const [showSaveOptions, setShowSaveOptions] = useState(false);
@@ -131,12 +134,25 @@ function App() {
const currentQ = quiz?.questions[currentQuestionIndex];
- // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
const correctOpt = currentQ?.options.find(o => {
if (role === 'HOST') return o.isCorrect;
return o.shape === currentCorrectShape;
});
+ const sharedMatch = location.pathname.match(/^\/shared\/([a-zA-Z0-9_-]+)$/);
+ const isSharedQuizRoute = !!sharedMatch && gameState === 'LANDING';
+
+ if (isSharedQuizRoute) {
+ return (
+
+
+
+ loadSavedQuiz(sharedQuiz)} />
+
+
+ );
+ }
+
return (
diff --git a/components/Landing.tsx b/components/Landing.tsx
index 54b31e6..c37805c 100644
--- a/components/Landing.tsx
+++ b/components/Landing.tsx
@@ -133,12 +133,15 @@ export const Landing: React.FC
= ({ onGenerate, onCreateManual, on
loading: libraryLoading,
loadingQuizId,
deletingQuizId,
+ sharingQuizId,
exporting,
importing,
error: libraryError,
fetchQuizzes,
loadQuiz,
deleteQuiz,
+ shareQuiz,
+ unshareQuiz,
exportQuizzes,
importQuizzes,
parseImportFile,
@@ -621,10 +624,13 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on
loading={libraryLoading}
loadingQuizId={loadingQuizId}
deletingQuizId={deletingQuizId}
+ sharingQuizId={sharingQuizId}
exporting={exporting}
error={libraryError}
onLoadQuiz={handleLoadQuiz}
onDeleteQuiz={deleteQuiz}
+ onShareQuiz={shareQuiz}
+ onUnshareQuiz={unshareQuiz}
onExportQuizzes={exportQuizzes}
onImportClick={() => setImportOpen(true)}
onRetry={retryLibrary}
diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx
index 7f1bf00..afbf116 100644
--- a/components/QuizLibrary.tsx
+++ b/components/QuizLibrary.tsx
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
-import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks } from 'lucide-react';
+import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks, Share2, Link2Off, Copy } from 'lucide-react';
import { QuizListItem } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
+import toast from 'react-hot-toast';
interface QuizLibraryProps {
isOpen: boolean;
@@ -11,10 +12,13 @@ interface QuizLibraryProps {
loading: boolean;
loadingQuizId: string | null;
deletingQuizId: string | null;
+ sharingQuizId: string | null;
exporting: boolean;
error: string | null;
onLoadQuiz: (id: string) => void;
onDeleteQuiz: (id: string) => void;
+ onShareQuiz: (id: string) => Promise;
+ onUnshareQuiz: (id: string) => Promise;
onExportQuizzes: (ids: string[]) => Promise;
onImportClick: () => void;
onRetry: () => void;
@@ -27,10 +31,13 @@ export const QuizLibrary: React.FC = ({
loading,
loadingQuizId,
deletingQuizId,
+ sharingQuizId,
exporting,
error,
onLoadQuiz,
onDeleteQuiz,
+ onShareQuiz,
+ onUnshareQuiz,
onExportQuizzes,
onImportClick,
onRetry,
@@ -38,7 +45,7 @@ export const QuizLibrary: React.FC = ({
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
- const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || exporting;
+ const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || !!sharingQuizId || exporting;
useBodyScrollLock(isOpen);
@@ -79,6 +86,24 @@ export const QuizLibrary: React.FC = ({
setConfirmDeleteId(id);
};
+ const handleShareClick = async (e: React.MouseEvent, quiz: QuizListItem) => {
+ e.stopPropagation();
+ if (quiz.isShared && quiz.shareToken) {
+ const shareUrl = `${window.location.origin}/shared/${quiz.shareToken}`;
+ await navigator.clipboard.writeText(shareUrl);
+ toast.success('Link copied to clipboard!');
+ } else {
+ const token = await onShareQuiz(quiz.id);
+ const shareUrl = `${window.location.origin}/shared/${token}`;
+ await navigator.clipboard.writeText(shareUrl);
+ }
+ };
+
+ const handleUnshareClick = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ await onUnshareQuiz(id);
+ };
+
const confirmDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (confirmDeleteId) {
@@ -264,6 +289,11 @@ export const QuizLibrary: React.FC = ({
Manual
)}
+ {quiz.isShared && (
+
+ Shared
+
+ )}
{formatDate(quiz.createdAt)}
@@ -280,7 +310,7 @@ export const QuizLibrary: React.FC = ({
{!selectMode && (
-
+
{loadingQuizId === quiz.id ? (
@@ -289,6 +319,10 @@ export const QuizLibrary: React.FC
= ({
+ ) : sharingQuizId === quiz.id ? (
+
+
+
) : confirmDeleteId === quiz.id ? (
e.stopPropagation()}>
) : (
<>
+ {quiz.isShared ? (
+
+
+
+
+ ) : (
+
+ )}
diff --git a/components/SharedQuizView.tsx b/components/SharedQuizView.tsx
new file mode 100644
index 0000000..6495aff
--- /dev/null
+++ b/components/SharedQuizView.tsx
@@ -0,0 +1,241 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useAuth } from 'react-oidc-context';
+import { motion } from 'framer-motion';
+import { BrainCircuit, Play, Loader2, BookmarkPlus, ChevronDown, ChevronUp, AlertCircle, LogIn } from 'lucide-react';
+import toast from 'react-hot-toast';
+import type { Quiz, Question, GameConfig } from '../types';
+import { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch';
+
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
+
+interface SharedQuizData {
+ title: string;
+ source: 'manual' | 'ai_generated';
+ aiTopic?: string;
+ gameConfig: GameConfig | null;
+ questions: Question[];
+ questionCount: number;
+}
+
+interface SharedQuizViewProps {
+ onHostQuiz: (quiz: Quiz) => void;
+}
+
+export const SharedQuizView: React.FC
= ({ onHostQuiz }) => {
+ const { token } = useParams<{ token: string }>();
+ const navigate = useNavigate();
+ const auth = useAuth();
+ const { authFetch } = useAuthenticatedFetch();
+
+ const [quizData, setQuizData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [showQuestions, setShowQuestions] = useState(false);
+
+ useEffect(() => {
+ const fetchSharedQuiz = async () => {
+ if (!token) {
+ setError('Invalid share link');
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const response = await fetch(`${BACKEND_URL}/api/shared/${token}`);
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ setError('This quiz is no longer available');
+ } else {
+ setError('Failed to load quiz');
+ }
+ setLoading(false);
+ return;
+ }
+
+ const data = await response.json();
+ setQuizData(data);
+ } catch {
+ setError('Failed to load quiz');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchSharedQuiz();
+ }, [token]);
+
+ const handleHost = () => {
+ if (!quizData) return;
+
+ const quiz: Quiz = {
+ title: quizData.title,
+ questions: quizData.questions,
+ config: quizData.gameConfig || undefined,
+ };
+
+ onHostQuiz(quiz);
+ };
+
+ const handleSaveToLibrary = async () => {
+ if (!token || !auth.isAuthenticated) return;
+
+ setSaving(true);
+ try {
+ const response = await authFetch(`/api/shared/${token}/copy`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save quiz');
+ }
+
+ const data = await response.json();
+ toast.success(`"${data.title}" saved to your library!`);
+ } catch {
+ toast.error('Failed to save quiz to library');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ Loading shared quiz...
+
+
+ );
+ }
+
+ if (error || !quizData) {
+ return (
+
+
+
+ Quiz Not Found
+ {error || 'This quiz is no longer available'}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Shared Quiz
+
{quizData.title}
+
+ {quizData.questionCount} question{quizData.questionCount !== 1 ? 's' : ''}
+ {quizData.aiTopic && • {quizData.aiTopic}}
+
+
+
+
+
+
+ {auth.isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {showQuestions && (
+
+ {quizData.questions.map((q, i) => (
+
+
Question {i + 1}
+
{q.text}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts
index 4f361b1..276e226 100644
--- a/hooks/useQuizLibrary.ts
+++ b/hooks/useQuizLibrary.ts
@@ -8,6 +8,7 @@ interface UseQuizLibraryReturn {
loading: boolean;
loadingQuizId: string | null;
deletingQuizId: string | null;
+ sharingQuizId: string | null;
saving: boolean;
exporting: boolean;
importing: boolean;
@@ -18,6 +19,8 @@ interface UseQuizLibraryReturn {
updateQuiz: (id: string, quiz: Quiz) => Promise;
updateQuizConfig: (id: string, config: GameConfig) => Promise;
deleteQuiz: (id: string) => Promise;
+ shareQuiz: (id: string) => Promise;
+ unshareQuiz: (id: string) => Promise;
exportQuizzes: (quizIds: string[]) => Promise;
importQuizzes: (quizzes: ExportedQuiz[]) => Promise;
parseImportFile: (file: File) => Promise;
@@ -31,6 +34,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
const [loading, setLoading] = useState(false);
const [loadingQuizId, setLoadingQuizId] = useState(null);
const [deletingQuizId, setDeletingQuizId] = useState(null);
+ const [sharingQuizId, setSharingQuizId] = useState(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
@@ -267,6 +271,70 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
setDeletingQuizId(null);
}
}, [authFetch]);
+
+ const shareQuiz = useCallback(async (id: string): Promise => {
+ setSharingQuizId(id);
+ setError(null);
+
+ try {
+ const response = await authFetch(`/api/quizzes/${id}/share`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const errorText = response.status === 404
+ ? 'Quiz not found.'
+ : 'Failed to share quiz.';
+ throw new Error(errorText);
+ }
+
+ const data = await response.json();
+ setQuizzes(prev => prev.map(q =>
+ q.id === id ? { ...q, shareToken: data.shareToken, isShared: true } : q
+ ));
+ toast.success('Quiz shared! Link copied to clipboard.');
+ return data.shareToken;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to share quiz';
+ if (!message.includes('redirecting')) {
+ toast.error(message);
+ }
+ throw err;
+ } finally {
+ setSharingQuizId(null);
+ }
+ }, [authFetch]);
+
+ const unshareQuiz = useCallback(async (id: string): Promise => {
+ setSharingQuizId(id);
+ setError(null);
+
+ try {
+ const response = await authFetch(`/api/quizzes/${id}/share`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const errorText = response.status === 404
+ ? 'Quiz not found.'
+ : 'Failed to stop sharing quiz.';
+ throw new Error(errorText);
+ }
+
+ setQuizzes(prev => prev.map(q =>
+ q.id === id ? { ...q, shareToken: undefined, isShared: false } : q
+ ));
+ toast.success('Quiz is no longer shared');
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to stop sharing quiz';
+ if (!message.includes('redirecting')) {
+ toast.error(message);
+ }
+ throw err;
+ } finally {
+ setSharingQuizId(null);
+ }
+ }, [authFetch]);
const exportQuizzes = useCallback(async (quizIds: string[]): Promise => {
if (quizIds.length === 0) {
@@ -379,6 +447,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
loading,
loadingQuizId,
deletingQuizId,
+ sharingQuizId,
saving,
exporting,
importing,
@@ -389,6 +458,8 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
updateQuiz,
updateQuizConfig,
deleteQuiz,
+ shareQuiz,
+ unshareQuiz,
exportQuizzes,
importQuizzes,
parseImportFile,
diff --git a/package-lock.json b/package-lock.json
index 76af2f7..874a4e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3422,7 +3422,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -3546,7 +3545,6 @@
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
- "peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts
index 09d91be..0387c18 100644
--- a/server/src/db/connection.ts
+++ b/server/src/db/connection.ts
@@ -108,6 +108,25 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
console.log("Migration: Added gemini_model to users");
}
+
+ const quizTableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
+ const hasShareToken = quizTableInfo.some(col => col.name === "share_token");
+ if (!hasShareToken) {
+ db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT UNIQUE");
+ console.log("Migration: Added share_token to quizzes");
+ }
+
+ const hasIsShared = quizTableInfo.some(col => col.name === "is_shared");
+ if (!hasIsShared) {
+ db.exec("ALTER TABLE quizzes ADD COLUMN is_shared INTEGER DEFAULT 0");
+ console.log("Migration: Added is_shared to quizzes");
+ }
+
+ const shareTokenIndex = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_quizzes_share_token'").get();
+ if (!shareTokenIndex) {
+ db.exec("CREATE INDEX idx_quizzes_share_token ON quizzes(share_token)");
+ console.log("Migration: Created index on quizzes.share_token");
+ }
};
runMigrations();
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
index f1e2cd6..6e8c11f 100644
--- a/server/src/db/schema.sql
+++ b/server/src/db/schema.sql
@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS quizzes (
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
game_config TEXT,
+ share_token TEXT UNIQUE,
+ is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
@@ -58,6 +60,7 @@ CREATE TABLE IF NOT EXISTS game_sessions (
);
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
+CREATE INDEX IF NOT EXISTS idx_quizzes_share_token ON quizzes(share_token);
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);
diff --git a/server/src/index.ts b/server/src/index.ts
index e77fd69..17494e9 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -8,6 +8,7 @@ import usersRouter from './routes/users.js';
import uploadRouter from './routes/upload.js';
import gamesRouter from './routes/games.js';
import generateRouter from './routes/generate.js';
+import sharedRouter from './routes/shared.js';
const app = express();
const PORT = process.env.PORT || 3001;
@@ -93,6 +94,7 @@ app.use('/api/users', usersRouter);
app.use('/api/upload', uploadRouter);
app.use('/api/games', gamesRouter);
app.use('/api/generate', generateRouter);
+app.use('/api/shared', sharedRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err);
diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts
index 33f1f3f..a9eb34e 100644
--- a/server/src/routes/quizzes.ts
+++ b/server/src/routes/quizzes.ts
@@ -1,5 +1,6 @@
import { Router, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
+import { randomBytes } from 'crypto';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
@@ -47,6 +48,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
q.title,
q.source,
q.ai_topic as aiTopic,
+ q.share_token as shareToken,
+ q.is_shared as isShared,
q.created_at as createdAt,
q.updated_at as updatedAt,
(SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount
@@ -55,7 +58,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
ORDER BY q.updated_at DESC
`).all(req.user!.sub);
- res.json(quizzes);
+ res.json(quizzes.map((q: any) => ({ ...q, isShared: Boolean(q.isShared) })));
});
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
@@ -315,6 +318,48 @@ router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
res.json({ success: true });
});
+router.post('/:id/share', (req: AuthenticatedRequest, res: Response) => {
+ const quizId = req.params.id;
+
+ const existing = db.prepare(`
+ SELECT id, share_token as shareToken FROM quizzes WHERE id = ? AND user_id = ?
+ `).get(quizId, req.user!.sub) as { id: string; shareToken: string | null } | undefined;
+
+ if (!existing) {
+ res.status(404).json({ error: 'Quiz not found' });
+ return;
+ }
+
+ if (existing.shareToken) {
+ res.json({ shareToken: existing.shareToken });
+ return;
+ }
+
+ const shareToken = randomBytes(16).toString('base64url');
+
+ db.prepare(`
+ UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
+ `).run(shareToken, quizId);
+
+ res.json({ shareToken });
+});
+
+router.delete('/:id/share', (req: AuthenticatedRequest, res: Response) => {
+ const quizId = req.params.id;
+
+ const result = db.prepare(`
+ UPDATE quizzes SET share_token = NULL, is_shared = 0, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND user_id = ?
+ `).run(quizId, req.user!.sub);
+
+ if (result.changes === 0) {
+ res.status(404).json({ error: 'Quiz not found' });
+ return;
+ }
+
+ res.json({ success: true });
+});
+
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
const result = db.prepare(`
DELETE FROM quizzes WHERE id = ? AND user_id = ?
diff --git a/server/src/routes/shared.ts b/server/src/routes/shared.ts
new file mode 100644
index 0000000..ba2a410
--- /dev/null
+++ b/server/src/routes/shared.ts
@@ -0,0 +1,185 @@
+import { Router, Request, Response } from 'express';
+import { v4 as uuidv4 } from 'uuid';
+import { db } from '../db/connection.js';
+import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
+
+const router = Router();
+
+interface QuizRow {
+ id: string;
+ title: string;
+ source: string;
+ aiTopic: string | null;
+ gameConfig: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface QuestionRow {
+ id: string;
+ text: string;
+ timeLimit: number;
+ orderIndex: number;
+}
+
+interface OptionRow {
+ id: string;
+ text: string;
+ isCorrect: number;
+ shape: string;
+ color: string;
+ reason: string | null;
+ orderIndex: number;
+}
+
+router.get('/:token', (req: Request, res: Response) => {
+ const { token } = req.params;
+
+ const quiz = db.prepare(`
+ SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
+ FROM quizzes
+ WHERE share_token = ? AND is_shared = 1
+ `).get(token) as QuizRow | undefined;
+
+ if (!quiz) {
+ res.status(404).json({ error: 'Shared quiz not found' });
+ return;
+ }
+
+ const questions = db.prepare(`
+ SELECT id, text, time_limit as timeLimit, order_index as orderIndex
+ FROM questions
+ WHERE quiz_id = ?
+ ORDER BY order_index
+ `).all(quiz.id) as QuestionRow[];
+
+ const questionsWithOptions = questions.map((q) => {
+ const options = db.prepare(`
+ SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex
+ FROM answer_options
+ WHERE question_id = ?
+ ORDER BY order_index
+ `).all(q.id) as OptionRow[];
+
+ return {
+ ...q,
+ options: options.map((o) => ({
+ ...o,
+ isCorrect: Boolean(o.isCorrect),
+ })),
+ };
+ });
+
+ let parsedConfig = null;
+ if (quiz.gameConfig && typeof quiz.gameConfig === 'string') {
+ try {
+ parsedConfig = JSON.parse(quiz.gameConfig);
+ } catch {
+ parsedConfig = null;
+ }
+ }
+
+ res.json({
+ title: quiz.title,
+ source: quiz.source,
+ aiTopic: quiz.aiTopic,
+ gameConfig: parsedConfig,
+ questions: questionsWithOptions,
+ questionCount: questions.length,
+ });
+});
+
+router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Response) => {
+ const { token } = req.params;
+
+ const sourceQuiz = db.prepare(`
+ SELECT id, title, game_config as gameConfig
+ FROM quizzes
+ WHERE share_token = ? AND is_shared = 1
+ `).get(token) as { id: string; title: string; gameConfig: string | null } | undefined;
+
+ if (!sourceQuiz) {
+ res.status(404).json({ error: 'Shared quiz not found' });
+ return;
+ }
+
+ const existingWithSameTitle = db.prepare(`
+ SELECT id FROM quizzes WHERE user_id = ? AND title = ?
+ `).get(req.user!.sub, sourceQuiz.title);
+
+ const newTitle = existingWithSameTitle ? `${sourceQuiz.title} (Copy)` : sourceQuiz.title;
+
+ const questions = db.prepare(`
+ SELECT id, text, time_limit as timeLimit, order_index as orderIndex
+ FROM questions
+ WHERE quiz_id = ?
+ ORDER BY order_index
+ `).all(sourceQuiz.id) as QuestionRow[];
+
+ const newQuizId = uuidv4();
+
+ const upsertUser = db.prepare(`
+ INSERT INTO users (id, username, email, display_name, last_login)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(id) DO UPDATE SET
+ last_login = CURRENT_TIMESTAMP,
+ email = COALESCE(excluded.email, users.email),
+ display_name = COALESCE(excluded.display_name, users.display_name)
+ `);
+
+ const insertQuiz = db.prepare(`
+ INSERT INTO quizzes (id, user_id, title, source, game_config)
+ VALUES (?, ?, ?, 'manual', ?)
+ `);
+
+ const insertQuestion = db.prepare(`
+ INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
+ VALUES (?, ?, ?, ?, ?)
+ `);
+
+ const insertOption = db.prepare(`
+ INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ const transaction = db.transaction(() => {
+ upsertUser.run(
+ req.user!.sub,
+ req.user!.preferred_username,
+ req.user!.email || null,
+ req.user!.name || null
+ );
+
+ insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);
+
+ questions.forEach((q, qIdx) => {
+ const newQuestionId = uuidv4();
+ insertQuestion.run(newQuestionId, newQuizId, q.text, q.timeLimit, qIdx);
+
+ const options = db.prepare(`
+ SELECT text, is_correct, shape, color, reason, order_index
+ FROM answer_options
+ WHERE question_id = ?
+ ORDER BY order_index
+ `).all(q.id) as { text: string; is_correct: number; shape: string; color: string; reason: string | null; order_index: number }[];
+
+ options.forEach((o, oIdx) => {
+ insertOption.run(
+ uuidv4(),
+ newQuestionId,
+ o.text,
+ o.is_correct,
+ o.shape,
+ o.color,
+ o.reason,
+ oIdx
+ );
+ });
+ });
+ });
+
+ transaction();
+ res.status(201).json({ id: newQuizId, title: newTitle });
+});
+
+export default router;
diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts
index 1cdb382..3f1aa5f 100644
--- a/server/tests/api.test.ts
+++ b/server/tests/api.test.ts
@@ -2159,6 +2159,376 @@ console.log('\n=== Game Session Tests ===');
}
});
+ console.log('\n=== Quiz Sharing Tests ===');
+
+ let shareTestQuizId: string | null = null;
+ let shareTestToken: string | null = null;
+
+ console.log('\nShare Quiz Tests:');
+
+ await test('POST /api/quizzes creates quiz for sharing tests', async () => {
+ const quiz = {
+ title: 'Sharing Test Quiz',
+ source: 'manual',
+ gameConfig: {
+ shuffleQuestions: true,
+ shuffleAnswers: false,
+ hostParticipates: true,
+ streakBonusEnabled: false,
+ streakThreshold: 3,
+ streakMultiplier: 1.1,
+ comebackBonusEnabled: false,
+ comebackBonusPoints: 50,
+ penaltyForWrongAnswer: false,
+ penaltyPercent: 25,
+ firstCorrectBonusEnabled: false,
+ firstCorrectBonusPoints: 50,
+ },
+ questions: [
+ {
+ text: 'What is the capital of France?',
+ timeLimit: 20,
+ options: [
+ { text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is in UK' },
+ { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct!' },
+ { text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow' },
+ { text: 'Madrid', isCorrect: false, shape: 'square', color: 'green' },
+ ],
+ },
+ {
+ text: 'What is 2 + 2?',
+ timeLimit: 15,
+ options: [
+ { text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
+ { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ const { data } = await request('POST', '/api/quizzes', quiz, 201);
+ shareTestQuizId = (data as { id: string }).id;
+ });
+
+ await test('POST /api/quizzes/:id/share generates share token', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
+ const result = data as { shareToken: string };
+
+ if (!result.shareToken) throw new Error('Missing shareToken in response');
+ if (result.shareToken.length < 10) throw new Error('shareToken too short');
+
+ shareTestToken = result.shareToken;
+ });
+
+ await test('POST /api/quizzes/:id/share returns same token if already shared', async () => {
+ if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
+
+ const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
+ const result = data as { shareToken: string };
+
+ if (result.shareToken !== shareTestToken) {
+ throw new Error('Should return same token when already shared');
+ }
+ });
+
+ await test('GET /api/quizzes returns isShared and shareToken for shared quiz', async () => {
+ if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
+
+ const { data } = await request('GET', '/api/quizzes');
+ const quizzes = data as Array<{ id: string; isShared: boolean; shareToken: string | null }>;
+ const quiz = quizzes.find((q) => q.id === shareTestQuizId);
+
+ if (!quiz) throw new Error('Shared quiz not in list');
+ if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
+ if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
+ });
+
+ await test('GET /api/quizzes/:id returns isShared and shareToken', async () => {
+ if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
+
+ const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
+ const quiz = data as { isShared: boolean; shareToken: string | null };
+
+ if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
+ if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
+ });
+
+ await test('POST /api/quizzes/:id/share with non-existent ID returns 404', async () => {
+ await request('POST', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
+ });
+
+ await test('POST /api/quizzes/:id/share without auth returns 401', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
+ });
+
+ console.log('\nShared Quiz Public Access Tests:');
+
+ await test('GET /api/shared/:token returns quiz without auth', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
+ if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
+
+ const data = await res.json();
+ if (data.title !== 'Sharing Test Quiz') throw new Error('Wrong title');
+ if (data.source !== 'manual') throw new Error('Wrong source');
+ if (!Array.isArray(data.questions)) throw new Error('Missing questions');
+ if (data.questions.length !== 2) throw new Error(`Expected 2 questions, got ${data.questions.length}`);
+ if (data.questionCount !== 2) throw new Error(`Expected questionCount 2, got ${data.questionCount}`);
+ });
+
+ await test('GET /api/shared/:token returns gameConfig', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
+ const data = await res.json();
+
+ if (!data.gameConfig) throw new Error('Missing gameConfig');
+ if (data.gameConfig.shuffleQuestions !== true) throw new Error('gameConfig not preserved');
+ if (data.gameConfig.hostParticipates !== true) throw new Error('hostParticipates not preserved');
+ });
+
+ await test('GET /api/shared/:token returns questions with all fields', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
+ const data = await res.json();
+
+ const q1 = data.questions[0];
+ if (!q1.id) throw new Error('Missing question id');
+ if (!q1.text) throw new Error('Missing question text');
+ if (q1.timeLimit !== 20) throw new Error('Wrong timeLimit');
+ if (!Array.isArray(q1.options)) throw new Error('Missing options');
+ if (q1.options.length !== 4) throw new Error(`Expected 4 options, got ${q1.options.length}`);
+
+ const correctOption = q1.options.find((o: { isCorrect: boolean }) => o.isCorrect);
+ if (!correctOption) throw new Error('No correct option found');
+ if (correctOption.text !== 'Paris') throw new Error('Wrong correct answer');
+ if (correctOption.reason !== 'Correct!') throw new Error('Reason not preserved');
+ });
+
+ await test('GET /api/shared/:token with invalid token returns 404', async () => {
+ const res = await fetch(`${API_URL}/api/shared/invalid-token-12345`);
+ if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
+ });
+
+ await test('GET /api/shared/:token with empty token returns 404', async () => {
+ const res = await fetch(`${API_URL}/api/shared/`);
+ if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
+ });
+
+ console.log('\nCopy Shared Quiz Tests:');
+
+ await test('POST /api/shared/:token/copy without auth returns 401', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}/copy`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
+ });
+
+ await test('POST /api/shared/:token/copy with auth creates copy', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
+ const result = data as { id: string; title: string };
+
+ if (!result.id) throw new Error('Missing id');
+ if (!result.title) throw new Error('Missing title');
+ // Title may have "(Copy)" appended if user already has quiz with same title
+ if (!result.title.startsWith('Sharing Test Quiz')) throw new Error('Wrong title');
+
+ // Verify the copied quiz exists and has correct content
+ const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
+ const copiedQuiz = copiedData as { title: string; source: string; questions: unknown[]; gameConfig: unknown };
+
+ if (copiedQuiz.source !== 'manual') throw new Error('Source should be manual for copied quiz');
+ if (!Array.isArray(copiedQuiz.questions)) throw new Error('Missing questions in copy');
+ if (copiedQuiz.questions.length !== 2) throw new Error('Questions not copied correctly');
+ if (!copiedQuiz.gameConfig) throw new Error('gameConfig not copied');
+
+ // Cleanup
+ await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
+ });
+
+ await test('POST /api/shared/:token/copy copies all question data', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
+ const result = data as { id: string };
+
+ const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
+ const copiedQuiz = copiedData as {
+ questions: Array<{
+ text: string;
+ timeLimit: number;
+ options: Array<{ text: string; isCorrect: boolean; reason?: string }>;
+ }>;
+ };
+
+ const q1 = copiedQuiz.questions[0];
+ if (q1.text !== 'What is the capital of France?') throw new Error('Question text not copied');
+ if (q1.timeLimit !== 20) throw new Error('timeLimit not copied');
+
+ const parisOption = q1.options.find((o) => o.text === 'Paris');
+ if (!parisOption) throw new Error('Paris option not copied');
+ if (!parisOption.isCorrect) throw new Error('isCorrect not copied');
+ if (parisOption.reason !== 'Correct!') throw new Error('reason not copied');
+
+ await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
+ });
+
+ await test('POST /api/shared/:token/copy with invalid token returns 404', async () => {
+ await request('POST', '/api/shared/invalid-token-xyz/copy', undefined, 404);
+ });
+
+ console.log('\nUnshare Quiz Tests:');
+
+ await test('DELETE /api/quizzes/:id/share removes sharing', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const { data } = await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
+ const result = data as { success: boolean };
+
+ if (!result.success) throw new Error('Expected success: true');
+ });
+
+ await test('GET /api/quizzes/:id shows isShared=false after unsharing', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
+ const quiz = data as { isShared: boolean; shareToken: string | null };
+
+ if (quiz.isShared !== false) throw new Error('Expected isShared to be false');
+ if (quiz.shareToken !== null) throw new Error('Expected shareToken to be null');
+ });
+
+ await test('GET /api/shared/:token returns 404 after unsharing', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
+ if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
+ });
+
+ await test('POST /api/shared/:token/copy returns 404 after unsharing', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 404);
+ });
+
+ await test('DELETE /api/quizzes/:id/share with non-existent ID returns 404', async () => {
+ await request('DELETE', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
+ });
+
+ await test('DELETE /api/quizzes/:id/share without auth returns 401', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
+ });
+
+ console.log('\nRe-share Quiz Tests:');
+
+ await test('POST /api/quizzes/:id/share generates new token after unshare', async () => {
+ if (!shareTestQuizId) throw new Error('No quiz created');
+
+ const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
+ const result = data as { shareToken: string };
+
+ if (!result.shareToken) throw new Error('Missing shareToken');
+ if (result.shareToken === shareTestToken) throw new Error('Should generate new token');
+
+ // Update our reference to the new token
+ shareTestToken = result.shareToken;
+ });
+
+ await test('GET /api/shared/:token works with new token', async () => {
+ if (!shareTestToken) throw new Error('No share token');
+
+ const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
+ if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
+ });
+
+ console.log('\nShare Edge Cases:');
+
+ await test('Unshare and re-share does not affect quiz content', async () => {
+ if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
+
+ // Get original content
+ const { data: original } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
+ const originalQuiz = original as { title: string; questions: unknown[] };
+
+ // Unshare
+ await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
+
+ // Re-share
+ const { data: shareData } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
+ shareTestToken = (shareData as { shareToken: string }).shareToken;
+
+ // Verify content unchanged
+ const { data: afterData } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
+ const afterQuiz = afterData as { title: string; questions: unknown[] };
+
+ if (originalQuiz.title !== afterQuiz.title) throw new Error('Title changed');
+ if (JSON.stringify(originalQuiz.questions) !== JSON.stringify(afterQuiz.questions)) {
+ throw new Error('Questions changed');
+ }
+ });
+
+ await test('DELETE quiz also removes from shared access', async () => {
+ // Create a new quiz to share and delete
+ const quiz = {
+ title: 'Delete Shared Quiz Test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Test?',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ const { data: createData } = await request('POST', '/api/quizzes', quiz, 201);
+ const quizId = (createData as { id: string }).id;
+
+ // Share it
+ const { data: shareData } = await request('POST', `/api/quizzes/${quizId}/share`);
+ const token = (shareData as { shareToken: string }).shareToken;
+
+ // Verify it's accessible
+ let res = await fetch(`${API_URL}/api/shared/${token}`);
+ if (res.status !== 200) throw new Error('Shared quiz should be accessible before delete');
+
+ // Delete the quiz
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+
+ // Verify shared link no longer works
+ res = await fetch(`${API_URL}/api/shared/${token}`);
+ if (res.status !== 404) throw new Error(`Expected 404 after delete, got ${res.status}`);
+ });
+
+ await test('DELETE cleanup sharing test quiz', async () => {
+ if (shareTestQuizId) {
+ await request('DELETE', `/api/quizzes/${shareTestQuizId}`, undefined, 204);
+ }
+ });
+
console.log('\n=== Results ===');
const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;
diff --git a/tests/components/QuizLibrary.test.tsx b/tests/components/QuizLibrary.test.tsx
index 850f411..d6c12c7 100644
--- a/tests/components/QuizLibrary.test.tsx
+++ b/tests/components/QuizLibrary.test.tsx
@@ -10,6 +10,7 @@ const createMockQuiz = (overrides?: Partial): QuizListItem => ({
title: 'Test Quiz',
source: 'manual',
questionCount: 5,
+ isShared: false,
createdAt: '2024-01-15T10:00:00.000Z',
updatedAt: '2024-01-15T10:00:00.000Z',
...overrides,
@@ -23,6 +24,7 @@ describe('QuizLibrary', () => {
loading: false,
loadingQuizId: null,
deletingQuizId: null,
+ sharingQuizId: null,
exporting: false,
error: null,
onLoadQuiz: vi.fn(),
@@ -30,6 +32,8 @@ describe('QuizLibrary', () => {
onExportQuizzes: vi.fn(),
onImportClick: vi.fn(),
onRetry: vi.fn(),
+ onShareQuiz: vi.fn().mockResolvedValue('mock-token'),
+ onUnshareQuiz: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
@@ -519,4 +523,119 @@ describe('QuizLibrary', () => {
expect(defaultProps.onRetry).toHaveBeenCalled();
});
});
+
+ describe('share functionality', () => {
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
+
+ beforeEach(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: mockWriteText,
+ },
+ writable: true,
+ configurable: true,
+ });
+ mockWriteText.mockClear();
+ });
+
+ it('shows share button for non-shared quiz', () => {
+ render();
+ expect(screen.getByTitle('Share quiz')).toBeInTheDocument();
+ });
+
+ it('shows copy link button and stop sharing button for shared quiz', () => {
+ const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
+ render();
+
+ expect(screen.getByTitle('Copy share link')).toBeInTheDocument();
+ expect(screen.getByTitle('Stop sharing')).toBeInTheDocument();
+ });
+
+ it('shows Shared badge for shared quiz', () => {
+ const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
+ render();
+
+ expect(screen.getByText('Shared')).toBeInTheDocument();
+ });
+
+ it('calls onShareQuiz when share button clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTitle('Share quiz'));
+
+ expect(defaultProps.onShareQuiz).toHaveBeenCalledWith('quiz-1');
+ });
+
+ it('does not call onShareQuiz for already shared quiz', async () => {
+ const user = userEvent.setup();
+ const quizzes = [createMockQuiz({ isShared: true, shareToken: 'existing-token' })];
+ render();
+
+ await user.click(screen.getByTitle('Copy share link'));
+
+ expect(defaultProps.onShareQuiz).not.toHaveBeenCalled();
+ });
+
+ it('calls onUnshareQuiz when stop sharing button clicked', async () => {
+ const user = userEvent.setup();
+ const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
+ render();
+
+ await user.click(screen.getByTitle('Stop sharing'));
+
+ expect(defaultProps.onUnshareQuiz).toHaveBeenCalledWith('quiz-1');
+ });
+
+ it('hides share buttons in selection mode', async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByTitle('Share quiz')).toBeInTheDocument();
+
+ await user.click(screen.getByTitle('Select for export'));
+
+ expect(screen.queryByTitle('Share quiz')).not.toBeInTheDocument();
+ });
+
+ it('shows loading state when sharing quiz', () => {
+ const quizzes = [createMockQuiz({ id: 'quiz-1' })];
+ render();
+
+ const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!;
+ expect(quizCard).toHaveClass('opacity-70');
+ });
+
+ it('share click does not trigger quiz load', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTitle('Share quiz'));
+
+ expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled();
+ });
+
+ it('unshare click does not trigger quiz load', async () => {
+ const user = userEvent.setup();
+ const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
+ render();
+
+ await user.click(screen.getByTitle('Stop sharing'));
+
+ expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled();
+ });
+
+ it('displays multiple shared and non-shared quizzes correctly', () => {
+ const quizzes = [
+ createMockQuiz({ id: '1', title: 'Shared Quiz', isShared: true, shareToken: 'token1' }),
+ createMockQuiz({ id: '2', title: 'Private Quiz', isShared: false }),
+ ];
+ render();
+
+ expect(screen.getByText('Shared')).toBeInTheDocument();
+ expect(screen.getAllByTitle('Share quiz')).toHaveLength(1);
+ expect(screen.getAllByTitle('Copy share link')).toHaveLength(1);
+ expect(screen.getAllByTitle('Stop sharing')).toHaveLength(1);
+ });
+ });
});
diff --git a/tests/components/SharedQuizView.test.tsx b/tests/components/SharedQuizView.test.tsx
new file mode 100644
index 0000000..ee8af61
--- /dev/null
+++ b/tests/components/SharedQuizView.test.tsx
@@ -0,0 +1,531 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { SharedQuizView } from '../../components/SharedQuizView';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+const mockAuthFetch = vi.fn();
+const mockSigninRedirect = vi.fn();
+vi.mock('../../hooks/useAuthenticatedFetch', () => ({
+ useAuthenticatedFetch: () => ({
+ authFetch: mockAuthFetch,
+ isAuthenticated: true,
+ }),
+}));
+
+let mockIsAuthenticated = false;
+vi.mock('react-oidc-context', () => ({
+ useAuth: () => ({
+ isAuthenticated: mockIsAuthenticated,
+ signinRedirect: mockSigninRedirect,
+ }),
+}));
+
+vi.mock('react-hot-toast', () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+const mockSharedQuiz = {
+ title: 'Shared Test Quiz',
+ source: 'manual' as const,
+ aiTopic: null,
+ gameConfig: null,
+ 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' },
+ ],
+ },
+ {
+ id: 'q2',
+ text: 'What is the capital of France?',
+ timeLimit: 15,
+ options: [
+ { text: 'London', isCorrect: false, shape: 'triangle', color: 'red' },
+ { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ questionCount: 2,
+};
+
+const renderWithRouter = (token: string = 'valid-token') => {
+ const onHostQuiz = vi.fn();
+
+ render(
+
+
+ } />
+
+
+ );
+
+ return { onHostQuiz };
+};
+
+describe('SharedQuizView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockIsAuthenticated = false;
+ global.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('loading state', () => {
+ it('shows loading state while fetching quiz', async () => {
+ (global.fetch as ReturnType).mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ renderWithRouter();
+
+ expect(screen.getByText('Loading shared quiz...')).toBeInTheDocument();
+ });
+ });
+
+ describe('error states', () => {
+ it('shows error when quiz not found (404)', async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
+ expect(screen.getByText('This quiz is no longer available')).toBeInTheDocument();
+ });
+ });
+
+ it('shows generic error for other failures', async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
+ expect(screen.getByText('Failed to load quiz')).toBeInTheDocument();
+ });
+ });
+
+ it('shows error on network failure', async () => {
+ (global.fetch as ReturnType).mockRejectedValueOnce(
+ new Error('Network error')
+ );
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
+ });
+ });
+
+ it('navigates home when Go Home clicked on error', async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Go Home')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Go Home'));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/');
+ });
+ });
+
+ describe('successful quiz load', () => {
+ beforeEach(() => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockSharedQuiz),
+ });
+ });
+
+ it('displays quiz title', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Shared Test Quiz')).toBeInTheDocument();
+ });
+ });
+
+ it('displays question count', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 questions/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows singular for 1 question', async () => {
+ (global.fetch as ReturnType).mockReset();
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ ...mockSharedQuiz, questionCount: 1, questions: [mockSharedQuiz.questions[0]] }),
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 question/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays AI topic when present', async () => {
+ (global.fetch as ReturnType).mockReset();
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ ...mockSharedQuiz, source: 'ai_generated', aiTopic: 'Science' }),
+ });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Science/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows Host Game button', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Host Game')).toBeInTheDocument();
+ });
+ });
+
+ it('calls onHostQuiz with quiz data when Host Game clicked', async () => {
+ const user = userEvent.setup();
+ const { onHostQuiz } = renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Host Game')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Host Game'));
+
+ expect(onHostQuiz).toHaveBeenCalledWith({
+ title: 'Shared Test Quiz',
+ questions: mockSharedQuiz.questions,
+ config: undefined,
+ });
+ });
+
+ it('includes gameConfig in hosted quiz when present', async () => {
+ const gameConfig = { shuffleQuestions: true, shuffleAnswers: false };
+ (global.fetch as ReturnType).mockReset();
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ ...mockSharedQuiz, gameConfig }),
+ });
+
+ const user = userEvent.setup();
+ const { onHostQuiz } = renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Host Game')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Host Game'));
+
+ expect(onHostQuiz).toHaveBeenCalledWith({
+ title: 'Shared Test Quiz',
+ questions: mockSharedQuiz.questions,
+ config: gameConfig,
+ });
+ });
+
+ it('shows Back to Home link', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('← Back to Home')).toBeInTheDocument();
+ });
+ });
+
+ it('navigates home when Back to Home clicked', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('← Back to Home')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('← Back to Home'));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/');
+ });
+ });
+
+ describe('question preview', () => {
+ beforeEach(() => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockSharedQuiz),
+ });
+ });
+
+ it('shows Preview Questions button', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Preview Questions')).toBeInTheDocument();
+ });
+ });
+
+ it('expands questions when Preview Questions clicked', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Preview Questions')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Preview Questions'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Hide Questions')).toBeInTheDocument();
+ expect(screen.getByText('Question 1')).toBeInTheDocument();
+ expect(screen.getByText('What is 2 + 2?')).toBeInTheDocument();
+ expect(screen.getByText('Question 2')).toBeInTheDocument();
+ expect(screen.getByText('What is the capital of France?')).toBeInTheDocument();
+ });
+ });
+
+ it('collapses questions when Hide Questions clicked', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Preview Questions')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Preview Questions'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Hide Questions')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Hide Questions'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Preview Questions')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('save to library - unauthenticated user', () => {
+ beforeEach(() => {
+ mockIsAuthenticated = false;
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockSharedQuiz),
+ });
+ });
+
+ it('shows Sign in to Save button for unauthenticated users', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
+ });
+ });
+
+ it('triggers sign in when Sign in to Save clicked', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Sign in to Save'));
+
+ expect(mockSigninRedirect).toHaveBeenCalled();
+ });
+ });
+
+ describe('save to library - authenticated user', () => {
+ beforeEach(() => {
+ mockIsAuthenticated = true;
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockSharedQuiz),
+ });
+ });
+
+ it('shows Save to My Library button for authenticated users', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+ });
+
+ it('calls copy API when Save to My Library clicked', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'new-id', title: 'Shared Test Quiz' }),
+ });
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Save to My Library'));
+
+ await waitFor(() => {
+ expect(mockAuthFetch).toHaveBeenCalledWith('/api/shared/valid-token/copy', {
+ method: 'POST',
+ });
+ });
+ });
+
+ it('shows saving state while copying', async () => {
+ let resolvePromise: (value: unknown) => void;
+ mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
+ resolvePromise = resolve;
+ }));
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Save to My Library'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Saving...')).toBeInTheDocument();
+ });
+
+ resolvePromise!({
+ ok: true,
+ json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
+ });
+ });
+
+ it('shows success toast after saving', async () => {
+ const toast = await import('react-hot-toast');
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'new-id', title: 'My Quiz Copy' }),
+ });
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Save to My Library'));
+
+ await waitFor(() => {
+ expect(toast.default.success).toHaveBeenCalledWith(
+ '"My Quiz Copy" saved to your library!'
+ );
+ });
+ });
+
+ it('shows error toast when save fails', async () => {
+ const toast = await import('react-hot-toast');
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText('Save to My Library'));
+
+ await waitFor(() => {
+ expect(toast.default.error).toHaveBeenCalledWith(
+ 'Failed to save quiz to library'
+ );
+ });
+ });
+
+ it('disables save button while saving', async () => {
+ let resolvePromise: (value: unknown) => void;
+ mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
+ resolvePromise = resolve;
+ }));
+
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Save to My Library')).toBeInTheDocument();
+ });
+
+ const saveButton = screen.getByText('Save to My Library').closest('button')!;
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ const savingButton = screen.getByText('Saving...').closest('button')!;
+ expect(savingButton).toBeDisabled();
+ });
+
+ resolvePromise!({
+ ok: true,
+ json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
+ });
+ });
+ });
+
+ describe('fetches correct token from URL', () => {
+ it('fetches quiz with token from URL', async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockSharedQuiz),
+ });
+
+ renderWithRouter('my-special-token');
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('/api/shared/my-special-token')
+ );
+ });
+ });
+ });
+});
diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx
index 7a556a7..0679f72 100644
--- a/tests/hooks/useQuizLibrary.test.tsx
+++ b/tests/hooks/useQuizLibrary.test.tsx
@@ -1,7 +1,7 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
-import type { Quiz } from '../../types';
+import type { Quiz, QuizListItem } from '../../types';
const mockAuthFetch = vi.fn();
@@ -1167,4 +1167,319 @@ it('creates blob with correct mime type', async () => {
expect(result.current.importing).toBe(false);
});
});
+
+ describe('shareQuiz', () => {
+ it('shares a quiz and returns the share token', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ shareToken: 'abc123token' }),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ let token;
+ await act(async () => {
+ token = await result.current.shareQuiz('quiz-123');
+ });
+
+ expect(token).toBe('abc123token');
+ expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
+ method: 'POST',
+ });
+ });
+
+ it('sets and clears sharingQuizId during share operation', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockAuthFetch.mockReturnValueOnce(pendingPromise);
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ act(() => {
+ result.current.shareQuiz('quiz-123');
+ });
+
+ await waitFor(() => {
+ expect(result.current.sharingQuizId).toBe('quiz-123');
+ });
+
+ await act(async () => {
+ resolvePromise!({ ok: true, json: () => Promise.resolve({ shareToken: 'token' }) });
+ });
+
+ await waitFor(() => {
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+ });
+
+ it('updates quiz in local state with shareToken and isShared', async () => {
+ const initialQuizzes: QuizListItem[] = [
+ { id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, isShared: false, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
+ ];
+
+ mockAuthFetch
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'newtoken' }) });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ await act(async () => {
+ await result.current.fetchQuizzes();
+ });
+
+ await act(async () => {
+ await result.current.shareQuiz('quiz-123');
+ });
+
+ expect(result.current.quizzes[0].shareToken).toBe('newtoken');
+ expect(result.current.quizzes[0].isShared).toBe(true);
+ });
+
+ it('handles 404 not found when sharing non-existent quiz', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.shareQuiz('non-existent');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Quiz not found.');
+ }
+
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+
+ it('handles generic server error when sharing', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.shareQuiz('quiz-123');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Failed to share quiz.');
+ }
+ });
+
+ it('handles network error when sharing', async () => {
+ mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.shareQuiz('quiz-123');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Network error');
+ }
+
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+
+ it('returns existing token if quiz is already shared', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ shareToken: 'existing-token' }),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ let token;
+ await act(async () => {
+ token = await result.current.shareQuiz('quiz-123');
+ });
+
+ expect(token).toBe('existing-token');
+ });
+ });
+
+ describe('unshareQuiz', () => {
+ it('unshares a quiz successfully', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ success: true }),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ await act(async () => {
+ await result.current.unshareQuiz('quiz-123');
+ });
+
+ expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
+ method: 'DELETE',
+ });
+ });
+
+ it('sets and clears sharingQuizId during unshare operation', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockAuthFetch.mockReturnValueOnce(pendingPromise);
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ act(() => {
+ result.current.unshareQuiz('quiz-123');
+ });
+
+ await waitFor(() => {
+ expect(result.current.sharingQuizId).toBe('quiz-123');
+ });
+
+ await act(async () => {
+ resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true }) });
+ });
+
+ await waitFor(() => {
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+ });
+
+ it('updates quiz in local state to remove shareToken and set isShared false', async () => {
+ const initialQuizzes: QuizListItem[] = [
+ { id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, shareToken: 'oldtoken', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
+ ];
+
+ mockAuthFetch
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ await act(async () => {
+ await result.current.fetchQuizzes();
+ });
+
+ expect(result.current.quizzes[0].isShared).toBe(true);
+
+ await act(async () => {
+ await result.current.unshareQuiz('quiz-123');
+ });
+
+ expect(result.current.quizzes[0].shareToken).toBeUndefined();
+ expect(result.current.quizzes[0].isShared).toBe(false);
+ });
+
+ it('handles 404 not found when unsharing non-existent quiz', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.unshareQuiz('non-existent');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Quiz not found.');
+ }
+
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+
+ it('handles generic server error when unsharing', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.unshareQuiz('quiz-123');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Failed to stop sharing quiz.');
+ }
+ });
+
+ it('handles network error when unsharing', async () => {
+ mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ try {
+ await act(async () => {
+ await result.current.unshareQuiz('quiz-123');
+ });
+ expect.fail('Should have thrown');
+ } catch (e) {
+ expect((e as Error).message).toBe('Network error');
+ }
+
+ expect(result.current.sharingQuizId).toBeNull();
+ });
+
+ it('does not affect other quizzes in the list when unsharing', async () => {
+ const initialQuizzes: QuizListItem[] = [
+ { id: 'quiz-1', title: 'Quiz 1', source: 'manual', questionCount: 5, shareToken: 'token1', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
+ { id: 'quiz-2', title: 'Quiz 2', source: 'manual', questionCount: 3, shareToken: 'token2', isShared: true, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
+ ];
+
+ mockAuthFetch
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ await act(async () => {
+ await result.current.fetchQuizzes();
+ });
+
+ await act(async () => {
+ await result.current.unshareQuiz('quiz-1');
+ });
+
+ expect(result.current.quizzes[0].isShared).toBe(false);
+ expect(result.current.quizzes[1].isShared).toBe(true);
+ expect(result.current.quizzes[1].shareToken).toBe('token2');
+ });
+ });
+
+ describe('fetchQuizzes with sharing info', () => {
+ it('fetches quizzes with isShared and shareToken fields', async () => {
+ const mockQuizzes: QuizListItem[] = [
+ { id: '1', title: 'Shared Quiz', source: 'manual', questionCount: 5, shareToken: 'token123', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
+ { id: '2', title: 'Private Quiz', source: 'ai_generated', questionCount: 10, isShared: false, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
+ ];
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockQuizzes),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+
+ await act(async () => {
+ await result.current.fetchQuizzes();
+ });
+
+ expect(result.current.quizzes[0].isShared).toBe(true);
+ expect(result.current.quizzes[0].shareToken).toBe('token123');
+ expect(result.current.quizzes[1].isShared).toBe(false);
+ expect(result.current.quizzes[1].shareToken).toBeUndefined();
+ });
+ });
});
diff --git a/types.ts b/types.ts
index 4cf6c2e..44b91df 100644
--- a/types.ts
+++ b/types.ts
@@ -121,6 +121,8 @@ export interface QuizListItem {
source: QuizSource;
aiTopic?: string;
questionCount: number;
+ shareToken?: string;
+ isShared: boolean;
createdAt: string;
updatedAt: string;
}