Add sharing
This commit is contained in:
parent
240ce28692
commit
8a11275849
16 changed files with 1996 additions and 10 deletions
531
tests/components/SharedQuizView.test.tsx
Normal file
531
tests/components/SharedQuizView.test.tsx
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
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, Routes, Route } 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}`]}>
|
||||
<Routes>
|
||||
<Route path="/shared/:token" element={<SharedQuizView onHostQuiz={onHostQuiz} />} />
|
||||
</Routes>
|
||||
</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')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue