Add more tests

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 07:08:50 -07:00
commit 5974134ad8
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
7 changed files with 1418 additions and 231 deletions

View file

@ -0,0 +1,395 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockSigninSilent = vi.fn();
const mockSigninRedirect = vi.fn();
const mockAuth = {
user: {
access_token: 'valid-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
isAuthenticated: true,
isLoading: false,
signinSilent: mockSigninSilent,
signinRedirect: mockSigninRedirect,
};
vi.mock('react-oidc-context', () => ({
useAuth: () => mockAuth,
}));
const originalFetch = global.fetch;
describe('useAuthenticatedFetch', () => {
let useAuthenticatedFetch: typeof import('../../hooks/useAuthenticatedFetch').useAuthenticatedFetch;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
const module = await import('../../hooks/useAuthenticatedFetch');
useAuthenticatedFetch = module.useAuthenticatedFetch;
mockAuth.user = {
access_token: 'valid-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
};
mockAuth.isAuthenticated = true;
mockAuth.isLoading = false;
global.fetch = vi.fn();
Object.defineProperty(navigator, 'onLine', { value: true, writable: true });
});
afterEach(() => {
global.fetch = originalFetch;
vi.resetAllMocks();
});
describe('authFetch - happy path', () => {
it('makes authenticated request with Bearer token', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ data: 'test' }),
});
const { result } = renderHook(() => useAuthenticatedFetch());
let response;
await act(async () => {
response = await result.current.authFetch('/api/test');
});
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/test',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
}),
})
);
expect(response).toBeDefined();
});
it('uses full URL when provided', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('https://other-api.com/endpoint');
});
expect(global.fetch).toHaveBeenCalledWith(
'https://other-api.com/endpoint',
expect.any(Object)
);
});
it('passes through additional options', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('/api/test', {
method: 'POST',
body: JSON.stringify({ data: 'test' }),
});
});
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ data: 'test' }),
})
);
});
});
describe('token expiration handling', () => {
it('refreshes token silently when expired', async () => {
mockAuth.user = {
access_token: 'expired-token',
expires_at: Math.floor(Date.now() / 1000) - 100,
};
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('/api/test');
});
expect(mockSigninSilent).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer new-token',
}),
})
);
});
it('refreshes token with buffer before actual expiration', async () => {
mockAuth.user = {
access_token: 'almost-expired-token',
expires_at: Math.floor(Date.now() / 1000) + 30,
};
mockSigninSilent.mockResolvedValueOnce({ access_token: 'refreshed-token' });
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('/api/test');
});
expect(mockSigninSilent).toHaveBeenCalled();
});
});
describe('redirect scenarios', () => {
it('throws error when silent refresh fails on expired token', async () => {
mockAuth.user = {
access_token: 'expired-token',
expires_at: Math.floor(Date.now() / 1000) - 100,
};
mockSigninSilent.mockResolvedValueOnce(null);
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Session expired, redirecting to login');
});
it('throws error when 401 and refresh fails', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401,
});
mockSigninSilent.mockResolvedValueOnce(null);
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Session expired, redirecting to login');
});
});
describe('401 handling', () => {
it('retries with refreshed token on 401', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: false,
status: 401,
})
.mockResolvedValueOnce({
ok: true,
status: 200,
});
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('/api/test');
});
expect(mockSigninSilent).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
describe('error handling', () => {
it('throws on network error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network failed'));
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Network error. Please try again.');
});
it('throws when offline before request', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('You appear to be offline. Please check your connection.');
});
it('throws server error for 5xx responses', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Server error. Please try again later.');
});
it('throws when not authenticated', async () => {
mockAuth.user = null;
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Not authenticated');
});
});
describe('concurrent requests', () => {
it('reuses pending silent refresh for concurrent requests', async () => {
mockAuth.user = {
access_token: 'expired-token',
expires_at: Math.floor(Date.now() / 1000) - 100,
};
let resolveRefresh: (value: unknown) => void;
const refreshPromise = new Promise((resolve) => {
resolveRefresh = resolve;
});
mockSigninSilent.mockReturnValue(refreshPromise);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
let promise1: Promise<Response>;
let promise2: Promise<Response>;
act(() => {
promise1 = result.current.authFetch('/api/test1');
promise2 = result.current.authFetch('/api/test2');
});
await act(async () => {
resolveRefresh!({ access_token: 'new-token' });
});
await act(async () => {
await promise1!;
await promise2!;
});
expect(mockSigninSilent).toHaveBeenCalledTimes(1);
});
});
describe('isAuthenticated and user properties', () => {
it('exposes isAuthenticated from auth context', () => {
mockAuth.isAuthenticated = true;
const { result } = renderHook(() => useAuthenticatedFetch());
expect(result.current.isAuthenticated).toBe(true);
});
it('exposes isLoading from auth context', () => {
mockAuth.isLoading = true;
const { result } = renderHook(() => useAuthenticatedFetch());
expect(result.current.isLoading).toBe(true);
});
it('exposes user from auth context', () => {
const { result } = renderHook(() => useAuthenticatedFetch());
expect(result.current.user).toEqual(mockAuth.user);
});
});
describe('edge cases', () => {
it('handles missing expires_at gracefully', async () => {
mockAuth.user = {
access_token: 'token-without-expiry',
expires_at: undefined,
};
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const { result } = renderHook(() => useAuthenticatedFetch());
await act(async () => {
await result.current.authFetch('/api/test');
});
expect(mockSigninSilent).toHaveBeenCalled();
});
it('handles fetch throwing TypeError for network issues', async () => {
const typeError = new TypeError('Failed to fetch');
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(typeError);
const { result } = renderHook(() => useAuthenticatedFetch());
await expect(
act(async () => {
await result.current.authFetch('/api/test');
})
).rejects.toThrow('Network error. Please try again.');
});
it('returns response for 4xx errors (non-401)', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
const { result } = renderHook(() => useAuthenticatedFetch());
let response;
await act(async () => {
response = await result.current.authFetch('/api/test');
});
expect(response.status).toBe(404);
});
});
});

View file

@ -1,16 +1,14 @@
import React from 'react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } 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(),
isAuthenticated: true,
}),
}));
@ -39,38 +37,35 @@ const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
...overrides,
});
describe('useQuizLibrary - updateQuiz', () => {
describe('useQuizLibrary', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAuthenticated.mockReturnValue(true);
mockAuthFetch.mockReset();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('happy path', () => {
it('successfully updates a quiz', async () => {
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({ id: 'quiz-123' }),
json: () => Promise.resolve(mockQuizzes),
});
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz({ title: 'Updated Quiz' });
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
await result.current.fetchQuizzes();
});
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
method: 'PUT',
body: expect.stringContaining('Updated Quiz'),
});
expect(result.current.saving).toBe(false);
expect(result.current.quizzes).toEqual(mockQuizzes);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('sets saving to true during update', async () => {
it('sets loading to true during fetch', async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
@ -78,14 +73,97 @@ describe('useQuizLibrary - updateQuiz', () => {
mockAuthFetch.mockReturnValueOnce(pendingPromise);
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
act(() => {
result.current.updateQuiz('quiz-123', quiz);
result.current.fetchQuizzes();
});
await waitFor(() => {
expect(result.current.saving).toBe(true);
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 () => {
@ -93,105 +171,315 @@ describe('useQuizLibrary - updateQuiz', () => {
});
await waitFor(() => {
expect(result.current.saving).toBe(false);
expect(result.current.loadingQuizId).toBeNull();
});
});
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 () => {
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();
await expect(
act(async () => {
await result.current.updateQuiz('non-existent', quiz);
})
).rejects.toThrow('Quiz not found');
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,
@ -201,49 +489,81 @@ describe('useQuizLibrary - updateQuiz', () => {
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);
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('unhappy path - edge cases', () => {
it('resets saving state even on error', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
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();
@ -252,11 +572,10 @@ describe('useQuizLibrary - updateQuiz', () => {
await act(async () => {
await result.current.updateQuiz('quiz-123', quiz);
});
} catch {
// Expected to throw
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Failed to update quiz.');
}
expect(result.current.saving).toBe(false);
});
it('handles empty quiz ID', async () => {
@ -268,86 +587,14 @@ describe('useQuizLibrary - updateQuiz', () => {
const { result } = renderHook(() => useQuizLibrary());
const quiz = createMockQuiz();
await expect(
act(async () => {
try {
await 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);
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Quiz not found.');
}
});
});
});