529 lines
15 KiB
TypeScript
529 lines
15 KiB
TypeScript
import React from 'react';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { SharedQuizView } from '../../components/SharedQuizView';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
|
|
const mockNavigate = vi.fn();
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom');
|
|
return {
|
|
...actual,
|
|
useNavigate: () => mockNavigate,
|
|
};
|
|
});
|
|
|
|
const mockAuthFetch = vi.fn();
|
|
const mockSigninRedirect = vi.fn();
|
|
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
|
useAuthenticatedFetch: () => ({
|
|
authFetch: mockAuthFetch,
|
|
isAuthenticated: true,
|
|
}),
|
|
}));
|
|
|
|
let mockIsAuthenticated = false;
|
|
vi.mock('react-oidc-context', () => ({
|
|
useAuth: () => ({
|
|
isAuthenticated: mockIsAuthenticated,
|
|
signinRedirect: mockSigninRedirect,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockSharedQuiz = {
|
|
title: 'Shared Test Quiz',
|
|
source: 'manual' as const,
|
|
aiTopic: null,
|
|
gameConfig: null,
|
|
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' },
|
|
],
|
|
},
|
|
{
|
|
id: 'q2',
|
|
text: 'What is the capital of France?',
|
|
timeLimit: 15,
|
|
options: [
|
|
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red' },
|
|
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue' },
|
|
],
|
|
},
|
|
],
|
|
questionCount: 2,
|
|
};
|
|
|
|
const renderWithRouter = (token: string = 'valid-token') => {
|
|
const onHostQuiz = vi.fn();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={[`/shared/${token}`]}>
|
|
<SharedQuizView onHostQuiz={onHostQuiz} shareToken={token} />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
return { onHostQuiz };
|
|
};
|
|
|
|
describe('SharedQuizView', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockIsAuthenticated = false;
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('loading state', () => {
|
|
it('shows loading state while fetching quiz', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
|
|
() => new Promise(() => {})
|
|
);
|
|
|
|
renderWithRouter();
|
|
|
|
expect(screen.getByText('Loading shared quiz...')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('error states', () => {
|
|
it('shows error when quiz not found (404)', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
|
|
expect(screen.getByText('This quiz is no longer available')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows generic error for other failures', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
|
|
expect(screen.getByText('Failed to load quiz')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows error on network failure', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
new Error('Network error')
|
|
);
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Quiz Not Found')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('navigates home when Go Home clicked on error', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Go Home')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Go Home'));
|
|
|
|
expect(mockNavigate).toHaveBeenCalledWith('/');
|
|
});
|
|
});
|
|
|
|
describe('successful quiz load', () => {
|
|
beforeEach(() => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSharedQuiz),
|
|
});
|
|
});
|
|
|
|
it('displays quiz title', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Shared Test Quiz')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('displays question count', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/2 questions/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows singular for 1 question', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...mockSharedQuiz, questionCount: 1, questions: [mockSharedQuiz.questions[0]] }),
|
|
});
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/1 question/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('displays AI topic when present', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...mockSharedQuiz, source: 'ai_generated', aiTopic: 'Science' }),
|
|
});
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Science/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows Host Game button', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Host Game')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls onHostQuiz with quiz data when Host Game clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const { onHostQuiz } = renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Host Game')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Host Game'));
|
|
|
|
expect(onHostQuiz).toHaveBeenCalledWith({
|
|
title: 'Shared Test Quiz',
|
|
questions: mockSharedQuiz.questions,
|
|
config: undefined,
|
|
});
|
|
});
|
|
|
|
it('includes gameConfig in hosted quiz when present', async () => {
|
|
const gameConfig = { shuffleQuestions: true, shuffleAnswers: false };
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...mockSharedQuiz, gameConfig }),
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { onHostQuiz } = renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Host Game')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Host Game'));
|
|
|
|
expect(onHostQuiz).toHaveBeenCalledWith({
|
|
title: 'Shared Test Quiz',
|
|
questions: mockSharedQuiz.questions,
|
|
config: gameConfig,
|
|
});
|
|
});
|
|
|
|
it('shows Back to Home link', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('← Back to Home')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('navigates home when Back to Home clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('← Back to Home')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('← Back to Home'));
|
|
|
|
expect(mockNavigate).toHaveBeenCalledWith('/');
|
|
});
|
|
});
|
|
|
|
describe('question preview', () => {
|
|
beforeEach(() => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSharedQuiz),
|
|
});
|
|
});
|
|
|
|
it('shows Preview Questions button', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('expands questions when Preview Questions clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Preview Questions'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Hide Questions')).toBeInTheDocument();
|
|
expect(screen.getByText('Question 1')).toBeInTheDocument();
|
|
expect(screen.getByText('What is 2 + 2?')).toBeInTheDocument();
|
|
expect(screen.getByText('Question 2')).toBeInTheDocument();
|
|
expect(screen.getByText('What is the capital of France?')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('collapses questions when Hide Questions clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Preview Questions'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Hide Questions')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Hide Questions'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Preview Questions')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('save to library - unauthenticated user', () => {
|
|
beforeEach(() => {
|
|
mockIsAuthenticated = false;
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSharedQuiz),
|
|
});
|
|
});
|
|
|
|
it('shows Sign in to Save button for unauthenticated users', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('triggers sign in when Sign in to Save clicked', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Sign in to Save')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Sign in to Save'));
|
|
|
|
expect(mockSigninRedirect).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('save to library - authenticated user', () => {
|
|
beforeEach(() => {
|
|
mockIsAuthenticated = true;
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSharedQuiz),
|
|
});
|
|
});
|
|
|
|
it('shows Save to My Library button for authenticated users', async () => {
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls copy API when Save to My Library clicked', async () => {
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'new-id', title: 'Shared Test Quiz' }),
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Save to My Library'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/shared/valid-token/copy', {
|
|
method: 'POST',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('shows saving state while copying', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
}));
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Save to My Library'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
|
});
|
|
|
|
resolvePromise!({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
|
|
});
|
|
});
|
|
|
|
it('shows success toast after saving', async () => {
|
|
const toast = await import('react-hot-toast');
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'new-id', title: 'My Quiz Copy' }),
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Save to My Library'));
|
|
|
|
await waitFor(() => {
|
|
expect(toast.default.success).toHaveBeenCalledWith(
|
|
'"My Quiz Copy" saved to your library!'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('shows error toast when save fails', async () => {
|
|
const toast = await import('react-hot-toast');
|
|
mockAuthFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Save to My Library'));
|
|
|
|
await waitFor(() => {
|
|
expect(toast.default.error).toHaveBeenCalledWith(
|
|
'Failed to save quiz to library'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('disables save button while saving', async () => {
|
|
let resolvePromise: (value: unknown) => void;
|
|
mockAuthFetch.mockReturnValueOnce(new Promise((resolve) => {
|
|
resolvePromise = resolve;
|
|
}));
|
|
|
|
const user = userEvent.setup();
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Save to My Library')).toBeInTheDocument();
|
|
});
|
|
|
|
const saveButton = screen.getByText('Save to My Library').closest('button')!;
|
|
await user.click(saveButton);
|
|
|
|
await waitFor(() => {
|
|
const savingButton = screen.getByText('Saving...').closest('button')!;
|
|
expect(savingButton).toBeDisabled();
|
|
});
|
|
|
|
resolvePromise!({
|
|
ok: true,
|
|
json: () => Promise.resolve({ id: 'new-id', title: 'Test' }),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fetches correct token from URL', () => {
|
|
it('fetches quiz with token from URL', async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSharedQuiz),
|
|
});
|
|
|
|
renderWithRouter('my-special-token');
|
|
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/shared/my-special-token')
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|