diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 628e382..0000000 --- a/.dockerignore +++ /dev/null @@ -1,17 +0,0 @@ -node_modules -dist -.git -.gitignore -*.md -.env* -!.env.example -server/ -authentik/ -caddy/ -scripts/ -docs/ -tests/ -*.log -.DS_Store -Caddyfile -docker-compose*.yml diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 10a3651..0000000 --- a/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# Kaboot Frontend - Multi-stage Production Build -# -# Build: -# docker build \ -# --build-arg VITE_API_URL=https://kaboot.example.com \ -# --build-arg VITE_BACKEND_URL=https://kaboot.example.com \ -# --build-arg VITE_AUTHENTIK_URL=https://auth.example.com \ -# -t kaboot-frontend . - -FROM node:20-alpine AS builder -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --silent - -COPY index.html tsconfig.json vite.config.ts postcss.config.mjs ./ -COPY src/ ./src/ -COPY components/ ./components/ -COPY hooks/ ./hooks/ -COPY public/ ./public/ - -ARG VITE_API_URL -ARG VITE_BACKEND_URL -ARG VITE_AUTHENTIK_URL -ARG VITE_OIDC_CLIENT_ID=kaboot-spa -ARG VITE_OIDC_APP_SLUG=kaboot -ARG GEMINI_API_KEY - -ENV VITE_API_URL=$VITE_API_URL \ - VITE_BACKEND_URL=$VITE_BACKEND_URL \ - VITE_AUTHENTIK_URL=$VITE_AUTHENTIK_URL \ - VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \ - VITE_OIDC_APP_SLUG=$VITE_OIDC_APP_SLUG \ - GEMINI_API_KEY=$GEMINI_API_KEY - -RUN npm run build - -FROM caddy:2-alpine -COPY --from=builder /app/dist /srv/frontend -EXPOSE 80 443 -CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/components/ImportQuizzesModal.tsx b/components/ImportQuizzesModal.tsx deleted file mode 100644 index b3700ae..0000000 --- a/components/ImportQuizzesModal.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useState, useRef } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { X, FileUp, FileJson, Check, AlertCircle, Loader2, BrainCircuit, PenTool } from 'lucide-react'; -import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; -import type { ExportedQuiz, QuizExportFile } from '../types'; - -interface ImportQuizzesModalProps { - isOpen: boolean; - onClose: () => void; - onImport: (quizzes: ExportedQuiz[]) => Promise; - parseFile: (file: File) => Promise; - importing: boolean; -} - -type ImportStep = 'upload' | 'select'; - -export const ImportQuizzesModal: React.FC = ({ - isOpen, - onClose, - onImport, - parseFile, - importing, -}) => { - const [step, setStep] = useState('upload'); - const [parsedFile, setParsedFile] = useState(null); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [parseError, setParseError] = useState(null); - const [dragActive, setDragActive] = useState(false); - const fileInputRef = useRef(null); - - useBodyScrollLock(isOpen); - - const resetState = () => { - setStep('upload'); - setParsedFile(null); - setSelectedIds(new Set()); - setParseError(null); - setDragActive(false); - }; - - const handleClose = () => { - resetState(); - onClose(); - }; - - const handleFileSelect = async (file: File) => { - setParseError(null); - - if (!file.name.endsWith('.json')) { - setParseError('Please select a JSON file'); - return; - } - - try { - const parsed = await parseFile(file); - setParsedFile(parsed); - setSelectedIds(new Set(parsed.quizzes.map((_, i) => i))); - setStep('select'); - } catch (err) { - setParseError(err instanceof Error ? err.message : 'Failed to parse file'); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragActive(false); - - const file = e.dataTransfer.files[0]; - if (file) handleFileSelect(file); - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setDragActive(true); - }; - - const handleDragLeave = () => { - setDragActive(false); - }; - - const toggleQuiz = (index: number) => { - const newSelected = new Set(selectedIds); - if (newSelected.has(index)) { - newSelected.delete(index); - } else { - newSelected.add(index); - } - setSelectedIds(newSelected); - }; - - const toggleAll = () => { - if (!parsedFile) return; - if (selectedIds.size === parsedFile.quizzes.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(parsedFile.quizzes.map((_, i) => i))); - } - }; - - const handleImport = async () => { - if (!parsedFile || selectedIds.size === 0) return; - - const quizzesToImport = parsedFile.quizzes.filter((_, i) => selectedIds.has(i)); - await onImport(quizzesToImport); - handleClose(); - }; - - return ( - - {isOpen && ( - - e.stopPropagation()} - className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden" - > -
-
-

Import Quizzes

-

- {step === 'upload' ? 'Select a Kaboot export file' : `${selectedIds.size} of ${parsedFile?.quizzes.length} selected`} -

-
- -
- -
- {step === 'upload' && ( -
-
fileInputRef.current?.click()} - className={`border-3 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${ - dragActive - ? 'border-theme-primary bg-theme-primary/5' - : 'border-gray-200 hover:border-gray-300 hover:bg-gray-100' - }`} - > - e.target.files?.[0] && handleFileSelect(e.target.files[0])} - className="hidden" - /> -
-
- -
-
-

- {dragActive ? 'Drop file here' : 'Drag & drop or click to select'} -

-

- Accepts .json export files -

-
-
-
- - {parseError && ( -
- -

{parseError}

-
- )} -
- )} - - {step === 'select' && parsedFile && ( -
-
- -

- Exported {new Date(parsedFile.exportedAt).toLocaleDateString()} -

-
- - {parsedFile.quizzes.map((quiz, index) => ( - !importing && toggleQuiz(index)} - className={`group bg-white p-4 rounded-2xl border-2 transition-all cursor-pointer ${ - selectedIds.has(index) - ? 'border-theme-primary shadow-md' - : 'border-gray-100 hover:border-gray-200' - } ${importing ? 'opacity-70 cursor-not-allowed' : ''}`} - > -
-
- {selectedIds.has(index) && ( - - )} -
- -
-
- {quiz.source === 'ai_generated' ? ( - - AI - - ) : ( - - Manual - - )} -
-

{quiz.title}

-

- {quiz.questions.length} question{quiz.questions.length !== 1 ? 's' : ''} - {quiz.aiTopic && · {quiz.aiTopic}} -

-
-
-
- ))} -
- )} -
- - {step === 'select' && ( -
- - -
- )} -
-
- )} -
- ); -}; diff --git a/components/Landing.tsx b/components/Landing.tsx index 54b31e6..d6ba50a 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -5,7 +5,6 @@ import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Im import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; import { QuizLibrary } from './QuizLibrary'; -import { ImportQuizzesModal } from './ImportQuizzesModal'; import { DefaultConfigModal } from './DefaultConfigModal'; import { PreferencesModal } from './PreferencesModal'; import { ApiKeyModal } from './ApiKeyModal'; @@ -69,7 +68,6 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const modalParam = searchParams.get('modal'); const libraryOpen = modalParam === 'library'; - const importOpen = modalParam === 'import'; const preferencesOpen = modalParam === 'preferences'; const defaultConfigOpen = modalParam === 'settings'; const accountSettingsOpen = modalParam === 'account'; @@ -91,10 +89,6 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setSearchParams(buildCleanParams({ modal: open ? 'library' : null })); }; - const setImportOpen = (open: boolean) => { - setSearchParams(buildCleanParams({ modal: open ? 'import' : null })); - }; - const setPreferencesOpen = (open: boolean) => { setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null })); }; @@ -133,15 +127,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading: libraryLoading, loadingQuizId, deletingQuizId, - exporting, - importing, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz, - exportQuizzes, - importQuizzes, - parseImportFile, retry: retryLibrary } = useQuizLibrary(); @@ -621,23 +610,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading={libraryLoading} loadingQuizId={loadingQuizId} deletingQuizId={deletingQuizId} - exporting={exporting} error={libraryError} onLoadQuiz={handleLoadQuiz} onDeleteQuiz={deleteQuiz} - onExportQuizzes={exportQuizzes} - onImportClick={() => setImportOpen(true)} onRetry={retryLibrary} /> - setImportOpen(false)} - onImport={importQuizzes} - parseFile={parseImportFile} - importing={importing} - /> - { diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx index 7f1bf00..bd25caa 100644 --- a/components/QuizLibrary.tsx +++ b/components/QuizLibrary.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks } from 'lucide-react'; +import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react'; import { QuizListItem } from '../types'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; @@ -11,12 +11,9 @@ interface QuizLibraryProps { loading: boolean; loadingQuizId: string | null; deletingQuizId: string | null; - exporting: boolean; error: string | null; onLoadQuiz: (id: string) => void; onDeleteQuiz: (id: string) => void; - onExportQuizzes: (ids: string[]) => Promise; - onImportClick: () => void; onRetry: () => void; } @@ -27,53 +24,16 @@ export const QuizLibrary: React.FC = ({ loading, loadingQuizId, deletingQuizId, - exporting, error, onLoadQuiz, onDeleteQuiz, - onExportQuizzes, - onImportClick, onRetry, }) => { const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [selectMode, setSelectMode] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || exporting; + const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId; useBodyScrollLock(isOpen); - const toggleSelectMode = () => { - if (selectMode) { - setSelectedIds(new Set()); - } - setSelectMode(!selectMode); - }; - - const toggleQuizSelection = (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - const newSelected = new Set(selectedIds); - if (newSelected.has(id)) { - newSelected.delete(id); - } else { - newSelected.add(id); - } - setSelectedIds(newSelected); - }; - - const toggleSelectAll = () => { - if (selectedIds.size === quizzes.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(quizzes.map(q => q.id))); - } - }; - - const handleExport = async () => { - await onExportQuizzes(Array.from(selectedIds)); - setSelectMode(false); - setSelectedIds(new Set()); - }; - const handleDeleteClick = (e: React.MouseEvent, id: string) => { e.stopPropagation(); setConfirmDeleteId(id); @@ -130,45 +90,14 @@ export const QuizLibrary: React.FC = ({

My Library

-

- {selectMode - ? `${selectedIds.size} of ${quizzes.length} selected` - : 'Select a quiz to play' - } -

-
-
- {!loading && quizzes.length > 0 && ( - <> - - - - )} - +

Select a quiz to play

+
@@ -198,24 +127,6 @@ export const QuizLibrary: React.FC = ({

No saved quizzes yet

Create or generate a quiz to save it here!

- - - )} - - {selectMode && quizzes.length > 0 && ( -
-
)} @@ -225,34 +136,10 @@ export const QuizLibrary: React.FC = ({ layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} - className={`group bg-white p-4 rounded-2xl border-2 transition-all relative overflow-hidden ${ - selectMode && selectedIds.has(quiz.id) - ? 'border-theme-primary shadow-md' - : 'border-gray-100 hover:border-theme-primary hover:shadow-md' - } ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} - onClick={(e) => { - if (isAnyOperationInProgress) return; - if (selectMode) { - toggleQuizSelection(e, quiz.id); - } else { - onLoadQuiz(quiz.id); - } - }} + className={`group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all relative overflow-hidden ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} + onClick={() => !isAnyOperationInProgress && onLoadQuiz(quiz.id)} >
- {selectMode && ( -
-
- {selectedIds.has(quiz.id) && ( - - )} -
-
- )}
{quiz.source === 'ai_generated' ? ( @@ -279,83 +166,52 @@ export const QuizLibrary: React.FC = ({

- {!selectMode && ( -
- {loadingQuizId === quiz.id ? ( -
- +
+ {loadingQuizId === quiz.id ? ( +
+ +
+ ) : deletingQuizId === quiz.id ? ( +
+ +
+ ) : confirmDeleteId === quiz.id ? ( +
e.stopPropagation()}> + + +
+ ) : ( + <> + +
+
- ) : deletingQuizId === quiz.id ? ( -
- -
- ) : confirmDeleteId === quiz.id ? ( -
e.stopPropagation()}> - - -
- ) : ( - <> - -
- -
- - )} -
- )} + + )} +
))}
- - {selectMode && ( -
- - -
- )} diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml index bba43ed..a79fb70 100644 --- a/docker-compose.caddy.yml +++ b/docker-compose.caddy.yml @@ -1,22 +1,28 @@ +# Caddy Reverse Proxy for Kaboot Production +# +# This compose file adds Caddy as a reverse proxy with automatic HTTPS. +# Use with docker-compose.prod.yml using the -f flag. +# +# Usage: +# docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d +# +# Prerequisites: +# 1. Copy Caddyfile.example to Caddyfile and update domains +# 2. Build the frontend: npm run build +# 3. Update your domain DNS to point to your server +# 4. See docs/PRODUCTION.md for full instructions + services: - kaboot-frontend: - build: - context: . - dockerfile: Dockerfile - args: - VITE_API_URL: https://${KABOOT_DOMAIN} - VITE_BACKEND_URL: https://${KABOOT_DOMAIN} - VITE_AUTHENTIK_URL: https://${AUTH_DOMAIN} - VITE_OIDC_CLIENT_ID: kaboot-spa - VITE_OIDC_APP_SLUG: kaboot - GEMINI_API_KEY: ${GEMINI_API_KEY:-} - container_name: kaboot-frontend + caddy: + image: caddy:2-alpine + container_name: kaboot-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./dist:/srv/frontend:ro - ./caddy/data:/data - ./caddy/config:/config depends_on: diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index f9217d6..d9670c3 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -16,10 +16,11 @@ The easiest way to deploy Kaboot is using the automated setup script: This script automatically: - Generates all required secrets (database passwords, API tokens, etc.) -- Creates the production `.env` file with domain configuration +- Creates the production `.env` file - Creates the `Caddyfile` with your domains - Creates the Authentik production blueprint - Removes the development blueprint +- Builds the frontend with production URLs After running the script, start the stack: @@ -66,16 +67,19 @@ cp Caddyfile.example Caddyfile # Edit Caddyfile - replace example.com with your domains # 4. Update .env with production values -# - KABOOT_DOMAIN=your-app.com -# - AUTH_DOMAIN=auth.your-app.com # - OIDC_ISSUER=https://auth.your-app.com/application/o/kaboot/ # - OIDC_JWKS_URI=https://auth.your-app.com/application/o/kaboot/jwks/ # - CORS_ORIGIN=https://your-app.com -# 5. Start the stack (frontend builds automatically in Docker) -docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d --build +# 5. Build frontend with production URLs +VITE_API_URL=https://your-app.com/api \ +VITE_OIDC_AUTHORITY=https://auth.your-app.com/application/o/kaboot/ \ +npm run build -# 6. Verify +# 6. Start the stack +docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d + +# 7. Verify docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps ``` @@ -86,8 +90,6 @@ docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps - DNS A records pointing your domains to your server (see below). - A Google Gemini API key (optional, for AI quiz generation). -> **Note**: Node.js is **not** required on the production server. The frontend is built inside a Docker container. - ## DNS Configuration You need two DNS records pointing to your server's IP address. You can use either: @@ -157,9 +159,10 @@ Both should return your server's IP address. You can also use [dnschecker.org](h ## Architecture Overview In production, the stack consists of: -- **Kaboot Frontend**: Multi-stage Docker build (Node.js builds assets, Caddy serves them). Includes automatic HTTPS via Let's Encrypt. -- **Kaboot Backend**: Express server for quiz logic and SQLite storage. +- **Caddy**: Reverse proxy with automatic HTTPS via Let's Encrypt. - **Authentik**: Identity Provider for OAuth2/OIDC authentication. +- **Kaboot Backend**: Express server for quiz logic and SQLite storage. +- **Kaboot Frontend**: Static assets served via Caddy. - **PostgreSQL**: Database for Authentik. - **Redis**: Cache and task queue for Authentik. @@ -196,13 +199,11 @@ LOG_REQUESTS=true ### Frontend Configuration -The frontend is built inside Docker using the `KABOOT_DOMAIN` and `AUTH_DOMAIN` variables from your `.env` file. These are passed as build arguments to the Dockerfile: +The frontend requires environment variables at **build time** (not runtime): +- `VITE_API_URL`: `https://kaboot.example.com/api` +- `VITE_OIDC_AUTHORITY`: `https://auth.example.com/application/o/kaboot/` -- `VITE_API_URL`: `https://${KABOOT_DOMAIN}` -- `VITE_BACKEND_URL`: `https://${KABOOT_DOMAIN}` -- `VITE_AUTHENTIK_URL`: `https://${AUTH_DOMAIN}` - -The `setup-prod.sh` script sets these domain variables automatically. +The `setup-prod.sh` script sets these automatically when building. ## Docker Compose Files @@ -259,21 +260,28 @@ auth.example.com { } ``` -**Step 2: Start with Caddy** +**Step 2: Build the Frontend** + +```bash +npm run build +``` + +This creates the `dist/` directory with production assets. + +**Step 3: Start with Caddy** Use both compose files together: ```bash -docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d --build +docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d ``` This will: -- Build the frontend inside Docker with production URLs baked in - Start all Kaboot services (backend, Authentik, PostgreSQL, Redis) - Start Caddy as a reverse proxy on ports 80 and 443 - Automatically obtain SSL certificates from Let's Encrypt -**Step 3: Verify** +**Step 4: Verify** Check that all services are running: @@ -281,10 +289,10 @@ Check that all services are running: docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps ``` -View frontend/Caddy logs: +View Caddy logs: ```bash -docker logs kaboot-frontend +docker logs kaboot-caddy ``` Check Authentik blueprint was applied: diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts index 4f361b1..f9c91c1 100644 --- a/hooks/useQuizLibrary.ts +++ b/hooks/useQuizLibrary.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef } from 'react'; import toast from 'react-hot-toast'; import { useAuthenticatedFetch } from './useAuthenticatedFetch'; -import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig, ExportedQuiz, QuizExportFile } from '../types'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types'; interface UseQuizLibraryReturn { quizzes: QuizListItem[]; @@ -9,8 +9,6 @@ interface UseQuizLibraryReturn { loadingQuizId: string | null; deletingQuizId: string | null; saving: boolean; - exporting: boolean; - importing: boolean; error: string | null; fetchQuizzes: () => Promise; loadQuiz: (id: string) => Promise; @@ -18,9 +16,6 @@ interface UseQuizLibraryReturn { updateQuiz: (id: string, quiz: Quiz) => Promise; updateQuizConfig: (id: string, config: GameConfig) => Promise; deleteQuiz: (id: string) => Promise; - exportQuizzes: (quizIds: string[]) => Promise; - importQuizzes: (quizzes: ExportedQuiz[]) => Promise; - parseImportFile: (file: File) => Promise; retry: () => Promise; clearError: () => void; } @@ -32,8 +27,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { const [loadingQuizId, setLoadingQuizId] = useState(null); const [deletingQuizId, setDeletingQuizId] = useState(null); const [saving, setSaving] = useState(false); - const [exporting, setExporting] = useState(false); - const [importing, setImporting] = useState(false); const [error, setError] = useState(null); const lastOperationRef = useRef<(() => Promise) | null>(null); const savingRef = useRef(false); @@ -172,7 +165,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } throw err; } finally { - savingRef.current = false; setSaving(false); } }, [authFetch]); @@ -268,102 +260,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } }, [authFetch]); - const exportQuizzes = useCallback(async (quizIds: string[]): Promise => { - if (quizIds.length === 0) { - toast.error('No quizzes selected'); - return; - } - - setExporting(true); - try { - const quizzesToExport: ExportedQuiz[] = []; - - for (const id of quizIds) { - const response = await authFetch(`/api/quizzes/${id}`); - if (!response.ok) continue; - - const data = await response.json(); - quizzesToExport.push({ - title: data.title, - source: data.source, - aiTopic: data.aiTopic, - config: data.gameConfig, - questions: data.questions.map((q: { id: string; text: string; timeLimit: number; options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }) => ({ - id: q.id, - text: q.text, - timeLimit: q.timeLimit, - options: q.options, - })), - }); - } - - const exportData: QuizExportFile = { - version: 1, - exportedAt: new Date().toISOString(), - quizzes: quizzesToExport, - }; - - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `kaboot-quizzes-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast.success(`Exported ${quizzesToExport.length} quiz${quizzesToExport.length !== 1 ? 'zes' : ''}`); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to export quizzes'; - toast.error(message); - throw err; - } finally { - setExporting(false); - } - }, [authFetch]); - - const parseImportFile = useCallback(async (file: File): Promise => { - const text = await file.text(); - const data = JSON.parse(text); - - if (!data.version || !data.quizzes || !Array.isArray(data.quizzes)) { - throw new Error('Invalid export file format'); - } - - return data as QuizExportFile; - }, []); - - const importQuizzes = useCallback(async (quizzesToImport: ExportedQuiz[]): Promise => { - if (quizzesToImport.length === 0) { - toast.error('No quizzes selected'); - return; - } - - setImporting(true); - let successCount = 0; - - try { - for (const quiz of quizzesToImport) { - await saveQuiz( - { title: quiz.title, questions: quiz.questions, config: quiz.config }, - quiz.source, - quiz.aiTopic - ); - successCount++; - } - - toast.success(`Imported ${successCount} quiz${successCount !== 1 ? 'zes' : ''}`); - await fetchQuizzes(); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to import quizzes'; - toast.error(`Imported ${successCount} of ${quizzesToImport.length}. ${message}`); - throw err; - } finally { - setImporting(false); - } - }, [saveQuiz, fetchQuizzes]); - const retry = useCallback(async () => { if (lastOperationRef.current) { await lastOperationRef.current(); @@ -380,8 +276,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { loadingQuizId, deletingQuizId, saving, - exporting, - importing, error, fetchQuizzes, loadQuiz, @@ -389,9 +283,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { updateQuiz, updateQuizConfig, deleteQuiz, - exportQuizzes, - importQuizzes, - parseImportFile, retry, clearError, }; diff --git a/scripts/setup-prod.sh b/scripts/setup-prod.sh index e98d4ed..1910756 100755 --- a/scripts/setup-prod.sh +++ b/scripts/setup-prod.sh @@ -133,10 +133,6 @@ cat > .env << EOF # Kaboot Production Configuration # Generated by setup-prod.sh on $(date) -# Domain Configuration (used by docker-compose for frontend build) -KABOOT_DOMAIN=${KABOOT_DOMAIN} -AUTH_DOMAIN=${AUTH_DOMAIN} - # Database PG_PASS=${PG_PASS} PG_USER=authentik @@ -233,12 +229,29 @@ if [ -f "$DEV_BLUEPRINT" ]; then print_success "Removed development blueprint" fi +print_step "Installing frontend dependencies..." + +npm install --silent 2>/dev/null || npm install + +print_success "Dependencies installed" + +print_step "Building frontend with production URLs..." + +VITE_API_URL="https://${KABOOT_DOMAIN}" \ +VITE_BACKEND_URL="https://${KABOOT_DOMAIN}" \ +VITE_AUTHENTIK_URL="https://${AUTH_DOMAIN}" \ +VITE_OIDC_CLIENT_ID="kaboot-spa" \ +VITE_OIDC_APP_SLUG="kaboot" \ +npm run build --silent 2>/dev/null || npm run build + if [ -n "$GEMINI_API_KEY" ]; then print_success "System AI will be available (key configured for backend)" else print_warning "No Gemini API key provided - users must configure their own" fi +print_success "Frontend built" + echo "" echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}${BOLD} Setup Complete!${NC}" @@ -267,6 +280,7 @@ echo "──────────────────────── echo " .env - Environment variables" echo " Caddyfile - Reverse proxy config" echo " authentik/blueprints/kaboot-setup-production.yaml" +echo " dist/ - Built frontend" echo "" echo -e "${BOLD}Next Steps${NC}" echo "────────────────────────────────────────────────────────────" diff --git a/tests/components/ImportQuizzesModal.test.tsx b/tests/components/ImportQuizzesModal.test.tsx deleted file mode 100644 index 69dfcb4..0000000 --- a/tests/components/ImportQuizzesModal.test.tsx +++ /dev/null @@ -1,539 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ImportQuizzesModal } from '../../components/ImportQuizzesModal'; -import type { ExportedQuiz, QuizExportFile } from '../../types'; - -const createMockExportFile = (quizzes: ExportedQuiz[] = []): QuizExportFile => ({ - version: 1, - exportedAt: '2024-01-15T10:00:00.000Z', - quizzes, -}); - -const createMockQuiz = (overrides?: Partial): ExportedQuiz => ({ - title: 'Test Quiz', - source: 'manual', - 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' }, - ], - }, - ], - ...overrides, -}); - -describe('ImportQuizzesModal', () => { - const defaultProps = { - isOpen: true, - onClose: vi.fn(), - onImport: vi.fn(), - parseFile: vi.fn(), - importing: false, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders nothing when isOpen is false', () => { - render(); - expect(screen.queryByText('Import Quizzes')).not.toBeInTheDocument(); - }); - - it('renders modal when isOpen is true', () => { - render(); - expect(screen.getByText('Import Quizzes')).toBeInTheDocument(); - }); - - it('shows upload step by default', () => { - render(); - expect(screen.getByText(/Select a Kaboot export file/)).toBeInTheDocument(); - expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); - }); - - it('shows file type hint', () => { - render(); - expect(screen.getByText(/Accepts .json export files/)).toBeInTheDocument(); - }); - }); - - describe('file upload - happy path', () => { - it('parses file on drop and shows quiz selection', async () => { - const mockExport = createMockExportFile([createMockQuiz()]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { - dataTransfer: { files: [file] }, - }); - - await waitFor(() => { - expect(screen.getByText('Test Quiz')).toBeInTheDocument(); - }); - }); - - it('shows all quizzes from export file', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - createMockQuiz({ title: 'Quiz 3' }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Quiz 1')).toBeInTheDocument(); - expect(screen.getByText('Quiz 2')).toBeInTheDocument(); - expect(screen.getByText('Quiz 3')).toBeInTheDocument(); - }); - }); - - it('selects all quizzes by default', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); - }); - }); - - it('shows export date', async () => { - const mockExport = createMockExportFile([createMockQuiz()]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(/Exported/)).toBeInTheDocument(); - }); - }); - - it('shows AI badge for ai_generated quizzes', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Science' }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('AI')).toBeInTheDocument(); - expect(screen.getByText(/Science/)).toBeInTheDocument(); - }); - }); - - it('shows Manual badge for manual quizzes', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ title: 'Manual Quiz', source: 'manual' }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Manual')).toBeInTheDocument(); - }); - }); - }); - - describe('file upload - unhappy path', () => { - it('shows error for non-JSON files', async () => { - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File(['content'], 'export.txt', { type: 'text/plain' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Please select a JSON file')).toBeInTheDocument(); - }); - }); - - it('shows error when parseFile throws', async () => { - defaultProps.parseFile.mockRejectedValueOnce(new Error('Invalid export file format')); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File(['{}'], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Invalid export file format')).toBeInTheDocument(); - }); - }); - - it('shows generic error for unknown parse failures', async () => { - defaultProps.parseFile.mockRejectedValueOnce('unknown error'); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File(['{}'], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Failed to parse file')).toBeInTheDocument(); - }); - }); - - it('handles empty file drop event', async () => { - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - - await fireEvent.drop(dropZone, { dataTransfer: { files: [] } }); - - expect(defaultProps.parseFile).not.toHaveBeenCalled(); - }); - }); - - describe('quiz selection', () => { - const setupWithQuizzes = async (quizzes: ExportedQuiz[] = [createMockQuiz()]) => { - const mockExport = createMockExportFile(quizzes); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); - }); - }; - - it('toggles quiz selection on click', async () => { - await setupWithQuizzes([createMockQuiz({ title: 'Toggle Test' })]); - - const quizCard = screen.getByText('Toggle Test').closest('[class*="cursor-pointer"]')!; - - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - - await fireEvent.click(quizCard); - - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - - await fireEvent.click(quizCard); - - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - }); - - it('select all button selects all quizzes', async () => { - await setupWithQuizzes([ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - ]); - - const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; - await fireEvent.click(quiz1Card); - - expect(screen.getByText('1 of 2 selected')).toBeInTheDocument(); - - await fireEvent.click(screen.getByText('Select All')); - - expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); - }); - - it('deselect all when all selected', async () => { - await setupWithQuizzes([ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - ]); - - expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); - expect(screen.getByText('Deselect All')).toBeInTheDocument(); - - await fireEvent.click(screen.getByText('Deselect All')); - - expect(screen.getByText('0 of 2 selected')).toBeInTheDocument(); - }); - - it('back button returns to upload step', async () => { - await setupWithQuizzes(); - - await fireEvent.click(screen.getByText('Back')); - - expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); - }); - }); - - describe('import action', () => { - const setupWithQuizzes = async (quizzes: ExportedQuiz[] = [createMockQuiz()]) => { - const mockExport = createMockExportFile(quizzes); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); - }); - }; - - it('calls onImport with selected quizzes', async () => { - const quizzes = [ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - ]; - await setupWithQuizzes(quizzes); - - const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; - await fireEvent.click(quiz1Card); - - await fireEvent.click(screen.getByText(/Import 1 Quiz/)); - - expect(defaultProps.onImport).toHaveBeenCalledWith([quizzes[1]]); - }); - - it('calls onImport with all selected quizzes', async () => { - const quizzes = [ - createMockQuiz({ title: 'Quiz 1' }), - createMockQuiz({ title: 'Quiz 2' }), - ]; - await setupWithQuizzes(quizzes); - - await fireEvent.click(screen.getByText(/Import 2 Quizzes/)); - - expect(defaultProps.onImport).toHaveBeenCalledWith(quizzes); - }); - - it('disables import button when no quizzes selected', async () => { - await setupWithQuizzes([createMockQuiz({ title: 'Quiz 1' })]); - - const quizCard = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; - await fireEvent.click(quizCard); - - const importButton = screen.getByRole('button', { name: /Import 0 Quiz/ }); - expect(importButton).toBeDisabled(); - }); - - it('closes modal after successful import', async () => { - defaultProps.onImport.mockResolvedValueOnce(undefined); - await setupWithQuizzes(); - - await fireEvent.click(screen.getByText(/Import 1 Quiz/)); - - await waitFor(() => { - expect(defaultProps.onClose).toHaveBeenCalled(); - }); - }); - - it('shows importing state', async () => { - const quizzes = [createMockQuiz()]; - const mockExport = createMockExportFile(quizzes); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - const { rerender } = render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); - }); - - rerender(); - - expect(screen.getByText('Importing...')).toBeInTheDocument(); - }); - - it('disables quiz selection while importing', async () => { - const quizzes = [createMockQuiz()]; - const mockExport = createMockExportFile(quizzes); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - const { rerender } = render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); - }); - - rerender(); - - const quizCard = screen.getByText(quizzes[0].title).closest('[class*="cursor-pointer"]')!; - expect(quizCard).toHaveClass('cursor-not-allowed'); - }); - }); - - describe('modal interactions', () => { - it('calls onClose when X button clicked', async () => { - const user = userEvent.setup(); - render(); - - const closeButtons = screen.getAllByRole('button'); - const xButton = closeButtons.find(btn => btn.querySelector('svg')); - - if (xButton) { - await user.click(xButton); - expect(defaultProps.onClose).toHaveBeenCalled(); - } - }); - - it('calls onClose when backdrop clicked', async () => { - render(); - - const backdrop = document.querySelector('.fixed.inset-0'); - expect(backdrop).toBeInTheDocument(); - fireEvent.click(backdrop!); - - expect(defaultProps.onClose).toHaveBeenCalled(); - }); - - it('does not close when modal content clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Import Quizzes')); - - expect(defaultProps.onClose).not.toHaveBeenCalled(); - }); - - it('resets state when closed via onClose', async () => { - const mockExport = createMockExportFile([createMockQuiz()]); - const mockOnClose = vi.fn(); - defaultProps.parseFile.mockResolvedValue(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('Test Quiz')).toBeInTheDocument(); - }); - - await fireEvent.click(screen.getByText('Back')); - - expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); - }); - }); - - describe('drag and drop visual feedback', () => { - it('shows visual feedback on drag over', async () => { - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - - fireEvent.dragOver(dropZone); - - expect(screen.getByText('Drop file here')).toBeInTheDocument(); - }); - - it('removes visual feedback on drag leave', async () => { - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - - fireEvent.dragOver(dropZone); - expect(screen.getByText('Drop file here')).toBeInTheDocument(); - - fireEvent.dragLeave(dropZone); - expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); - }); - }); - - describe('question count display', () => { - it('shows singular for 1 question', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ title: 'Single Q Quiz' }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('1 question')).toBeInTheDocument(); - }); - }); - - it('shows plural for multiple questions', async () => { - const mockExport = createMockExportFile([ - createMockQuiz({ - title: 'Multi Q Quiz', - questions: [ - { id: 'q1', text: 'Q1', timeLimit: 20, options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }, - { id: 'q2', text: 'Q2', timeLimit: 20, options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }, - ], - }), - ]); - defaultProps.parseFile.mockResolvedValueOnce(mockExport); - - render(); - - const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; - const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); - - await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('2 questions')).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/tests/components/QuizLibrary.test.tsx b/tests/components/QuizLibrary.test.tsx deleted file mode 100644 index 850f411..0000000 --- a/tests/components/QuizLibrary.test.tsx +++ /dev/null @@ -1,522 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { QuizLibrary } from '../../components/QuizLibrary'; -import type { QuizListItem } from '../../types'; - -const createMockQuiz = (overrides?: Partial): QuizListItem => ({ - id: 'quiz-1', - title: 'Test Quiz', - source: 'manual', - questionCount: 5, - createdAt: '2024-01-15T10:00:00.000Z', - updatedAt: '2024-01-15T10:00:00.000Z', - ...overrides, -}); - -describe('QuizLibrary', () => { - const defaultProps = { - isOpen: true, - onClose: vi.fn(), - quizzes: [createMockQuiz()], - loading: false, - loadingQuizId: null, - deletingQuizId: null, - exporting: false, - error: null, - onLoadQuiz: vi.fn(), - onDeleteQuiz: vi.fn(), - onExportQuizzes: vi.fn(), - onImportClick: vi.fn(), - onRetry: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders nothing when isOpen is false', () => { - render(); - expect(screen.queryByText('My Library')).not.toBeInTheDocument(); - }); - - it('renders modal when isOpen is true', () => { - render(); - expect(screen.getByText('My Library')).toBeInTheDocument(); - }); - - it('shows quiz list', () => { - const quizzes = [ - createMockQuiz({ id: '1', title: 'Quiz 1' }), - createMockQuiz({ id: '2', title: 'Quiz 2' }), - ]; - render(); - - expect(screen.getByText('Quiz 1')).toBeInTheDocument(); - expect(screen.getByText('Quiz 2')).toBeInTheDocument(); - }); - - it('shows import button in header', () => { - render(); - expect(screen.getByTitle('Import quizzes')).toBeInTheDocument(); - }); - - it('shows selection mode toggle button', () => { - render(); - expect(screen.getByTitle('Select for export')).toBeInTheDocument(); - }); - - it('shows empty state when no quizzes', () => { - render(); - expect(screen.getByText('No saved quizzes yet')).toBeInTheDocument(); - }); - - it('shows import button in empty state', () => { - render(); - expect(screen.getByText('Import Quizzes')).toBeInTheDocument(); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Loading your quizzes...')).toBeInTheDocument(); - }); - - it('shows error state with retry button', () => { - render(); - expect(screen.getByText('Failed to load quizzes')).toBeInTheDocument(); - expect(screen.getByText('Try Again')).toBeInTheDocument(); - }); - }); - - describe('selection mode', () => { - it('enters selection mode on toggle click', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - expect(screen.getByTitle('Cancel selection')).toBeInTheDocument(); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - }); - - it('exits selection mode on toggle click', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - - await user.click(screen.getByTitle('Cancel selection')); - expect(screen.getByText('Select a quiz to play')).toBeInTheDocument(); - }); - - it('shows checkboxes in selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const checkboxes = document.querySelectorAll('[class*="rounded-lg border-2"]'); - expect(checkboxes.length).toBeGreaterThan(0); - }); - - it('selects quiz on click in selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - }); - - it('deselects quiz on second click', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - - await user.click(quizCard); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - }); - - it('shows Select All button in selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - expect(screen.getByText('Select All')).toBeInTheDocument(); - }); - - it('selects all quizzes on Select All click', async () => { - const user = userEvent.setup(); - const quizzes = [ - createMockQuiz({ id: '1', title: 'Quiz 1' }), - createMockQuiz({ id: '2', title: 'Quiz 2' }), - ]; - render(); - - await user.click(screen.getByTitle('Select for export')); - expect(screen.getByText('0 of 2 selected')).toBeInTheDocument(); - - await user.click(screen.getByText('Select All')); - expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); - }); - - it('shows Deselect All when all selected', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - expect(screen.getByText('Deselect All')).toBeInTheDocument(); - }); - - it('deselects all on Deselect All click', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - - await user.click(screen.getByText('Deselect All')); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - }); - - it('clears selection when exiting selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); - - await user.click(screen.getByTitle('Cancel selection')); - - await user.click(screen.getByTitle('Select for export')); - expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); - }); - - it('hides play and delete buttons in selection mode', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByTitle('Delete quiz')).toBeInTheDocument(); - - await user.click(screen.getByTitle('Select for export')); - - expect(screen.queryByTitle('Delete quiz')).not.toBeInTheDocument(); - }); - }); - - describe('export functionality', () => { - it('shows export footer in selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - expect(screen.getByText('Cancel')).toBeInTheDocument(); - expect(screen.getByText(/Export 0 Quiz/)).toBeInTheDocument(); - }); - - it('export button is disabled when no selection', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const exportButton = screen.getByRole('button', { name: /Export 0 Quiz/ }); - expect(exportButton).toBeDisabled(); - }); - - it('export button is enabled with selection', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - const exportButton = screen.getByRole('button', { name: /Export 1 Quiz/ }); - expect(exportButton).not.toBeDisabled(); - }); - - it('calls onExportQuizzes with selected IDs', async () => { - const user = userEvent.setup(); - const quizzes = [ - createMockQuiz({ id: 'id-1', title: 'Quiz 1' }), - createMockQuiz({ id: 'id-2', title: 'Quiz 2' }), - ]; - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; - await user.click(quiz1Card); - - await user.click(screen.getByRole('button', { name: /Export 1 Quiz/ })); - - expect(defaultProps.onExportQuizzes).toHaveBeenCalledWith(['id-1']); - }); - - it('exports multiple selected quizzes', async () => { - const user = userEvent.setup(); - const quizzes = [ - createMockQuiz({ id: 'id-1', title: 'Quiz 1' }), - createMockQuiz({ id: 'id-2', title: 'Quiz 2' }), - ]; - render(); - - await user.click(screen.getByTitle('Select for export')); - await user.click(screen.getByText('Select All')); - await user.click(screen.getByRole('button', { name: /Export 2 Quizzes/ })); - - expect(defaultProps.onExportQuizzes).toHaveBeenCalledWith(['id-1', 'id-2']); - }); - - it('exits selection mode after export', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - await user.click(screen.getByRole('button', { name: /Export 1 Quiz/ })); - - await waitFor(() => { - expect(screen.getByText('Select a quiz to play')).toBeInTheDocument(); - }); - }); - - it('cancel button exits selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - - await user.click(screen.getByText('Cancel')); - - expect(screen.getByText('Select a quiz to play')).toBeInTheDocument(); - }); - - it('shows exporting state', async () => { - const user = userEvent.setup(); - const { rerender } = render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - rerender(); - - expect(screen.getByText('Exporting...')).toBeInTheDocument(); - }); - - it('disables export button while exporting', async () => { - const user = userEvent.setup(); - const { rerender } = render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - rerender(); - - const exportButton = screen.getByRole('button', { name: /Exporting/ }); - expect(exportButton).toBeDisabled(); - }); - }); - - describe('import functionality', () => { - it('calls onImportClick when import button clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Import quizzes')); - - expect(defaultProps.onImportClick).toHaveBeenCalled(); - }); - - it('calls onImportClick from empty state', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Import Quizzes')); - - expect(defaultProps.onImportClick).toHaveBeenCalled(); - }); - }); - - describe('normal mode interactions', () => { - it('calls onLoadQuiz when quiz clicked in normal mode', async () => { - const user = userEvent.setup(); - render(); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - expect(defaultProps.onLoadQuiz).toHaveBeenCalledWith('quiz-1'); - }); - - it('does not call onLoadQuiz in selection mode', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Select for export')); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; - await user.click(quizCard); - - expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled(); - }); - }); - - describe('quiz display', () => { - it('shows AI badge for ai_generated quizzes', () => { - const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Science' })]; - render(); - - expect(screen.getByText('AI')).toBeInTheDocument(); - }); - - it('shows Manual badge for manual quizzes', () => { - const quizzes = [createMockQuiz({ source: 'manual' })]; - render(); - - expect(screen.getByText('Manual')).toBeInTheDocument(); - }); - - it('shows question count', () => { - const quizzes = [createMockQuiz({ questionCount: 10 })]; - render(); - - expect(screen.getByText('10 questions')).toBeInTheDocument(); - }); - - it('shows singular for 1 question', () => { - const quizzes = [createMockQuiz({ questionCount: 1 })]; - render(); - - expect(screen.getByText('1 question')).toBeInTheDocument(); - }); - - it('shows aiTopic for AI quizzes', () => { - const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Space' })]; - render(); - - expect(screen.getByText(/Topic: Space/)).toBeInTheDocument(); - }); - }); - - describe('delete functionality', () => { - it('shows delete confirmation on delete click', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Delete quiz')); - - expect(screen.getByText('Confirm')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - - it('calls onDeleteQuiz on confirm', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Delete quiz')); - await user.click(screen.getByText('Confirm')); - - expect(defaultProps.onDeleteQuiz).toHaveBeenCalledWith('quiz-1'); - }); - - it('hides confirmation on cancel', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Delete quiz')); - expect(screen.getByText('Confirm')).toBeInTheDocument(); - - await user.click(screen.getByText('Cancel')); - - expect(screen.queryByText('Confirm')).not.toBeInTheDocument(); - }); - }); - - describe('disabled states', () => { - it('hides export/import buttons when loading', () => { - render(); - - expect(screen.queryByTitle('Import quizzes')).not.toBeInTheDocument(); - expect(screen.queryByTitle('Select for export')).not.toBeInTheDocument(); - }); - - it('shows quiz cards with reduced opacity when loadingQuizId is set', async () => { - render(); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!; - expect(quizCard).toHaveClass('opacity-70'); - }); - - it('shows quiz cards with reduced opacity when deletingQuizId is set', async () => { - render(); - - const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!; - expect(quizCard).toHaveClass('opacity-70'); - }); - }); - - describe('modal interactions', () => { - it('calls onClose when backdrop clicked', async () => { - render(); - - const backdrop = document.querySelector('.fixed.inset-0'); - expect(backdrop).toBeInTheDocument(); - fireEvent.click(backdrop!); - - expect(defaultProps.onClose).toHaveBeenCalled(); - }); - - it('does not close when modal content clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('My Library')); - - expect(defaultProps.onClose).not.toHaveBeenCalled(); - }); - - it('calls onRetry when Try Again clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Try Again')); - - expect(defaultProps.onRetry).toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/hooks/useAuthenticatedFetch.test.tsx b/tests/hooks/useAuthenticatedFetch.test.tsx index f6fed68..041697e 100644 --- a/tests/hooks/useAuthenticatedFetch.test.tsx +++ b/tests/hooks/useAuthenticatedFetch.test.tsx @@ -18,9 +18,6 @@ vi.mock('react-oidc-context', () => ({ useAuth: () => mockAuth, })); -// Get the API URL that the hook will actually use -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - const originalFetch = global.fetch; describe('useAuthenticatedFetch', () => { @@ -64,7 +61,7 @@ describe('useAuthenticatedFetch', () => { }); expect(global.fetch).toHaveBeenCalledWith( - `${API_URL}/api/test`, + 'http://localhost:3001/api/test', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer valid-token', diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx index 7a556a7..430bc70 100644 --- a/tests/hooks/useQuizLibrary.test.tsx +++ b/tests/hooks/useQuizLibrary.test.tsx @@ -597,574 +597,4 @@ describe('useQuizLibrary', () => { } }); }); - - describe('exportQuizzes', () => { - let mockCreateObjectURL: ReturnType; - let mockRevokeObjectURL: ReturnType; - let mockClick: ReturnType; - let capturedBlob: Blob | null = null; - let originalCreateElement: typeof document.createElement; - let originalCreateObjectURL: typeof URL.createObjectURL; - let originalRevokeObjectURL: typeof URL.revokeObjectURL; - - beforeEach(() => { - capturedBlob = null; - mockCreateObjectURL = vi.fn((blob: Blob) => { - capturedBlob = blob; - return 'blob:test-url'; - }); - mockRevokeObjectURL = vi.fn(); - mockClick = vi.fn(); - - originalCreateObjectURL = global.URL.createObjectURL; - originalRevokeObjectURL = global.URL.revokeObjectURL; - global.URL.createObjectURL = mockCreateObjectURL as typeof URL.createObjectURL; - global.URL.revokeObjectURL = mockRevokeObjectURL as typeof URL.revokeObjectURL; - - originalCreateElement = document.createElement.bind(document); - vi.spyOn(document, 'createElement').mockImplementation((tag) => { - if (tag === 'a') { - return { - href: '', - download: '', - click: mockClick, - } as unknown as HTMLAnchorElement; - } - return originalCreateElement(tag); - }); - - vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node); - vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => node); - }); - - afterEach(() => { - global.URL.createObjectURL = originalCreateObjectURL; - global.URL.revokeObjectURL = originalRevokeObjectURL; - vi.restoreAllMocks(); - }); - - it('exports selected quizzes as JSON file', async () => { - const mockQuizData = { - id: 'quiz-1', - title: 'Test Quiz', - source: 'manual', - questions: [ - { id: 'q1', text: 'Question 1', timeLimit: 20, options: [] }, - ], - }; - - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockQuizData), - }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.exportQuizzes(['quiz-1']); - }); - - expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-1'); - expect(mockCreateObjectURL).toHaveBeenCalled(); - expect(mockClick).toHaveBeenCalled(); - expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url'); - expect(result.current.exporting).toBe(false); - }); - - it('exports multiple quizzes', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questions: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'ai_generated', aiTopic: 'Science', questions: [] }), - }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.exportQuizzes(['quiz-1', 'quiz-2']); - }); - - expect(mockAuthFetch).toHaveBeenCalledTimes(2); - expect(mockCreateObjectURL).toHaveBeenCalled(); - }); - - it('sets exporting to true during export', async () => { - let resolvePromise: (value: unknown) => void; - const pendingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockAuthFetch.mockReturnValueOnce(pendingPromise); - - const { result } = renderHook(() => useQuizLibrary()); - - act(() => { - result.current.exportQuizzes(['quiz-1']); - }); - - await waitFor(() => { - expect(result.current.exporting).toBe(true); - }); - - await act(async () => { - resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'quiz-1', title: 'Q', source: 'manual', questions: [] }) }); - }); - - await waitFor(() => { - expect(result.current.exporting).toBe(false); - }); - }); - - it('handles empty quiz IDs array', async () => { - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.exportQuizzes([]); - }); - - expect(mockAuthFetch).not.toHaveBeenCalled(); - expect(mockCreateObjectURL).not.toHaveBeenCalled(); - }); - - it('skips quizzes that fail to load', async () => { - mockAuthFetch - .mockResolvedValueOnce({ ok: false, status: 404 }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questions: [] }), - }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.exportQuizzes(['quiz-1', 'quiz-2']); - }); - - expect(mockCreateObjectURL).toHaveBeenCalled(); - expect(capturedBlob).toBeInstanceOf(Blob); - }); - - it('handles network error during export', async () => { - mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.exportQuizzes(['quiz-1']); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toBe('Network error'); - } - - expect(result.current.exporting).toBe(false); - }); - -it('creates blob with correct mime type', async () => { - const mockQuizData = { - id: 'quiz-1', - title: 'Test Quiz', - source: 'ai_generated', - aiTopic: 'History', - gameConfig: { shuffleQuestions: true }, - questions: [ - { - id: 'q1', - text: 'What year?', - timeLimit: 30, - options: [ - { text: '1990', isCorrect: false, shape: 'triangle', color: 'red' }, - { text: '2000', isCorrect: true, shape: 'diamond', color: 'blue' }, - ], - }, - ], - }; - - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockQuizData), - }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.exportQuizzes(['quiz-1']); - }); - - expect(capturedBlob).not.toBeNull(); - expect(capturedBlob!.type).toBe('application/json'); - }); - }); - - describe('parseImportFile', () => { - const originalText = File.prototype.text; - - beforeEach(() => { - // JSDOM doesn't implement File.prototype.text, so define it using FileReader - File.prototype.text = function() { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsText(this); - }); - }; - }); - - afterEach(() => { - File.prototype.text = originalText; - }); - - it('parses valid export file', async () => { - const validExportData = { - version: 1, - exportedAt: '2024-01-01T00:00:00.000Z', - quizzes: [ - { title: 'Quiz 1', source: 'manual', questions: [] }, - ], - }; - - const file = new File( - [JSON.stringify(validExportData)], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - let parsed; - await act(async () => { - parsed = await result.current.parseImportFile(file); - }); - - expect(parsed).toEqual(validExportData); - }); - - it('rejects file without version field', async () => { - const invalidData = { - exportedAt: '2024-01-01T00:00:00.000Z', - quizzes: [], - }; - - const file = new File( - [JSON.stringify(invalidData)], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.parseImportFile(file); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toBe('Invalid export file format'); - } - }); - - it('rejects file without quizzes array', async () => { - const invalidData = { - version: 1, - exportedAt: '2024-01-01T00:00:00.000Z', - }; - - const file = new File( - [JSON.stringify(invalidData)], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.parseImportFile(file); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toBe('Invalid export file format'); - } - }); - - it('rejects file with quizzes as non-array', async () => { - const invalidData = { - version: 1, - exportedAt: '2024-01-01T00:00:00.000Z', - quizzes: 'not-an-array', - }; - - const file = new File( - [JSON.stringify(invalidData)], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.parseImportFile(file); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toBe('Invalid export file format'); - } - }); - - it('rejects invalid JSON', async () => { - const file = new File( - ['not valid json {'], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.parseImportFile(file); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect(e).toBeInstanceOf(SyntaxError); - } - }); - - it('handles empty file', async () => { - const file = new File( - [''], - 'export.json', - { type: 'application/json' } - ); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.parseImportFile(file); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect(e).toBeInstanceOf(SyntaxError); - } - }); - }); - - describe('importQuizzes', () => { - it('imports single quiz successfully', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ id: 'new-quiz-1' }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve([]), - }); - - const { result } = renderHook(() => useQuizLibrary()); - - const quizzesToImport = [ - { - title: 'Imported Quiz', - source: 'manual' as const, - questions: [ - { - id: 'q1', - text: 'Question?', - timeLimit: 20, - options: [ - { text: 'A', isCorrect: true, shape: 'triangle' as const, color: 'red' as const }, - { text: 'B', isCorrect: false, shape: 'diamond' as const, color: 'blue' as const }, - ], - }, - ], - }, - ]; - - await act(async () => { - await result.current.importQuizzes(quizzesToImport); - }); - - expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes', expect.objectContaining({ - method: 'POST', - })); - expect(result.current.importing).toBe(false); - }); - - it('imports multiple quizzes sequentially', async () => { - mockAuthFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q2' }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); - - const { result } = renderHook(() => useQuizLibrary()); - - const quizzesToImport = [ - { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, - { title: 'Quiz 2', source: 'ai_generated' as const, aiTopic: 'Science', questions: createMockQuiz().questions }, - ]; - - await act(async () => { - await result.current.importQuizzes(quizzesToImport); - }); - - expect(mockAuthFetch).toHaveBeenCalledTimes(3); - }); - - it('sets importing to true during import', async () => { - let resolvePromise: (value: unknown) => void; - const pendingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockAuthFetch.mockReturnValueOnce(pendingPromise); - - const { result } = renderHook(() => useQuizLibrary()); - - act(() => { - result.current.importQuizzes([ - { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, - ]); - }); - - await waitFor(() => { - expect(result.current.importing).toBe(true); - }); - - await act(async () => { - resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'id' }) }); - }); - }); - - it('handles empty quizzes array', async () => { - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.importQuizzes([]); - }); - - expect(mockAuthFetch).not.toHaveBeenCalled(); - }); - - it('reports partial success when some imports fail', async () => { - mockAuthFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) - .mockResolvedValueOnce({ ok: false, status: 400 }); - - const { result } = renderHook(() => useQuizLibrary()); - - const quizzesToImport = [ - { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, - { title: 'Quiz 2', source: 'manual' as const, questions: createMockQuiz().questions }, - ]; - - try { - await act(async () => { - await result.current.importQuizzes(quizzesToImport); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toContain('Invalid quiz data'); - } - - expect(result.current.importing).toBe(false); - }); - - it('refreshes quiz list after successful import', async () => { - const mockQuizList = [{ id: 'q1', title: 'Quiz 1', source: 'manual', questionCount: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }]; - - mockAuthFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizList) }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.importQuizzes([ - { title: 'Quiz 1', source: 'manual', questions: createMockQuiz().questions }, - ]); - }); - - expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/quizzes'); - expect(result.current.quizzes).toEqual(mockQuizList); - }); - - it('preserves aiTopic for AI-generated quizzes', async () => { - mockAuthFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); - - const { result } = renderHook(() => useQuizLibrary()); - - await act(async () => { - await result.current.importQuizzes([ - { title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Space', questions: createMockQuiz().questions }, - ]); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.source).toBe('ai_generated'); - expect(body.aiTopic).toBe('Space'); - }); - - it('preserves game config during import', async () => { - mockAuthFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); - - const { result } = renderHook(() => useQuizLibrary()); - - const config = { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: false, - randomNamesEnabled: true, - streakBonusEnabled: true, - streakThreshold: 5, - streakMultiplier: 1.5, - comebackBonusEnabled: false, - comebackBonusPoints: 100, - penaltyForWrongAnswer: true, - penaltyPercent: 10, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 25, - }; - - await act(async () => { - await result.current.importQuizzes([ - { title: 'Quiz', source: 'manual', config, questions: createMockQuiz().questions }, - ]); - }); - - const [, options] = mockAuthFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.gameConfig).toEqual(config); - }); - - it('handles network error during import', async () => { - mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useQuizLibrary()); - - try { - await act(async () => { - await result.current.importQuizzes([ - { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, - ]); - }); - expect.fail('Should have thrown'); - } catch (e) { - expect((e as Error).message).toBe('Network error'); - } - - expect(result.current.importing).toBe(false); - }); - }); }); diff --git a/types.ts b/types.ts index 4cf6c2e..46064b1 100644 --- a/types.ts +++ b/types.ts @@ -167,20 +167,6 @@ export interface Player { } // Network Types -export interface ExportedQuiz { - title: string; - source: QuizSource; - aiTopic?: string; - config?: GameConfig; - questions: Question[]; -} - -export interface QuizExportFile { - version: 1; - exportedAt: string; - quizzes: ExportedQuiz[]; -} - export type NetworkMessage = | { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } } | { type: 'WELCOME'; payload: {