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;
}