Add tests and edit works
This commit is contained in:
parent
bfbba7b5ab
commit
bc4b0e2df7
12 changed files with 2415 additions and 20 deletions
200
tests/components/SaveOptionsModal.test.tsx
Normal file
200
tests/components/SaveOptionsModal.test.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SaveOptionsModal } from '../../components/SaveOptionsModal';
|
||||
|
||||
describe('SaveOptionsModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onSaveNew: vi.fn(),
|
||||
onOverwrite: vi.fn(),
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save Quiz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays explanation text', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
expect(screen.getByText(/loaded from your library/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows update existing button', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
expect(screen.getByText('Update existing quiz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows save as new button', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save as new quiz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows close button', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
expect(closeButtons.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions - happy path', () => {
|
||||
it('calls onOverwrite when update existing is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Update existing quiz'));
|
||||
|
||||
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onSaveNew when save as new is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save as new quiz'));
|
||||
|
||||
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when close button (X) is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: '' });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when backdrop is clicked', async () => {
|
||||
render(<SaveOptionsModal {...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 is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save Quiz'));
|
||||
|
||||
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions - unhappy path / edge cases', () => {
|
||||
it('disables buttons when isSaving is true', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||
|
||||
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||
const saveNewButton = screen.getByText('Save as new quiz').closest('button');
|
||||
|
||||
expect(updateButton).toBeDisabled();
|
||||
expect(saveNewButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not call onOverwrite when disabled and clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||
|
||||
const button = screen.getByText('Update existing quiz').closest('button')!;
|
||||
await user.click(button);
|
||||
|
||||
expect(defaultProps.onOverwrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onSaveNew when disabled and clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||
|
||||
const button = screen.getByText('Save as new quiz').closest('button')!;
|
||||
await user.click(button);
|
||||
|
||||
expect(defaultProps.onSaveNew).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles rapid clicks gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
const button = screen.getByText('Update existing quiz');
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('remains functional after re-opening', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Update existing quiz'));
|
||||
expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<SaveOptionsModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<SaveOptionsModal {...defaultProps} isOpen={true} />);
|
||||
await user.click(screen.getByText('Save as new quiz'));
|
||||
expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('all interactive elements are focusable', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).not.toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('buttons have descriptive text for screen readers', () => {
|
||||
render(<SaveOptionsModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Overwrite the original with your changes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep the original and create a copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions', () => {
|
||||
it('transitions from not saving to saving correctly', async () => {
|
||||
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={false} />);
|
||||
|
||||
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||
expect(updateButton).not.toBeDisabled();
|
||||
|
||||
rerender(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||
expect(updateButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('transitions from saving back to not saving', async () => {
|
||||
const { rerender } = render(<SaveOptionsModal {...defaultProps} isSaving={true} />);
|
||||
|
||||
const updateButton = screen.getByText('Update existing quiz').closest('button');
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
rerender(<SaveOptionsModal {...defaultProps} isSaving={false} />);
|
||||
expect(updateButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
353
tests/hooks/useQuizLibrary.test.tsx
Normal file
353
tests/hooks/useQuizLibrary.test.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import React from 'react';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
||||
import type { Quiz } from '../../types';
|
||||
|
||||
const mockAuthFetch = vi.fn();
|
||||
const mockIsAuthenticated = vi.fn(() => true);
|
||||
|
||||
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
||||
useAuthenticatedFetch: () => ({
|
||||
authFetch: mockAuthFetch,
|
||||
isAuthenticated: mockIsAuthenticated(),
|
||||
}),
|
||||
}));
|
||||
|
||||
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 - updateQuiz', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsAuthenticated.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('successfully updates a quiz', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'quiz-123' }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz({ title: 'Updated Quiz' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
|
||||
method: 'PUT',
|
||||
body: expect.stringContaining('Updated Quiz'),
|
||||
});
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('sets saving to true during update', async () => {
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuiz('quiz-123', quiz);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.saving).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise!({ ok: true, json: () => Promise.resolve({}) });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('sends correct request body structure', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz({
|
||||
title: 'My Quiz',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
timeLimit: 30,
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-456', quiz);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
|
||||
expect(body.title).toBe('My Quiz');
|
||||
expect(body.questions).toHaveLength(1);
|
||||
expect(body.questions[0].text).toBe('Question 1');
|
||||
expect(body.questions[0].timeLimit).toBe(30);
|
||||
expect(body.questions[0].options[0].reason).toBe('Correct!');
|
||||
});
|
||||
|
||||
it('handles quiz with multiple questions', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz({
|
||||
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: 25,
|
||||
options: [
|
||||
{ text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: 'D', isCorrect: true, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-789', quiz);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.questions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhappy path - API errors', () => {
|
||||
it('handles 404 not found error', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateQuiz('non-existent', quiz);
|
||||
})
|
||||
).rejects.toThrow('Quiz not found');
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('handles generic server error', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
})
|
||||
).rejects.toThrow('Failed to update quiz');
|
||||
});
|
||||
|
||||
it('handles network error', async () => {
|
||||
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('handles timeout/abort error', async () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
mockAuthFetch.mockRejectedValueOnce(abortError);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhappy path - edge cases', () => {
|
||||
it('resets saving state even on error', async () => {
|
||||
mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty quiz ID', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateQuiz('', quiz);
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('handles quiz with empty title', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz({ title: '' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.title).toBe('');
|
||||
});
|
||||
|
||||
it('strips undefined reason fields to null', async () => {
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz({
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'Q',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('quiz-123', quiz);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.questions[0].options[0]).not.toHaveProperty('reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('allows update after save completes', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'new-quiz' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
const quiz = createMockQuiz();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveQuiz(quiz, 'manual');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' });
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
tests/setup.tsx
Normal file
20
tests/setup.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock('framer-motion', async () => {
|
||||
const actual = await vi.importActual('framer-motion');
|
||||
return {
|
||||
...actual,
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue