Add sharing

This commit is contained in:
Joey Yakimowich-Payne 2026-01-16 08:49:21 -07:00
commit 8a11275849
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
16 changed files with 1996 additions and 10 deletions

View file

@ -1,7 +1,7 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
import type { Quiz } from '../../types';
import type { Quiz, QuizListItem } from '../../types';
const mockAuthFetch = vi.fn();
@ -1167,4 +1167,319 @@ it('creates blob with correct mime type', async () => {
expect(result.current.importing).toBe(false);
});
});
describe('shareQuiz', () => {
it('shares a quiz and returns the share token', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ shareToken: 'abc123token' }),
});
const { result } = renderHook(() => useQuizLibrary());
let token;
await act(async () => {
token = await result.current.shareQuiz('quiz-123');
});
expect(token).toBe('abc123token');
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
method: 'POST',
});
});
it('sets and clears sharingQuizId during share operation', async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockAuthFetch.mockReturnValueOnce(pendingPromise);
const { result } = renderHook(() => useQuizLibrary());
act(() => {
result.current.shareQuiz('quiz-123');
});
await waitFor(() => {
expect(result.current.sharingQuizId).toBe('quiz-123');
});
await act(async () => {
resolvePromise!({ ok: true, json: () => Promise.resolve({ shareToken: 'token' }) });
});
await waitFor(() => {
expect(result.current.sharingQuizId).toBeNull();
});
});
it('updates quiz in local state with shareToken and isShared', async () => {
const initialQuizzes: QuizListItem[] = [
{ id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, isShared: false, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
];
mockAuthFetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareToken: 'newtoken' }) });
const { result } = renderHook(() => useQuizLibrary());
await act(async () => {
await result.current.fetchQuizzes();
});
await act(async () => {
await result.current.shareQuiz('quiz-123');
});
expect(result.current.quizzes[0].shareToken).toBe('newtoken');
expect(result.current.quizzes[0].isShared).toBe(true);
});
it('handles 404 not found when sharing non-existent quiz', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.shareQuiz('non-existent');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Quiz not found.');
}
expect(result.current.sharingQuizId).toBeNull();
});
it('handles generic server error when sharing', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.shareQuiz('quiz-123');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Failed to share quiz.');
}
});
it('handles network error when sharing', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.shareQuiz('quiz-123');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Network error');
}
expect(result.current.sharingQuizId).toBeNull();
});
it('returns existing token if quiz is already shared', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ shareToken: 'existing-token' }),
});
const { result } = renderHook(() => useQuizLibrary());
let token;
await act(async () => {
token = await result.current.shareQuiz('quiz-123');
});
expect(token).toBe('existing-token');
});
});
describe('unshareQuiz', () => {
it('unshares a quiz successfully', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const { result } = renderHook(() => useQuizLibrary());
await act(async () => {
await result.current.unshareQuiz('quiz-123');
});
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123/share', {
method: 'DELETE',
});
});
it('sets and clears sharingQuizId during unshare operation', async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockAuthFetch.mockReturnValueOnce(pendingPromise);
const { result } = renderHook(() => useQuizLibrary());
act(() => {
result.current.unshareQuiz('quiz-123');
});
await waitFor(() => {
expect(result.current.sharingQuizId).toBe('quiz-123');
});
await act(async () => {
resolvePromise!({ ok: true, json: () => Promise.resolve({ success: true }) });
});
await waitFor(() => {
expect(result.current.sharingQuizId).toBeNull();
});
});
it('updates quiz in local state to remove shareToken and set isShared false', async () => {
const initialQuizzes: QuizListItem[] = [
{ id: 'quiz-123', title: 'Test Quiz', source: 'manual', questionCount: 5, shareToken: 'oldtoken', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
];
mockAuthFetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
const { result } = renderHook(() => useQuizLibrary());
await act(async () => {
await result.current.fetchQuizzes();
});
expect(result.current.quizzes[0].isShared).toBe(true);
await act(async () => {
await result.current.unshareQuiz('quiz-123');
});
expect(result.current.quizzes[0].shareToken).toBeUndefined();
expect(result.current.quizzes[0].isShared).toBe(false);
});
it('handles 404 not found when unsharing non-existent quiz', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.unshareQuiz('non-existent');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Quiz not found.');
}
expect(result.current.sharingQuizId).toBeNull();
});
it('handles generic server error when unsharing', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.unshareQuiz('quiz-123');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Failed to stop sharing quiz.');
}
});
it('handles network error when unsharing', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useQuizLibrary());
try {
await act(async () => {
await result.current.unshareQuiz('quiz-123');
});
expect.fail('Should have thrown');
} catch (e) {
expect((e as Error).message).toBe('Network error');
}
expect(result.current.sharingQuizId).toBeNull();
});
it('does not affect other quizzes in the list when unsharing', async () => {
const initialQuizzes: QuizListItem[] = [
{ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questionCount: 5, shareToken: 'token1', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questionCount: 3, shareToken: 'token2', isShared: true, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
];
mockAuthFetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(initialQuizzes) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
const { result } = renderHook(() => useQuizLibrary());
await act(async () => {
await result.current.fetchQuizzes();
});
await act(async () => {
await result.current.unshareQuiz('quiz-1');
});
expect(result.current.quizzes[0].isShared).toBe(false);
expect(result.current.quizzes[1].isShared).toBe(true);
expect(result.current.quizzes[1].shareToken).toBe('token2');
});
});
describe('fetchQuizzes with sharing info', () => {
it('fetches quizzes with isShared and shareToken fields', async () => {
const mockQuizzes: QuizListItem[] = [
{ id: '1', title: 'Shared Quiz', source: 'manual', questionCount: 5, shareToken: 'token123', isShared: true, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: '2', title: 'Private Quiz', source: 'ai_generated', questionCount: 10, isShared: false, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
];
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockQuizzes),
});
const { result } = renderHook(() => useQuizLibrary());
await act(async () => {
await result.current.fetchQuizzes();
});
expect(result.current.quizzes[0].isShared).toBe(true);
expect(result.current.quizzes[0].shareToken).toBe('token123');
expect(result.current.quizzes[1].isShared).toBe(false);
expect(result.current.quizzes[1].shareToken).toBeUndefined();
});
});
});