Add sharing
This commit is contained in:
parent
240ce28692
commit
8a11275849
16 changed files with 1996 additions and 10 deletions
18
App.tsx
18
App.tsx
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useGame } from './hooks/useGame';
|
import { useGame } from './hooks/useGame';
|
||||||
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
||||||
import { useUserConfig } from './hooks/useUserConfig';
|
import { useUserConfig } from './hooks/useUserConfig';
|
||||||
|
|
@ -16,6 +17,7 @@ import { SaveOptionsModal } from './components/SaveOptionsModal';
|
||||||
import { DisconnectedScreen } from './components/DisconnectedScreen';
|
import { DisconnectedScreen } from './components/DisconnectedScreen';
|
||||||
import { WaitingToRejoin } from './components/WaitingToRejoin';
|
import { WaitingToRejoin } from './components/WaitingToRejoin';
|
||||||
import { HostReconnected } from './components/HostReconnected';
|
import { HostReconnected } from './components/HostReconnected';
|
||||||
|
import { SharedQuizView } from './components/SharedQuizView';
|
||||||
import type { Quiz, GameConfig } from './types';
|
import type { Quiz, GameConfig } from './types';
|
||||||
|
|
||||||
const seededRandom = (seed: number) => {
|
const seededRandom = (seed: number) => {
|
||||||
|
|
@ -45,6 +47,7 @@ const FloatingShapes = React.memo(() => {
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
||||||
const { defaultConfig } = useUserConfig();
|
const { defaultConfig } = useUserConfig();
|
||||||
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
||||||
|
|
@ -131,12 +134,25 @@ function App() {
|
||||||
|
|
||||||
const currentQ = quiz?.questions[currentQuestionIndex];
|
const currentQ = quiz?.questions[currentQuestionIndex];
|
||||||
|
|
||||||
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
|
||||||
const correctOpt = currentQ?.options.find(o => {
|
const correctOpt = currentQ?.options.find(o => {
|
||||||
if (role === 'HOST') return o.isCorrect;
|
if (role === 'HOST') return o.isCorrect;
|
||||||
return o.shape === currentCorrectShape;
|
return o.shape === currentCorrectShape;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sharedMatch = location.pathname.match(/^\/shared\/([a-zA-Z0-9_-]+)$/);
|
||||||
|
const isSharedQuizRoute = !!sharedMatch && gameState === 'LANDING';
|
||||||
|
|
||||||
|
if (isSharedQuizRoute) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen text-white relative overflow-hidden">
|
||||||
|
<FloatingShapes />
|
||||||
|
<div className="relative z-10 h-full">
|
||||||
|
<SharedQuizView onHostQuiz={(sharedQuiz) => loadSavedQuiz(sharedQuiz)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen text-white relative overflow-hidden">
|
<div className="h-screen text-white relative overflow-hidden">
|
||||||
<FloatingShapes />
|
<FloatingShapes />
|
||||||
|
|
|
||||||
|
|
@ -133,12 +133,15 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
loading: libraryLoading,
|
loading: libraryLoading,
|
||||||
loadingQuizId,
|
loadingQuizId,
|
||||||
deletingQuizId,
|
deletingQuizId,
|
||||||
|
sharingQuizId,
|
||||||
exporting,
|
exporting,
|
||||||
importing,
|
importing,
|
||||||
error: libraryError,
|
error: libraryError,
|
||||||
fetchQuizzes,
|
fetchQuizzes,
|
||||||
loadQuiz,
|
loadQuiz,
|
||||||
deleteQuiz,
|
deleteQuiz,
|
||||||
|
shareQuiz,
|
||||||
|
unshareQuiz,
|
||||||
exportQuizzes,
|
exportQuizzes,
|
||||||
importQuizzes,
|
importQuizzes,
|
||||||
parseImportFile,
|
parseImportFile,
|
||||||
|
|
@ -621,10 +624,13 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
loading={libraryLoading}
|
loading={libraryLoading}
|
||||||
loadingQuizId={loadingQuizId}
|
loadingQuizId={loadingQuizId}
|
||||||
deletingQuizId={deletingQuizId}
|
deletingQuizId={deletingQuizId}
|
||||||
|
sharingQuizId={sharingQuizId}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
error={libraryError}
|
error={libraryError}
|
||||||
onLoadQuiz={handleLoadQuiz}
|
onLoadQuiz={handleLoadQuiz}
|
||||||
onDeleteQuiz={deleteQuiz}
|
onDeleteQuiz={deleteQuiz}
|
||||||
|
onShareQuiz={shareQuiz}
|
||||||
|
onUnshareQuiz={unshareQuiz}
|
||||||
onExportQuizzes={exportQuizzes}
|
onExportQuizzes={exportQuizzes}
|
||||||
onImportClick={() => setImportOpen(true)}
|
onImportClick={() => setImportOpen(true)}
|
||||||
onRetry={retryLibrary}
|
onRetry={retryLibrary}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { QuizListItem } from '../types';
|
||||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface QuizLibraryProps {
|
interface QuizLibraryProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -11,10 +12,13 @@ interface QuizLibraryProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingQuizId: string | null;
|
loadingQuizId: string | null;
|
||||||
deletingQuizId: string | null;
|
deletingQuizId: string | null;
|
||||||
|
sharingQuizId: string | null;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onLoadQuiz: (id: string) => void;
|
onLoadQuiz: (id: string) => void;
|
||||||
onDeleteQuiz: (id: string) => void;
|
onDeleteQuiz: (id: string) => void;
|
||||||
|
onShareQuiz: (id: string) => Promise<string>;
|
||||||
|
onUnshareQuiz: (id: string) => Promise<void>;
|
||||||
onExportQuizzes: (ids: string[]) => Promise<void>;
|
onExportQuizzes: (ids: string[]) => Promise<void>;
|
||||||
onImportClick: () => void;
|
onImportClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
|
@ -27,10 +31,13 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
loading,
|
loading,
|
||||||
loadingQuizId,
|
loadingQuizId,
|
||||||
deletingQuizId,
|
deletingQuizId,
|
||||||
|
sharingQuizId,
|
||||||
exporting,
|
exporting,
|
||||||
error,
|
error,
|
||||||
onLoadQuiz,
|
onLoadQuiz,
|
||||||
onDeleteQuiz,
|
onDeleteQuiz,
|
||||||
|
onShareQuiz,
|
||||||
|
onUnshareQuiz,
|
||||||
onExportQuizzes,
|
onExportQuizzes,
|
||||||
onImportClick,
|
onImportClick,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
|
@ -38,7 +45,7 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || exporting;
|
const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || !!sharingQuizId || exporting;
|
||||||
|
|
||||||
useBodyScrollLock(isOpen);
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
|
|
@ -79,6 +86,24 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
setConfirmDeleteId(id);
|
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) => {
|
const confirmDelete = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirmDeleteId) {
|
if (confirmDeleteId) {
|
||||||
|
|
@ -264,6 +289,11 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
<PenTool size={12} /> Manual
|
<PenTool size={12} /> Manual
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{quiz.isShared && (
|
||||||
|
<span className="bg-green-100 text-green-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Share2 size={12} /> Shared
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-gray-400 text-xs font-bold flex items-center gap-1">
|
<span className="text-gray-400 text-xs font-bold flex items-center gap-1">
|
||||||
<Calendar size={12} /> {formatDate(quiz.createdAt)}
|
<Calendar size={12} /> {formatDate(quiz.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -280,7 +310,7 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectMode && (
|
{!selectMode && (
|
||||||
<div className="flex items-center gap-3 pl-4">
|
<div className="flex items-center gap-2 pl-4">
|
||||||
{loadingQuizId === quiz.id ? (
|
{loadingQuizId === quiz.id ? (
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<Loader2 size={24} className="animate-spin text-theme-primary" />
|
<Loader2 size={24} className="animate-spin text-theme-primary" />
|
||||||
|
|
@ -289,6 +319,10 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<Loader2 size={24} className="animate-spin text-red-500" />
|
<Loader2 size={24} className="animate-spin text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
|
) : sharingQuizId === quiz.id ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<Loader2 size={24} className="animate-spin text-theme-primary" />
|
||||||
|
</div>
|
||||||
) : confirmDeleteId === quiz.id ? (
|
) : confirmDeleteId === quiz.id ? (
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -308,13 +342,42 @@ export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{quiz.isShared ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleShareClick(e, quiz)}
|
||||||
|
disabled={isAnyOperationInProgress}
|
||||||
|
className="p-2.5 rounded-xl bg-green-50 text-green-600 hover:bg-green-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Copy share link"
|
||||||
|
>
|
||||||
|
<Copy size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleUnshareClick(e, quiz.id)}
|
||||||
|
disabled={isAnyOperationInProgress}
|
||||||
|
className="p-2.5 rounded-xl text-gray-300 hover:bg-orange-50 hover:text-orange-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Stop sharing"
|
||||||
|
>
|
||||||
|
<Link2Off size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleShareClick(e, quiz)}
|
||||||
|
disabled={isAnyOperationInProgress}
|
||||||
|
className="p-2.5 rounded-xl text-gray-300 hover:bg-theme-primary/10 hover:text-theme-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Share quiz"
|
||||||
|
>
|
||||||
|
<Share2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteClick(e, quiz.id)}
|
onClick={(e) => handleDeleteClick(e, quiz.id)}
|
||||||
disabled={isAnyOperationInProgress}
|
disabled={isAnyOperationInProgress}
|
||||||
className="p-3 rounded-xl text-gray-300 hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2.5 rounded-xl text-gray-300 hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
title="Delete quiz"
|
title="Delete quiz"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div className="bg-theme-primary/10 p-3 rounded-xl text-theme-primary group-hover:bg-theme-primary group-hover:text-white transition-all">
|
<div className="bg-theme-primary/10 p-3 rounded-xl text-theme-primary group-hover:bg-theme-primary group-hover:text-white transition-all">
|
||||||
<Play size={24} fill="currentColor" />
|
<Play size={24} fill="currentColor" />
|
||||||
|
|
|
||||||
241
components/SharedQuizView.tsx
Normal file
241
components/SharedQuizView.tsx
Normal file
|
|
@ -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<SharedQuizViewProps> = ({ onHostQuiz }) => {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
const { authFetch } = useAuthenticatedFetch();
|
||||||
|
|
||||||
|
const [quizData, setQuizData] = useState<SharedQuizData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50"
|
||||||
|
>
|
||||||
|
<Loader2 size={48} className="animate-spin text-theme-primary mx-auto mb-4" />
|
||||||
|
<p className="font-bold text-gray-500">Loading shared quiz...</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !quizData) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 text-center max-w-md"
|
||||||
|
>
|
||||||
|
<div className="bg-red-100 p-4 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertCircle size={40} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-gray-800 mb-2">Quiz Not Found</h2>
|
||||||
|
<p className="text-gray-500 font-medium mb-6">{error || 'This quiz is no longer available'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="bg-theme-primary text-white px-6 py-3 rounded-2xl font-bold shadow-[0_4px_0_var(--theme-primary-dark)] active:shadow-none active:translate-y-[4px] transition-all"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.4 }}
|
||||||
|
className="bg-white text-gray-900 p-6 md:p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 max-w-lg w-full"
|
||||||
|
>
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-theme-primary p-3 rounded-2xl rotate-3 shadow-lg">
|
||||||
|
<BrainCircuit size={32} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-gray-400 font-bold text-sm uppercase tracking-wider mb-1">Shared Quiz</p>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-black text-gray-800 mb-2">{quizData.title}</h1>
|
||||||
|
<p className="text-gray-500 font-medium">
|
||||||
|
{quizData.questionCount} question{quizData.questionCount !== 1 ? 's' : ''}
|
||||||
|
{quizData.aiTopic && <span className="text-gray-400"> • {quizData.aiTopic}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handleHost}
|
||||||
|
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<Play size={24} fill="currentColor" /> Host Game
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{auth.isAuthenticated ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveToLibrary}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="animate-spin" /> Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BookmarkPlus size={20} /> Save to My Library
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => auth.signinRedirect()}
|
||||||
|
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<LogIn size={20} /> Sign in to Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQuestions(!showQuestions)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 text-gray-400 font-bold hover:text-gray-600 transition-colors py-2"
|
||||||
|
>
|
||||||
|
{showQuestions ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp size={20} /> Hide Questions
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown size={20} /> Preview Questions
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showQuestions && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
className="mt-4 space-y-3 max-h-64 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{quizData.questions.map((q, i) => (
|
||||||
|
<div key={q.id} className="bg-gray-50 p-3 rounded-xl">
|
||||||
|
<p className="text-sm font-bold text-gray-400 mb-1">Question {i + 1}</p>
|
||||||
|
<p className="font-bold text-gray-700">{q.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-100 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="text-gray-400 font-bold hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ interface UseQuizLibraryReturn {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingQuizId: string | null;
|
loadingQuizId: string | null;
|
||||||
deletingQuizId: string | null;
|
deletingQuizId: string | null;
|
||||||
|
sharingQuizId: string | null;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
importing: boolean;
|
importing: boolean;
|
||||||
|
|
@ -18,6 +19,8 @@ interface UseQuizLibraryReturn {
|
||||||
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||||
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
||||||
deleteQuiz: (id: string) => Promise<void>;
|
deleteQuiz: (id: string) => Promise<void>;
|
||||||
|
shareQuiz: (id: string) => Promise<string>;
|
||||||
|
unshareQuiz: (id: string) => Promise<void>;
|
||||||
exportQuizzes: (quizIds: string[]) => Promise<void>;
|
exportQuizzes: (quizIds: string[]) => Promise<void>;
|
||||||
importQuizzes: (quizzes: ExportedQuiz[]) => Promise<void>;
|
importQuizzes: (quizzes: ExportedQuiz[]) => Promise<void>;
|
||||||
parseImportFile: (file: File) => Promise<QuizExportFile>;
|
parseImportFile: (file: File) => Promise<QuizExportFile>;
|
||||||
|
|
@ -31,6 +34,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingQuizId, setLoadingQuizId] = useState<string | null>(null);
|
const [loadingQuizId, setLoadingQuizId] = useState<string | null>(null);
|
||||||
const [deletingQuizId, setDeletingQuizId] = useState<string | null>(null);
|
const [deletingQuizId, setDeletingQuizId] = useState<string | null>(null);
|
||||||
|
const [sharingQuizId, setSharingQuizId] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
@ -267,6 +271,70 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
setDeletingQuizId(null);
|
setDeletingQuizId(null);
|
||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const shareQuiz = useCallback(async (id: string): Promise<string> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
const exportQuizzes = useCallback(async (quizIds: string[]): Promise<void> => {
|
||||||
if (quizIds.length === 0) {
|
if (quizIds.length === 0) {
|
||||||
|
|
@ -379,6 +447,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
loading,
|
loading,
|
||||||
loadingQuizId,
|
loadingQuizId,
|
||||||
deletingQuizId,
|
deletingQuizId,
|
||||||
|
sharingQuizId,
|
||||||
saving,
|
saving,
|
||||||
exporting,
|
exporting,
|
||||||
importing,
|
importing,
|
||||||
|
|
@ -389,6 +458,8 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
updateQuiz,
|
updateQuiz,
|
||||||
updateQuizConfig,
|
updateQuizConfig,
|
||||||
deleteQuiz,
|
deleteQuiz,
|
||||||
|
shareQuiz,
|
||||||
|
unshareQuiz,
|
||||||
exportQuizzes,
|
exportQuizzes,
|
||||||
importQuizzes,
|
importQuizzes,
|
||||||
parseImportFile,
|
parseImportFile,
|
||||||
|
|
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -3422,7 +3422,6 @@
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
|
|
@ -3546,7 +3545,6 @@
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,25 @@ const runMigrations = () => {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
|
||||||
console.log("Migration: Added gemini_model to users");
|
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();
|
runMigrations();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
||||||
ai_topic TEXT,
|
ai_topic TEXT,
|
||||||
game_config TEXT,
|
game_config TEXT,
|
||||||
|
share_token TEXT UNIQUE,
|
||||||
|
is_shared INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
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_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_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_options_question ON answer_options(question_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);
|
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import usersRouter from './routes/users.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
import gamesRouter from './routes/games.js';
|
import gamesRouter from './routes/games.js';
|
||||||
import generateRouter from './routes/generate.js';
|
import generateRouter from './routes/generate.js';
|
||||||
|
import sharedRouter from './routes/shared.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
@ -93,6 +94,7 @@ app.use('/api/users', usersRouter);
|
||||||
app.use('/api/upload', uploadRouter);
|
app.use('/api/upload', uploadRouter);
|
||||||
app.use('/api/games', gamesRouter);
|
app.use('/api/games', gamesRouter);
|
||||||
app.use('/api/generate', generateRouter);
|
app.use('/api/generate', generateRouter);
|
||||||
|
app.use('/api/shared', sharedRouter);
|
||||||
|
|
||||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { db } from '../db/connection.js';
|
import { db } from '../db/connection.js';
|
||||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
|
@ -47,6 +48,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
q.title,
|
q.title,
|
||||||
q.source,
|
q.source,
|
||||||
q.ai_topic as aiTopic,
|
q.ai_topic as aiTopic,
|
||||||
|
q.share_token as shareToken,
|
||||||
|
q.is_shared as isShared,
|
||||||
q.created_at as createdAt,
|
q.created_at as createdAt,
|
||||||
q.updated_at as updatedAt,
|
q.updated_at as updatedAt,
|
||||||
(SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount
|
(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
|
ORDER BY q.updated_at DESC
|
||||||
`).all(req.user!.sub);
|
`).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) => {
|
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
@ -315,6 +318,48 @@ router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
|
||||||
res.json({ success: true });
|
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) => {
|
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
||||||
|
|
|
||||||
185
server/src/routes/shared.ts
Normal file
185
server/src/routes/shared.ts
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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 ===');
|
console.log('\n=== Results ===');
|
||||||
const passed = results.filter((r) => r.passed).length;
|
const passed = results.filter((r) => r.passed).length;
|
||||||
const failed = results.filter((r) => !r.passed).length;
|
const failed = results.filter((r) => !r.passed).length;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const createMockQuiz = (overrides?: Partial<QuizListItem>): QuizListItem => ({
|
||||||
title: 'Test Quiz',
|
title: 'Test Quiz',
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
questionCount: 5,
|
questionCount: 5,
|
||||||
|
isShared: false,
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
updatedAt: '2024-01-15T10:00:00.000Z',
|
updatedAt: '2024-01-15T10:00:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
@ -23,6 +24,7 @@ describe('QuizLibrary', () => {
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingQuizId: null,
|
loadingQuizId: null,
|
||||||
deletingQuizId: null,
|
deletingQuizId: null,
|
||||||
|
sharingQuizId: null,
|
||||||
exporting: false,
|
exporting: false,
|
||||||
error: null,
|
error: null,
|
||||||
onLoadQuiz: vi.fn(),
|
onLoadQuiz: vi.fn(),
|
||||||
|
|
@ -30,6 +32,8 @@ describe('QuizLibrary', () => {
|
||||||
onExportQuizzes: vi.fn(),
|
onExportQuizzes: vi.fn(),
|
||||||
onImportClick: vi.fn(),
|
onImportClick: vi.fn(),
|
||||||
onRetry: vi.fn(),
|
onRetry: vi.fn(),
|
||||||
|
onShareQuiz: vi.fn().mockResolvedValue('mock-token'),
|
||||||
|
onUnshareQuiz: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -519,4 +523,119 @@ describe('QuizLibrary', () => {
|
||||||
expect(defaultProps.onRetry).toHaveBeenCalled();
|
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(<QuizLibrary {...defaultProps} />);
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Shared')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShareQuiz when share button clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuizLibrary {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} sharingQuizId="quiz-1" />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
531
tests/components/SharedQuizView.test.tsx
Normal file
531
tests/components/SharedQuizView.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<MemoryRouter initialEntries={[`/shared/${token}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/shared/:token" element={<SharedQuizView onHostQuiz={onHostQuiz} />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReset();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReset();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReset();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockSharedQuiz),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRouter('my-special-token');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/shared/my-special-token')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
||||||
import type { Quiz } from '../../types';
|
import type { Quiz, QuizListItem } from '../../types';
|
||||||
|
|
||||||
const mockAuthFetch = vi.fn();
|
const mockAuthFetch = vi.fn();
|
||||||
|
|
||||||
|
|
@ -1167,4 +1167,319 @@ it('creates blob with correct mime type', async () => {
|
||||||
expect(result.current.importing).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
types.ts
2
types.ts
|
|
@ -121,6 +121,8 @@ export interface QuizListItem {
|
||||||
source: QuizSource;
|
source: QuizSource;
|
||||||
aiTopic?: string;
|
aiTopic?: string;
|
||||||
questionCount: number;
|
questionCount: number;
|
||||||
|
shareToken?: string;
|
||||||
|
isShared: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue