Add import/export
This commit is contained in:
parent
02ecca7598
commit
667c490537
9 changed files with 2261 additions and 54 deletions
|
|
@ -18,6 +18,9 @@ vi.mock('react-oidc-context', () => ({
|
|||
useAuth: () => mockAuth,
|
||||
}));
|
||||
|
||||
// Get the API URL that the hook will actually use
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('useAuthenticatedFetch', () => {
|
||||
|
|
@ -61,7 +64,7 @@ describe('useAuthenticatedFetch', () => {
|
|||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:3001/api/test',
|
||||
`${API_URL}/api/test`,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer valid-token',
|
||||
|
|
|
|||
|
|
@ -597,4 +597,574 @@ describe('useQuizLibrary', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportQuizzes', () => {
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.fn>;
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let mockClick: ReturnType<typeof vi.fn>;
|
||||
let capturedBlob: Blob | null = null;
|
||||
let originalCreateElement: typeof document.createElement;
|
||||
let originalCreateObjectURL: typeof URL.createObjectURL;
|
||||
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = null;
|
||||
mockCreateObjectURL = vi.fn((blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test-url';
|
||||
});
|
||||
mockRevokeObjectURL = vi.fn();
|
||||
mockClick = vi.fn();
|
||||
|
||||
originalCreateObjectURL = global.URL.createObjectURL;
|
||||
originalRevokeObjectURL = global.URL.revokeObjectURL;
|
||||
global.URL.createObjectURL = mockCreateObjectURL as typeof URL.createObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL as typeof URL.revokeObjectURL;
|
||||
|
||||
originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
if (tag === 'a') {
|
||||
return {
|
||||
href: '',
|
||||
download: '',
|
||||
click: mockClick,
|
||||
} as unknown as HTMLAnchorElement;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
});
|
||||
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node);
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => node);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.URL.createObjectURL = originalCreateObjectURL;
|
||||
global.URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exports selected quizzes as JSON file', async () => {
|
||||
const mockQuizData = {
|
||||
id: 'quiz-1',
|
||||
title: 'Test Quiz',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{ id: 'q1', text: 'Question 1', timeLimit: 20, options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockQuizData),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes(['quiz-1']);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-1');
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
expect(mockClick).toHaveBeenCalled();
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(result.current.exporting).toBe(false);
|
||||
});
|
||||
|
||||
it('exports multiple quizzes', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questions: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'ai_generated', aiTopic: 'Science', questions: [] }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes(['quiz-1', 'quiz-2']);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets exporting to true during export', async () => {
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
act(() => {
|
||||
result.current.exportQuizzes(['quiz-1']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.exporting).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'quiz-1', title: 'Q', source: 'manual', questions: [] }) });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.exporting).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty quiz IDs array', async () => {
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes([]);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).not.toHaveBeenCalled();
|
||||
expect(mockCreateObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips quizzes that fail to load', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questions: [] }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes(['quiz-1', 'quiz-2']);
|
||||
});
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
expect(capturedBlob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('handles network error during export', async () => {
|
||||
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes(['quiz-1']);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe('Network error');
|
||||
}
|
||||
|
||||
expect(result.current.exporting).toBe(false);
|
||||
});
|
||||
|
||||
it('creates blob with correct mime type', async () => {
|
||||
const mockQuizData = {
|
||||
id: 'quiz-1',
|
||||
title: 'Test Quiz',
|
||||
source: 'ai_generated',
|
||||
aiTopic: 'History',
|
||||
gameConfig: { shuffleQuestions: true },
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'What year?',
|
||||
timeLimit: 30,
|
||||
options: [
|
||||
{ text: '1990', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '2000', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockAuthFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockQuizData),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportQuizzes(['quiz-1']);
|
||||
});
|
||||
|
||||
expect(capturedBlob).not.toBeNull();
|
||||
expect(capturedBlob!.type).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseImportFile', () => {
|
||||
const originalText = File.prototype.text;
|
||||
|
||||
beforeEach(() => {
|
||||
// JSDOM doesn't implement File.prototype.text, so define it using FileReader
|
||||
File.prototype.text = function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(this);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
File.prototype.text = originalText;
|
||||
});
|
||||
|
||||
it('parses valid export file', async () => {
|
||||
const validExportData = {
|
||||
version: 1,
|
||||
exportedAt: '2024-01-01T00:00:00.000Z',
|
||||
quizzes: [
|
||||
{ title: 'Quiz 1', source: 'manual', questions: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const file = new File(
|
||||
[JSON.stringify(validExportData)],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
let parsed;
|
||||
await act(async () => {
|
||||
parsed = await result.current.parseImportFile(file);
|
||||
});
|
||||
|
||||
expect(parsed).toEqual(validExportData);
|
||||
});
|
||||
|
||||
it('rejects file without version field', async () => {
|
||||
const invalidData = {
|
||||
exportedAt: '2024-01-01T00:00:00.000Z',
|
||||
quizzes: [],
|
||||
};
|
||||
|
||||
const file = new File(
|
||||
[JSON.stringify(invalidData)],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.parseImportFile(file);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe('Invalid export file format');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects file without quizzes array', async () => {
|
||||
const invalidData = {
|
||||
version: 1,
|
||||
exportedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const file = new File(
|
||||
[JSON.stringify(invalidData)],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.parseImportFile(file);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe('Invalid export file format');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects file with quizzes as non-array', async () => {
|
||||
const invalidData = {
|
||||
version: 1,
|
||||
exportedAt: '2024-01-01T00:00:00.000Z',
|
||||
quizzes: 'not-an-array',
|
||||
};
|
||||
|
||||
const file = new File(
|
||||
[JSON.stringify(invalidData)],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.parseImportFile(file);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe('Invalid export file format');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', async () => {
|
||||
const file = new File(
|
||||
['not valid json {'],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.parseImportFile(file);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SyntaxError);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles empty file', async () => {
|
||||
const file = new File(
|
||||
[''],
|
||||
'export.json',
|
||||
{ type: 'application/json' }
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.parseImportFile(file);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SyntaxError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('importQuizzes', () => {
|
||||
it('imports single quiz successfully', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'new-quiz-1' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
const quizzesToImport = [
|
||||
{
|
||||
title: 'Imported Quiz',
|
||||
source: 'manual' as const,
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
text: 'Question?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle' as const, color: 'red' as const },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond' as const, color: 'blue' as const },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes(quizzesToImport);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
|
||||
it('imports multiple quizzes sequentially', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q2' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
const quizzesToImport = [
|
||||
{ title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions },
|
||||
{ title: 'Quiz 2', source: 'ai_generated' as const, aiTopic: 'Science', questions: createMockQuiz().questions },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes(quizzesToImport);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('sets importing to true during import', async () => {
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
act(() => {
|
||||
result.current.importQuizzes([
|
||||
{ title: 'Quiz', source: 'manual', questions: createMockQuiz().questions },
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.importing).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'id' }) });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty quizzes array', async () => {
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes([]);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports partial success when some imports fail', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 400 });
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
const quizzesToImport = [
|
||||
{ title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions },
|
||||
{ title: 'Quiz 2', source: 'manual' as const, questions: createMockQuiz().questions },
|
||||
];
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes(quizzesToImport);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toContain('Invalid quiz data');
|
||||
}
|
||||
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes quiz list after successful import', async () => {
|
||||
const mockQuizList = [{ id: 'q1', title: 'Quiz 1', source: 'manual', questionCount: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }];
|
||||
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizList) });
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes([
|
||||
{ title: 'Quiz 1', source: 'manual', questions: createMockQuiz().questions },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/quizzes');
|
||||
expect(result.current.quizzes).toEqual(mockQuizList);
|
||||
});
|
||||
|
||||
it('preserves aiTopic for AI-generated quizzes', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes([
|
||||
{ title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Space', questions: createMockQuiz().questions },
|
||||
]);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.source).toBe('ai_generated');
|
||||
expect(body.aiTopic).toBe('Space');
|
||||
});
|
||||
|
||||
it('preserves game config during import', async () => {
|
||||
mockAuthFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
const config = {
|
||||
shuffleQuestions: true,
|
||||
shuffleAnswers: true,
|
||||
hostParticipates: false,
|
||||
randomNamesEnabled: true,
|
||||
streakBonusEnabled: true,
|
||||
streakThreshold: 5,
|
||||
streakMultiplier: 1.5,
|
||||
comebackBonusEnabled: false,
|
||||
comebackBonusPoints: 100,
|
||||
penaltyForWrongAnswer: true,
|
||||
penaltyPercent: 10,
|
||||
firstCorrectBonusEnabled: true,
|
||||
firstCorrectBonusPoints: 25,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes([
|
||||
{ title: 'Quiz', source: 'manual', config, questions: createMockQuiz().questions },
|
||||
]);
|
||||
});
|
||||
|
||||
const [, options] = mockAuthFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.gameConfig).toEqual(config);
|
||||
});
|
||||
|
||||
it('handles network error during import', async () => {
|
||||
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useQuizLibrary());
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.importQuizzes([
|
||||
{ title: 'Quiz', source: 'manual', questions: createMockQuiz().questions },
|
||||
]);
|
||||
});
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe('Network error');
|
||||
}
|
||||
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue