Add tests and edit works

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 23:52:04 -07:00
commit bc4b0e2df7
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
12 changed files with 2415 additions and 20 deletions

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