1485 lines
44 KiB
TypeScript
1485 lines
44 KiB
TypeScript
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
|
import type { Quiz, QuizListItem } from '../../types';
|
|
|
|
const mockAuthFetch = vi.fn();
|
|
|
|
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
|
useAuthenticatedFetch: () => ({
|
|
authFetch: mockAuthFetch,
|
|
isAuthenticated: true,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
|
title: 'Test Quiz',
|
|
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' },
|
|
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
|
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
|
],
|
|
},
|
|
],
|
|
...overrides,
|
|
});
|
|
|
|
describe('useQuizLibrary', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAuthFetch.mockReset();
|
|
});
|
|
|
|
describe('fetchQuizzes', () => {
|
|
it('fetches and stores quizzes', async () => {
|
|
const mockQuizzes = [
|
|
{ id: '1', title: 'Quiz 1', source: 'manual', questionCount: 5, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
|
{ id: '2', title: 'Quiz 2', source: 'ai_generated', questionCount: 10, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
|
|
];
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockQuizzes),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
expect(result.current.quizzes).toEqual(mockQuizzes);
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it('sets loading to true during fetch', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
const pendingPromise = new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
act(() => {
|
|
result.current.fetchQuizzes();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(true);
|
|
});
|
|
|
|
await act(async () => {
|
|
resolvePromise!({ ok: true, json: () => Promise.resolve([]) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('handles 500 server error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
expect(result.current.error).toBe('Server error. Please try again.');
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
it('handles generic error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
expect(result.current.error).toBe('Failed to load your quizzes.');
|
|
});
|
|
});
|
|
|
|
describe('loadQuiz', () => {
|
|
it('loads and returns a quiz', async () => {
|
|
const mockQuiz = {
|
|
id: 'quiz-123',
|
|
title: 'Loaded Quiz',
|
|
source: 'manual',
|
|
questions: [],
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
};
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockQuiz),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
let loadedQuiz;
|
|
await act(async () => {
|
|
loadedQuiz = await result.current.loadQuiz('quiz-123');
|
|
});
|
|
|
|
expect(loadedQuiz).toEqual(mockQuiz);
|
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123');
|
|
});
|
|
|
|
it('sets and clears loadingQuizId', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
const pendingPromise = new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
act(() => {
|
|
result.current.loadQuiz('quiz-123');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loadingQuizId).toBe('quiz-123');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolvePromise!({ ok: true, json: () => Promise.resolve({}) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loadingQuizId).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('handles 404 not found', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.loadQuiz('non-existent');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz not found. It may have been deleted.');
|
|
}
|
|
|
|
expect(result.current.loadingQuizId).toBeNull();
|
|
});
|
|
|
|
it('handles generic error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.loadQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to load quiz.');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('saveQuiz', () => {
|
|
it('saves a quiz and returns the ID', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'new-quiz-id' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
let savedId;
|
|
await act(async () => {
|
|
savedId = await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
|
|
expect(savedId).toBe('new-quiz-id');
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it('sends correct request body for manual quiz', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'id' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({ title: 'Manual Quiz' });
|
|
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
|
|
const [, options] = mockAuthFetch.mock.calls[0];
|
|
const body = JSON.parse(options.body);
|
|
expect(body.title).toBe('Manual Quiz');
|
|
expect(body.source).toBe('manual');
|
|
expect(body.aiTopic).toBeUndefined();
|
|
});
|
|
|
|
it('sends aiTopic for AI generated quiz', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'id' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'ai_generated', 'Science');
|
|
});
|
|
|
|
const [, options] = mockAuthFetch.mock.calls[0];
|
|
const body = JSON.parse(options.body);
|
|
expect(body.source).toBe('ai_generated');
|
|
expect(body.aiTopic).toBe('Science');
|
|
});
|
|
|
|
it('includes gameConfig when present', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'id' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({
|
|
config: {
|
|
shuffleQuestions: true,
|
|
shuffleAnswers: false,
|
|
hostParticipates: true,
|
|
randomNamesEnabled: false,
|
|
streakBonusEnabled: false,
|
|
streakThreshold: 3,
|
|
streakMultiplier: 1.1,
|
|
comebackBonusEnabled: false,
|
|
comebackBonusPoints: 50,
|
|
penaltyForWrongAnswer: false,
|
|
penaltyPercent: 25,
|
|
firstCorrectBonusEnabled: false,
|
|
firstCorrectBonusPoints: 50,
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
|
|
const [, options] = mockAuthFetch.mock.calls[0];
|
|
const body = JSON.parse(options.body);
|
|
expect(body.gameConfig.shuffleQuestions).toBe(true);
|
|
});
|
|
|
|
it('rejects quiz without title', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({ title: '' });
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz must have a title');
|
|
}
|
|
|
|
expect(mockAuthFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects quiz with whitespace-only title', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({ title: ' ' });
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz must have a title');
|
|
}
|
|
});
|
|
|
|
it('rejects quiz without questions', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({ questions: [] });
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz must have at least one question');
|
|
}
|
|
});
|
|
|
|
it('rejects quiz with question without text', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({
|
|
questions: [{
|
|
id: 'q1',
|
|
text: '',
|
|
timeLimit: 20,
|
|
options: [
|
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
|
],
|
|
}],
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('All questions must have text');
|
|
}
|
|
});
|
|
|
|
it('rejects question with less than 2 options', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({
|
|
questions: [{
|
|
id: 'q1',
|
|
text: 'Question?',
|
|
timeLimit: 20,
|
|
options: [
|
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
|
],
|
|
}],
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Each question must have at least 2 options');
|
|
}
|
|
});
|
|
|
|
it('rejects question without correct answer', async () => {
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz({
|
|
questions: [{
|
|
id: 'q1',
|
|
text: 'Question?',
|
|
timeLimit: 20,
|
|
options: [
|
|
{ text: 'A', isCorrect: false, shape: 'triangle', color: 'red' },
|
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
|
],
|
|
}],
|
|
});
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Each question must have a correct answer');
|
|
}
|
|
});
|
|
|
|
it('prevents double save when already saving', async () => {
|
|
let resolveFirstSave: (value: unknown) => void;
|
|
const firstSavePromise = new Promise((resolve) => {
|
|
resolveFirstSave = resolve;
|
|
});
|
|
mockAuthFetch.mockReturnValueOnce(firstSavePromise);
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
let firstSaveResult: Promise<string>;
|
|
act(() => {
|
|
firstSaveResult = result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.saving).toBe(true);
|
|
});
|
|
|
|
let secondSaveError: Error | null = null;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
} catch (e) {
|
|
secondSaveError = e as Error;
|
|
}
|
|
});
|
|
|
|
expect(secondSaveError).not.toBeNull();
|
|
expect(secondSaveError!.message).toBe('Save already in progress');
|
|
expect(mockAuthFetch).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
resolveFirstSave!({ ok: true, json: () => Promise.resolve({ id: 'test-id' }) });
|
|
await firstSaveResult!;
|
|
});
|
|
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it('handles 400 validation error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Invalid quiz data. Please check and try again.');
|
|
}
|
|
});
|
|
|
|
it('handles generic server error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.saveQuiz(quiz, 'manual');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to save quiz.');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('deleteQuiz', () => {
|
|
it('handles 404 not found', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.deleteQuiz('non-existent');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz not found.');
|
|
}
|
|
});
|
|
|
|
it('handles generic server error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.deleteQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to delete quiz.');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('updateQuizConfig', () => {
|
|
it('throws error on failure', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.updateQuizConfig('quiz-123', {} as any);
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to update config');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('updateQuiz', () => {
|
|
it('handles generic server error', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.updateQuiz('quiz-123', quiz);
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to update quiz.');
|
|
}
|
|
});
|
|
|
|
it('handles empty quiz ID', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
const quiz = createMockQuiz();
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.updateQuiz('', quiz);
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz not found.');
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('shareQuiz', () => {
|
|
it('shares a quiz and returns the share token', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ shareToken: 'abc123token' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
let token;
|
|
await act(async () => {
|
|
token = await result.current.shareQuiz('quiz-123');
|
|
});
|
|
|
|
expect(token).toBe('abc123token');
|
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
|
|
method: 'POST',
|
|
});
|
|
});
|
|
|
|
it('sets and clears sharingQuizId during share operation', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
const pendingPromise = new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
act(() => {
|
|
result.current.shareQuiz('quiz-123');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sharingQuizId).toBe('quiz-123');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolvePromise!({ ok: true, json: () => Promise.resolve({ shareToken: 'token' }) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('updates quiz in local state with shareToken and isShared', async () => {
|
|
const initialQuizzes: QuizListItem[] = [
|
|
{ id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, isShared: false, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
|
];
|
|
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'newtoken' }) });
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.shareQuiz('quiz-123');
|
|
});
|
|
|
|
expect(result.current.quizzes[0].shareToken).toBe('newtoken');
|
|
expect(result.current.quizzes[0].isShared).toBe(true);
|
|
});
|
|
|
|
it('handles 404 not found when sharing non-existent quiz', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.shareQuiz('non-existent');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz not found.');
|
|
}
|
|
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
|
|
it('handles generic server error when sharing', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.shareQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to share quiz.');
|
|
}
|
|
});
|
|
|
|
it('handles network error when sharing', async () => {
|
|
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.shareQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Network error');
|
|
}
|
|
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
|
|
it('returns existing token if quiz is already shared', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ shareToken: 'existing-token' }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
let token;
|
|
await act(async () => {
|
|
token = await result.current.shareQuiz('quiz-123');
|
|
});
|
|
|
|
expect(token).toBe('existing-token');
|
|
});
|
|
});
|
|
|
|
describe('unshareQuiz', () => {
|
|
it('unshares a quiz successfully', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('quiz-123');
|
|
});
|
|
|
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
|
|
method: 'DELETE',
|
|
});
|
|
});
|
|
|
|
it('sets and clears sharingQuizId during unshare operation', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
const pendingPromise = new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
act(() => {
|
|
result.current.unshareQuiz('quiz-123');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sharingQuizId).toBe('quiz-123');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true }) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('updates quiz in local state to remove shareToken and set isShared false', async () => {
|
|
const initialQuizzes: QuizListItem[] = [
|
|
{ id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, shareToken: 'oldtoken', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
|
];
|
|
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
expect(result.current.quizzes[0].isShared).toBe(true);
|
|
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('quiz-123');
|
|
});
|
|
|
|
expect(result.current.quizzes[0].shareToken).toBeUndefined();
|
|
expect(result.current.quizzes[0].isShared).toBe(false);
|
|
});
|
|
|
|
it('handles 404 not found when unsharing non-existent quiz', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('non-existent');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Quiz not found.');
|
|
}
|
|
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
|
|
it('handles generic server error when unsharing', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Failed to stop sharing quiz.');
|
|
}
|
|
});
|
|
|
|
it('handles network error when unsharing', async () => {
|
|
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
try {
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('quiz-123');
|
|
});
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect((e as Error).message).toBe('Network error');
|
|
}
|
|
|
|
expect(result.current.sharingQuizId).toBeNull();
|
|
});
|
|
|
|
it('does not affect other quizzes in the list when unsharing', async () => {
|
|
const initialQuizzes: QuizListItem[] = [
|
|
{ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questionCount: 5, shareToken: 'token1', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
|
{ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questionCount: 3, shareToken: 'token2', isShared: true, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
|
|
];
|
|
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.unshareQuiz('quiz-1');
|
|
});
|
|
|
|
expect(result.current.quizzes[0].isShared).toBe(false);
|
|
expect(result.current.quizzes[1].isShared).toBe(true);
|
|
expect(result.current.quizzes[1].shareToken).toBe('token2');
|
|
});
|
|
});
|
|
|
|
describe('fetchQuizzes with sharing info', () => {
|
|
it('fetches quizzes with isShared and shareToken fields', async () => {
|
|
const mockQuizzes: QuizListItem[] = [
|
|
{ id: '1', title: 'Shared Quiz', source: 'manual', questionCount: 5, shareToken: 'token123', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
|
{ id: '2', title: 'Private Quiz', source: 'ai_generated', questionCount: 10, isShared: false, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
|
|
];
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockQuizzes),
|
|
});
|
|
|
|
const { result } = renderHook(() => useQuizLibrary());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchQuizzes();
|
|
});
|
|
|
|
expect(result.current.quizzes[0].isShared).toBe(true);
|
|
expect(result.current.quizzes[0].shareToken).toBe('token123');
|
|
expect(result.current.quizzes[1].isShared).toBe(false);
|
|
expect(result.current.quizzes[1].shareToken).toBeUndefined();
|
|
});
|
|
});
|
|
});
|