Add import/export

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 22:27:17 -07:00
commit 667c490537
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
9 changed files with 2261 additions and 54 deletions

View file

@ -0,0 +1,539 @@
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>): 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(<ImportQuizzesModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Import Quizzes')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<ImportQuizzesModal {...defaultProps} />);
expect(screen.getByText('Import Quizzes')).toBeInTheDocument();
});
it('shows upload step by default', () => {
render(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} importing={true} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} importing={true} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} onClose={mockOnClose} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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(<ImportQuizzesModal {...defaultProps} />);
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();
});
});
});
});

View file

@ -0,0 +1,522 @@
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>): 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(<QuizLibrary {...defaultProps} isOpen={false} />);
expect(screen.queryByText('My Library')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Quiz 1')).toBeInTheDocument();
expect(screen.getByText('Quiz 2')).toBeInTheDocument();
});
it('shows import button in header', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Import quizzes')).toBeInTheDocument();
});
it('shows selection mode toggle button', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Select for export')).toBeInTheDocument();
});
it('shows empty state when no quizzes', () => {
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
expect(screen.getByText('No saved quizzes yet')).toBeInTheDocument();
});
it('shows import button in empty state', () => {
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
expect(screen.getByText('Import Quizzes')).toBeInTheDocument();
});
it('shows loading state', () => {
render(<QuizLibrary {...defaultProps} loading={true} />);
expect(screen.getByText('Loading your quizzes...')).toBeInTheDocument();
});
it('shows error state with retry button', () => {
render(<QuizLibrary {...defaultProps} error="Failed to load quizzes" />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
rerender(<QuizLibrary {...defaultProps} exporting={true} />);
expect(screen.getByText('Exporting...')).toBeInTheDocument();
});
it('disables export button while exporting', async () => {
const user = userEvent.setup();
const { rerender } = render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
rerender(<QuizLibrary {...defaultProps} exporting={true} />);
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(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Import quizzes'));
expect(defaultProps.onImportClick).toHaveBeenCalled();
});
it('calls onImportClick from empty state', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('AI')).toBeInTheDocument();
});
it('shows Manual badge for manual quizzes', () => {
const quizzes = [createMockQuiz({ source: 'manual' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Manual')).toBeInTheDocument();
});
it('shows question count', () => {
const quizzes = [createMockQuiz({ questionCount: 10 })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('10 questions')).toBeInTheDocument();
});
it('shows singular for 1 question', () => {
const quizzes = [createMockQuiz({ questionCount: 1 })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('1 question')).toBeInTheDocument();
});
it('shows aiTopic for AI quizzes', () => {
const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Space' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText(/Topic: Space/)).toBeInTheDocument();
});
});
describe('delete functionality', () => {
it('shows delete confirmation on delete click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} loading={true} />);
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(<QuizLibrary {...defaultProps} loadingQuizId="quiz-1" />);
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(<QuizLibrary {...defaultProps} deletingQuizId="quiz-1" />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} />);
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(<QuizLibrary {...defaultProps} error="Failed to load" />);
await user.click(screen.getByText('Try Again'));
expect(defaultProps.onRetry).toHaveBeenCalled();
});
});
});

View file

@ -18,6 +18,9 @@ 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', () => {
@ -61,7 +64,7 @@ describe('useAuthenticatedFetch', () => {
});
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/test',
`${API_URL}/api/test`,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer valid-token',

View file

@ -597,4 +597,574 @@ describe('useQuizLibrary', () => {
}
});
});
describe('exportQuizzes', () => {
let mockCreateObjectURL: ReturnType<typeof vi.fn>;
let mockRevokeObjectURL: ReturnType<typeof vi.fn>;
let mockClick: ReturnType<typeof vi.fn>;
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);
});
});
});