From 8a11275849fd39e5b0e9812e670b9c74f9ebc5f5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 16 Jan 2026 08:49:21 -0700 Subject: [PATCH] Add sharing --- App.tsx | 18 +- components/Landing.tsx | 6 + components/QuizLibrary.tsx | 73 +++- components/SharedQuizView.tsx | 241 ++++++++++ hooks/useQuizLibrary.ts | 71 +++ package-lock.json | 2 - server/src/db/connection.ts | 19 + server/src/db/schema.sql | 3 + server/src/index.ts | 2 + server/src/routes/quizzes.ts | 47 +- server/src/routes/shared.ts | 185 ++++++++ server/tests/api.test.ts | 370 ++++++++++++++++ tests/components/QuizLibrary.test.tsx | 119 +++++ tests/components/SharedQuizView.test.tsx | 531 +++++++++++++++++++++++ tests/hooks/useQuizLibrary.test.tsx | 317 +++++++++++++- types.ts | 2 + 16 files changed, 1996 insertions(+), 10 deletions(-) create mode 100644 components/SharedQuizView.tsx create mode 100644 server/src/routes/shared.ts create mode 100644 tests/components/SharedQuizView.test.tsx 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; }