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

@ -2159,6 +2159,376 @@ console.log('\n=== Game Session Tests ===');
}
});
console.log('\n=== Quiz Sharing Tests ===');
let shareTestQuizId: string | null = null;
let shareTestToken: string | null = null;
console.log('\nShare Quiz Tests:');
await test('POST /api/quizzes creates quiz for sharing tests', async () => {
const quiz = {
title: 'Sharing Test Quiz',
source: 'manual',
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: false,
hostParticipates: true,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
},
questions: [
{
text: 'What is the capital of France?',
timeLimit: 20,
options: [
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is in UK' },
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct!' },
{ text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: 'Madrid', isCorrect: false, shape: 'square', color: 'green' },
],
},
{
text: 'What is 2 + 2?',
timeLimit: 15,
options: [
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quiz, 201);
shareTestQuizId = (data as { id: string }).id;
});
await test('POST /api/quizzes/:id/share generates share token', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
const result = data as { shareToken: string };
if (!result.shareToken) throw new Error('Missing shareToken in response');
if (result.shareToken.length < 10) throw new Error('shareToken too short');
shareTestToken = result.shareToken;
});
await test('POST /api/quizzes/:id/share returns same token if already shared', async () => {
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
const result = data as { shareToken: string };
if (result.shareToken !== shareTestToken) {
throw new Error('Should return same token when already shared');
}
});
await test('GET /api/quizzes returns isShared and shareToken for shared quiz', async () => {
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
const { data } = await request('GET', '/api/quizzes');
const quizzes = data as Array<{ id: string; isShared: boolean; shareToken: string | null }>;
const quiz = quizzes.find((q) => q.id === shareTestQuizId);
if (!quiz) throw new Error('Shared quiz not in list');
if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
});
await test('GET /api/quizzes/:id returns isShared and shareToken', async () => {
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
const quiz = data as { isShared: boolean; shareToken: string | null };
if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
});
await test('POST /api/quizzes/:id/share with non-existent ID returns 404', async () => {
await request('POST', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
});
await test('POST /api/quizzes/:id/share without auth returns 401', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
});
console.log('\nShared Quiz Public Access Tests:');
await test('GET /api/shared/:token returns quiz without auth', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
const data = await res.json();
if (data.title !== 'Sharing Test Quiz') throw new Error('Wrong title');
if (data.source !== 'manual') throw new Error('Wrong source');
if (!Array.isArray(data.questions)) throw new Error('Missing questions');
if (data.questions.length !== 2) throw new Error(`Expected 2 questions, got ${data.questions.length}`);
if (data.questionCount !== 2) throw new Error(`Expected questionCount 2, got ${data.questionCount}`);
});
await test('GET /api/shared/:token returns gameConfig', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
const data = await res.json();
if (!data.gameConfig) throw new Error('Missing gameConfig');
if (data.gameConfig.shuffleQuestions !== true) throw new Error('gameConfig not preserved');
if (data.gameConfig.hostParticipates !== true) throw new Error('hostParticipates not preserved');
});
await test('GET /api/shared/:token returns questions with all fields', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
const data = await res.json();
const q1 = data.questions[0];
if (!q1.id) throw new Error('Missing question id');
if (!q1.text) throw new Error('Missing question text');
if (q1.timeLimit !== 20) throw new Error('Wrong timeLimit');
if (!Array.isArray(q1.options)) throw new Error('Missing options');
if (q1.options.length !== 4) throw new Error(`Expected 4 options, got ${q1.options.length}`);
const correctOption = q1.options.find((o: { isCorrect: boolean }) => o.isCorrect);
if (!correctOption) throw new Error('No correct option found');
if (correctOption.text !== 'Paris') throw new Error('Wrong correct answer');
if (correctOption.reason !== 'Correct!') throw new Error('Reason not preserved');
});
await test('GET /api/shared/:token with invalid token returns 404', async () => {
const res = await fetch(`${API_URL}/api/shared/invalid-token-12345`);
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
});
await test('GET /api/shared/:token with empty token returns 404', async () => {
const res = await fetch(`${API_URL}/api/shared/`);
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
});
console.log('\nCopy Shared Quiz Tests:');
await test('POST /api/shared/:token/copy without auth returns 401', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
});
await test('POST /api/shared/:token/copy with auth creates copy', async () => {
if (!shareTestToken) throw new Error('No share token');
const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
const result = data as { id: string; title: string };
if (!result.id) throw new Error('Missing id');
if (!result.title) throw new Error('Missing title');
// Title may have "(Copy)" appended if user already has quiz with same title
if (!result.title.startsWith('Sharing Test Quiz')) throw new Error('Wrong title');
// Verify the copied quiz exists and has correct content
const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
const copiedQuiz = copiedData as { title: string; source: string; questions: unknown[]; gameConfig: unknown };
if (copiedQuiz.source !== 'manual') throw new Error('Source should be manual for copied quiz');
if (!Array.isArray(copiedQuiz.questions)) throw new Error('Missing questions in copy');
if (copiedQuiz.questions.length !== 2) throw new Error('Questions not copied correctly');
if (!copiedQuiz.gameConfig) throw new Error('gameConfig not copied');
// Cleanup
await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
});
await test('POST /api/shared/:token/copy copies all question data', async () => {
if (!shareTestToken) throw new Error('No share token');
const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
const result = data as { id: string };
const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
const copiedQuiz = copiedData as {
questions: Array<{
text: string;
timeLimit: number;
options: Array<{ text: string; isCorrect: boolean; reason?: string }>;
}>;
};
const q1 = copiedQuiz.questions[0];
if (q1.text !== 'What is the capital of France?') throw new Error('Question text not copied');
if (q1.timeLimit !== 20) throw new Error('timeLimit not copied');
const parisOption = q1.options.find((o) => o.text === 'Paris');
if (!parisOption) throw new Error('Paris option not copied');
if (!parisOption.isCorrect) throw new Error('isCorrect not copied');
if (parisOption.reason !== 'Correct!') throw new Error('reason not copied');
await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
});
await test('POST /api/shared/:token/copy with invalid token returns 404', async () => {
await request('POST', '/api/shared/invalid-token-xyz/copy', undefined, 404);
});
console.log('\nUnshare Quiz Tests:');
await test('DELETE /api/quizzes/:id/share removes sharing', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const { data } = await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
const result = data as { success: boolean };
if (!result.success) throw new Error('Expected success: true');
});
await test('GET /api/quizzes/:id shows isShared=false after unsharing', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
const quiz = data as { isShared: boolean; shareToken: string | null };
if (quiz.isShared !== false) throw new Error('Expected isShared to be false');
if (quiz.shareToken !== null) throw new Error('Expected shareToken to be null');
});
await test('GET /api/shared/:token returns 404 after unsharing', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
});
await test('POST /api/shared/:token/copy returns 404 after unsharing', async () => {
if (!shareTestToken) throw new Error('No share token');
await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 404);
});
await test('DELETE /api/quizzes/:id/share with non-existent ID returns 404', async () => {
await request('DELETE', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
});
await test('DELETE /api/quizzes/:id/share without auth returns 401', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
});
console.log('\nRe-share Quiz Tests:');
await test('POST /api/quizzes/:id/share generates new token after unshare', async () => {
if (!shareTestQuizId) throw new Error('No quiz created');
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
const result = data as { shareToken: string };
if (!result.shareToken) throw new Error('Missing shareToken');
if (result.shareToken === shareTestToken) throw new Error('Should generate new token');
// Update our reference to the new token
shareTestToken = result.shareToken;
});
await test('GET /api/shared/:token works with new token', async () => {
if (!shareTestToken) throw new Error('No share token');
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
});
console.log('\nShare Edge Cases:');
await test('Unshare and re-share does not affect quiz content', async () => {
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
// Get original content
const { data: original } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
const originalQuiz = original as { title: string; questions: unknown[] };
// Unshare
await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
// Re-share
const { data: shareData } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
shareTestToken = (shareData as { shareToken: string }).shareToken;
// Verify content unchanged
const { data: afterData } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
const afterQuiz = afterData as { title: string; questions: unknown[] };
if (originalQuiz.title !== afterQuiz.title) throw new Error('Title changed');
if (JSON.stringify(originalQuiz.questions) !== JSON.stringify(afterQuiz.questions)) {
throw new Error('Questions changed');
}
});
await test('DELETE quiz also removes from shared access', async () => {
// Create a new quiz to share and delete
const quiz = {
title: 'Delete Shared Quiz Test',
source: 'manual',
questions: [
{
text: 'Test?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data: createData } = await request('POST', '/api/quizzes', quiz, 201);
const quizId = (createData as { id: string }).id;
// Share it
const { data: shareData } = await request('POST', `/api/quizzes/${quizId}/share`);
const token = (shareData as { shareToken: string }).shareToken;
// Verify it's accessible
let res = await fetch(`${API_URL}/api/shared/${token}`);
if (res.status !== 200) throw new Error('Shared quiz should be accessible before delete');
// Delete the quiz
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
// Verify shared link no longer works
res = await fetch(`${API_URL}/api/shared/${token}`);
if (res.status !== 404) throw new Error(`Expected 404 after delete, got ${res.status}`);
});
await test('DELETE cleanup sharing test quiz', async () => {
if (shareTestQuizId) {
await request('DELETE', `/api/quizzes/${shareTestQuizId}`, undefined, 204);
}
});
console.log('\n=== Results ===');
const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;