diff --git a/App.tsx b/App.tsx index 263c737..4f8c606 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; -import { useLocation } from 'react-router-dom'; import { useGame } from './hooks/useGame'; import { useQuizLibrary } from './hooks/useQuizLibrary'; import { useUserConfig } from './hooks/useUserConfig'; @@ -17,7 +16,6 @@ import { SaveOptionsModal } from './components/SaveOptionsModal'; import { DisconnectedScreen } from './components/DisconnectedScreen'; import { WaitingToRejoin } from './components/WaitingToRejoin'; import { HostReconnected } from './components/HostReconnected'; -import { SharedQuizView } from './components/SharedQuizView'; import type { Quiz, GameConfig } from './types'; const seededRandom = (seed: number) => { @@ -47,7 +45,6 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); - const location = useLocation(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); const { defaultConfig } = useUserConfig(); const [showSaveOptions, setShowSaveOptions] = useState(false); @@ -134,25 +131,12 @@ function App() { const currentQ = quiz?.questions[currentQuestionIndex]; + // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) const correctOpt = currentQ?.options.find(o => { if (role === 'HOST') return o.isCorrect; return o.shape === currentCorrectShape; }); - const sharedMatch = location.pathname.match(/^\/shared\/([a-zA-Z0-9_-]+)$/); - const isSharedQuizRoute = !!sharedMatch && gameState === 'LANDING'; - - if (isSharedQuizRoute) { - return ( -
- -
- loadSavedQuiz(sharedQuiz)} /> -
-
- ); - } - return (
diff --git a/authentik/blueprints/kaboot-setup-production.yaml.example b/authentik/blueprints/kaboot-setup-production.yaml.example index 1c9ef5e..561bf5b 100644 --- a/authentik/blueprints/kaboot-setup-production.yaml.example +++ b/authentik/blueprints/kaboot-setup-production.yaml.example @@ -16,15 +16,6 @@ metadata: name: Kaboot Application Setup (Production) labels: blueprints.goauthentik.io/description: "Complete Kaboot OAuth2/OIDC setup for production" - blueprints.goauthentik.io/instantiate: "true" - -depends_on: - - default/flow-default-authentication-flow.yaml - - default/flow-default-invalidation-flow.yaml - - default/flow-default-provider-authorization-implicit-consent.yaml - - default/flow-default-provider-invalidation.yaml - - default/flow-default-user-settings-flow.yaml - - system/providers-oauth2.yaml context: kaboot_domain: kaboot.example.com @@ -136,7 +127,7 @@ entries: scope_name: groups description: "Include user groups in the token" expression: | - return {"groups": [group.name for group in request.user.ak_groups.all()]} + return [group.name for group in user.ak_groups.all()] # ═══════════════════════════════════════════════════════════════════════════════ # OAUTH2/OIDC PROVIDER diff --git a/authentik/blueprints/kaboot-setup.yaml b/authentik/blueprints/kaboot-setup.yaml index 106a170..91e6ef8 100644 --- a/authentik/blueprints/kaboot-setup.yaml +++ b/authentik/blueprints/kaboot-setup.yaml @@ -23,71 +23,8 @@ metadata: name: Kaboot Application Setup labels: blueprints.goauthentik.io/description: "Complete Kaboot OAuth2/OIDC setup with enrollment flow" - blueprints.goauthentik.io/instantiate: "true" - -# This blueprint depends on default authentik resources being created first. -# The depends_on ensures proper ordering during initial bootstrap. -depends_on: - - default/flow-default-authentication-flow.yaml - - default/flow-default-invalidation-flow.yaml - - default/flow-default-provider-authorization-implicit-consent.yaml - - default/flow-default-provider-invalidation.yaml - - default/flow-default-user-settings-flow.yaml - - system/providers-oauth2.yaml entries: - # ═══════════════════════════════════════════════════════════════════════════════ - # CUSTOM INVALIDATION FLOW (must be defined before brand that references it) - # ═══════════════════════════════════════════════════════════════════════════════ - - - id: kaboot-logout-stage - model: authentik_stages_user_logout.userlogoutstage - identifiers: - name: kaboot-logout - attrs: - name: kaboot-logout - - - id: kaboot-redirect-stage - model: authentik_stages_redirect.redirectstage - identifiers: - name: kaboot-redirect-to-app - attrs: - name: kaboot-redirect-to-app - mode: static - target_static: http://localhost:5173 - - - id: kaboot-invalidation-flow - model: authentik_flows.flow - identifiers: - slug: kaboot-invalidation-flow - attrs: - name: Kaboot Logout Flow - title: Logging out... - slug: kaboot-invalidation-flow - designation: invalidation - authentication: none - background: /media/branding/background.svg - - - id: kaboot-invalidation-logout-binding - model: authentik_flows.flowstagebinding - identifiers: - target: !KeyOf kaboot-invalidation-flow - stage: !KeyOf kaboot-logout-stage - attrs: - order: 0 - evaluate_on_plan: true - re_evaluate_policies: false - - - id: kaboot-invalidation-redirect-binding - model: authentik_flows.flowstagebinding - identifiers: - target: !KeyOf kaboot-invalidation-flow - stage: !KeyOf kaboot-redirect-stage - attrs: - order: 10 - evaluate_on_plan: true - re_evaluate_policies: false - # ═══════════════════════════════════════════════════════════════════════════════ # GROUPS # ═══════════════════════════════════════════════════════════════════════════════ @@ -119,7 +56,7 @@ entries: scope_name: groups description: "Include user groups in the token" expression: | - return {"groups": [group.name for group in request.user.ak_groups.all()]} + return [group.name for group in user.ak_groups.all()] # ═══════════════════════════════════════════════════════════════════════════════ # OAUTH2/OIDC PROVIDER @@ -132,7 +69,7 @@ entries: attrs: name: Kaboot OAuth2 authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] - invalidation_flow: !KeyOf kaboot-invalidation-flow + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Internal JWT Certificate]] client_type: public client_id: kaboot-spa @@ -197,7 +134,7 @@ entries: branding_logo: /media/branding/logo.svg branding_favicon: /media/branding/logo.svg flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]] - flow_invalidation: !KeyOf kaboot-invalidation-flow + flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]] default_application: !KeyOf kaboot-application attributes: @@ -329,6 +266,13 @@ entries: title: Welcome to Kaboot! background: /media/branding/background.svg + - id: update-invalidation-flow-background + model: authentik_flows.flow + identifiers: + slug: default-invalidation-flow + attrs: + background: /media/branding/background.svg + - id: update-authorization-flow-background model: authentik_flows.flow identifiers: diff --git a/components/Landing.tsx b/components/Landing.tsx index c37805c..54b31e6 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -133,15 +133,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading: libraryLoading, loadingQuizId, deletingQuizId, - sharingQuizId, exporting, importing, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz, - shareQuiz, - unshareQuiz, exportQuizzes, importQuizzes, parseImportFile, @@ -624,13 +621,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading={libraryLoading} loadingQuizId={loadingQuizId} deletingQuizId={deletingQuizId} - sharingQuizId={sharingQuizId} exporting={exporting} error={libraryError} onLoadQuiz={handleLoadQuiz} onDeleteQuiz={deleteQuiz} - onShareQuiz={shareQuiz} - onUnshareQuiz={unshareQuiz} onExportQuizzes={exportQuizzes} onImportClick={() => setImportOpen(true)} onRetry={retryLibrary} diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx index afbf116..7f1bf00 100644 --- a/components/QuizLibrary.tsx +++ b/components/QuizLibrary.tsx @@ -1,9 +1,8 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks, Share2, Link2Off, Copy } from 'lucide-react'; +import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks } from 'lucide-react'; import { QuizListItem } from '../types'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; -import toast from 'react-hot-toast'; interface QuizLibraryProps { isOpen: boolean; @@ -12,13 +11,10 @@ interface QuizLibraryProps { loading: boolean; loadingQuizId: string | null; deletingQuizId: string | null; - sharingQuizId: string | null; exporting: boolean; error: string | null; onLoadQuiz: (id: string) => void; onDeleteQuiz: (id: string) => void; - onShareQuiz: (id: string) => Promise; - onUnshareQuiz: (id: string) => Promise; onExportQuizzes: (ids: string[]) => Promise; onImportClick: () => void; onRetry: () => void; @@ -31,13 +27,10 @@ export const QuizLibrary: React.FC = ({ loading, loadingQuizId, deletingQuizId, - sharingQuizId, exporting, error, onLoadQuiz, onDeleteQuiz, - onShareQuiz, - onUnshareQuiz, onExportQuizzes, onImportClick, onRetry, @@ -45,7 +38,7 @@ export const QuizLibrary: React.FC = ({ const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || !!sharingQuizId || exporting; + const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || exporting; useBodyScrollLock(isOpen); @@ -86,24 +79,6 @@ export const QuizLibrary: React.FC = ({ setConfirmDeleteId(id); }; - const handleShareClick = async (e: React.MouseEvent, quiz: QuizListItem) => { - e.stopPropagation(); - if (quiz.isShared && quiz.shareToken) { - const shareUrl = `${window.location.origin}/shared/${quiz.shareToken}`; - await navigator.clipboard.writeText(shareUrl); - toast.success('Link copied to clipboard!'); - } else { - const token = await onShareQuiz(quiz.id); - const shareUrl = `${window.location.origin}/shared/${token}`; - await navigator.clipboard.writeText(shareUrl); - } - }; - - const handleUnshareClick = async (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - await onUnshareQuiz(id); - }; - const confirmDelete = async (e: React.MouseEvent) => { e.stopPropagation(); if (confirmDeleteId) { @@ -289,11 +264,6 @@ export const QuizLibrary: React.FC = ({ Manual )} - {quiz.isShared && ( - - Shared - - )} {formatDate(quiz.createdAt)} @@ -310,7 +280,7 @@ export const QuizLibrary: React.FC = ({
{!selectMode && ( -
+
{loadingQuizId === quiz.id ? (
@@ -319,10 +289,6 @@ export const QuizLibrary: React.FC = ({
- ) : sharingQuizId === quiz.id ? ( -
- -
) : confirmDeleteId === quiz.id ? (
e.stopPropagation()}>
) : ( <> - {quiz.isShared ? ( -
- - -
- ) : ( - - )}
diff --git a/components/SharedQuizView.tsx b/components/SharedQuizView.tsx deleted file mode 100644 index 6495aff..0000000 --- a/components/SharedQuizView.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useAuth } from 'react-oidc-context'; -import { motion } from 'framer-motion'; -import { BrainCircuit, Play, Loader2, BookmarkPlus, ChevronDown, ChevronUp, AlertCircle, LogIn } from 'lucide-react'; -import toast from 'react-hot-toast'; -import type { Quiz, Question, GameConfig } from '../types'; -import { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch'; - -const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; - -interface SharedQuizData { - title: string; - source: 'manual' | 'ai_generated'; - aiTopic?: string; - gameConfig: GameConfig | null; - questions: Question[]; - questionCount: number; -} - -interface SharedQuizViewProps { - onHostQuiz: (quiz: Quiz) => void; -} - -export const SharedQuizView: React.FC = ({ onHostQuiz }) => { - const { token } = useParams<{ token: string }>(); - const navigate = useNavigate(); - const auth = useAuth(); - const { authFetch } = useAuthenticatedFetch(); - - const [quizData, setQuizData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - const [showQuestions, setShowQuestions] = useState(false); - - useEffect(() => { - const fetchSharedQuiz = async () => { - if (!token) { - setError('Invalid share link'); - setLoading(false); - return; - } - - try { - const response = await fetch(`${BACKEND_URL}/api/shared/${token}`); - - if (!response.ok) { - if (response.status === 404) { - setError('This quiz is no longer available'); - } else { - setError('Failed to load quiz'); - } - setLoading(false); - return; - } - - const data = await response.json(); - setQuizData(data); - } catch { - setError('Failed to load quiz'); - } finally { - setLoading(false); - } - }; - - fetchSharedQuiz(); - }, [token]); - - const handleHost = () => { - if (!quizData) return; - - const quiz: Quiz = { - title: quizData.title, - questions: quizData.questions, - config: quizData.gameConfig || undefined, - }; - - onHostQuiz(quiz); - }; - - const handleSaveToLibrary = async () => { - if (!token || !auth.isAuthenticated) return; - - setSaving(true); - try { - const response = await authFetch(`/api/shared/${token}/copy`, { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('Failed to save quiz'); - } - - const data = await response.json(); - toast.success(`"${data.title}" saved to your library!`); - } catch { - toast.error('Failed to save quiz to library'); - } finally { - setSaving(false); - } - }; - - if (loading) { - return ( -
- - -

Loading shared quiz...

-
-
- ); - } - - if (error || !quizData) { - return ( -
- -
- -
-

Quiz Not Found

-

{error || 'This quiz is no longer available'}

- -
-
- ); - } - - return ( -
- -
-
- -
-
- -
-

Shared Quiz

-

{quizData.title}

-

- {quizData.questionCount} question{quizData.questionCount !== 1 ? 's' : ''} - {quizData.aiTopic && • {quizData.aiTopic}} -

-
- -
- - - {auth.isAuthenticated ? ( - - ) : ( - - )} -
- - - - {showQuestions && ( - - {quizData.questions.map((q, i) => ( -
-

Question {i + 1}

-

{q.text}

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