const API_URL = process.env.API_URL || 'http://localhost:3001'; const TOKEN = process.env.TEST_TOKEN; if (!TOKEN) { console.error('ERROR: TEST_TOKEN environment variable is required'); console.log('Run: npm run test:get-token'); console.log('Then: export TEST_TOKEN=""'); process.exit(1); } interface TestResult { name: string; passed: boolean; error?: string; } const results: TestResult[] = []; async function request( method: string, path: string, body?: unknown, expectStatus = 200 ): Promise<{ status: number; data: unknown }> { const response = await fetch(`${API_URL}${path}`, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: body ? JSON.stringify(body) : undefined, }); const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : null; if (response.status !== expectStatus) { throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`); } return { status: response.status, data }; } async function test(name: string, fn: () => Promise) { try { await fn(); results.push({ name, passed: true }); console.log(` ✓ ${name}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); results.push({ name, passed: false, error: message }); console.log(` ✗ ${name}`); console.log(` ${message}`); } } async function runTests() { console.log('\n=== Kaboot API Tests ===\n'); console.log(`API: ${API_URL}`); console.log(''); let createdQuizId: string | null = null; console.log('Health Check:'); await test('GET /health returns ok', async () => { const res = await fetch(`${API_URL}/health`); const data = await res.json(); if (data.status !== 'ok') throw new Error('Health check failed'); }); console.log('\nAuth Tests:'); await test('GET /api/quizzes without token returns 401', async () => { const res = await fetch(`${API_URL}/api/quizzes`); if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); }); await test('GET /api/quizzes with invalid token returns 401', async () => { const res = await fetch(`${API_URL}/api/quizzes`, { headers: { Authorization: 'Bearer invalid-token' }, }); if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); }); console.log('\nUser Tests:'); await test('GET /api/users/me returns user info', async () => { const { data } = await request('GET', '/api/users/me'); const user = data as Record; if (!user.id) throw new Error('Missing user id'); if (!user.username) throw new Error('Missing username'); }); console.log('\nQuiz CRUD Tests:'); await test('GET /api/quizzes returns array', async () => { const { data } = await request('GET', '/api/quizzes'); if (!Array.isArray(data)) throw new Error('Expected array'); }); await test('POST /api/quizzes creates quiz', async () => { const quiz = { title: 'Test Quiz', source: 'manual', questions: [ { text: 'What is 2 + 2?', timeLimit: 20, options: [ { text: '3', isCorrect: false, shape: 'triangle', color: 'red' }, { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' }, { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: '6', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const result = data as { id: string }; if (!result.id) throw new Error('Missing quiz id'); createdQuizId = result.id; }); await test('GET /api/quizzes/:id returns full quiz', async () => { if (!createdQuizId) throw new Error('No quiz created'); const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); const quiz = data as Record; if (quiz.title !== 'Test Quiz') throw new Error('Wrong title'); if (!Array.isArray(quiz.questions)) throw new Error('Missing questions'); const questions = quiz.questions as Record[]; if (questions.length !== 1) throw new Error('Wrong question count'); const q = questions[0]; if (!Array.isArray(q.options)) throw new Error('Missing options'); if ((q.options as unknown[]).length !== 4) throw new Error('Wrong option count'); }); await test('PUT /api/quizzes/:id updates quiz', async () => { if (!createdQuizId) throw new Error('No quiz created'); const updatedQuiz = { title: 'Updated Test Quiz', questions: [ { text: 'Updated question?', timeLimit: 30, options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; await request('PUT', `/api/quizzes/${createdQuizId}`, updatedQuiz); const { data } = await request('GET', `/api/quizzes/${createdQuizId}`); const quiz = data as Record; if (quiz.title !== 'Updated Test Quiz') throw new Error('Title not updated'); }); await test('DELETE /api/quizzes/:id deletes quiz', async () => { if (!createdQuizId) throw new Error('No quiz created'); await request('DELETE', `/api/quizzes/${createdQuizId}`, undefined, 204); }); await test('GET /api/quizzes/:id returns 404 for deleted quiz', async () => { if (!createdQuizId) throw new Error('No quiz created'); await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); }); console.log('\nQuiz Validation Tests:'); await test('POST /api/quizzes without title returns 400', async () => { const invalidQuiz = { source: 'manual', questions: [ { text: 'Question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes without source returns 400', async () => { const invalidQuiz = { title: 'Missing Source Quiz', questions: [ { text: 'Question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes without questions returns 400', async () => { const invalidQuiz = { title: 'No Questions Quiz', source: 'manual', questions: [], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with empty body returns 400', async () => { await request('POST', '/api/quizzes', {}, 400); }); console.log('\nQuiz Not Found Tests:'); await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => { await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404); }); await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => { const quiz = { title: 'Update Non-Existent', questions: [ { text: 'Q?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404); }); await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => { await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404); }); console.log('\nQuiz Source Types Tests:'); let aiQuizId: string | null = null; await test('POST /api/quizzes with ai_generated source and aiTopic', async () => { const aiQuiz = { title: 'AI Generated Quiz', source: 'ai_generated', aiTopic: 'Space Exploration', questions: [ { text: 'What planet is known as the Red Planet?', timeLimit: 20, options: [ { text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' }, { text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' }, { text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); const result = data as { id: string }; if (!result.id) throw new Error('Missing quiz id'); aiQuizId = result.id; }); await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => { if (!aiQuizId) throw new Error('No AI quiz created'); const { data } = await request('GET', `/api/quizzes/${aiQuizId}`); const quiz = data as Record; if (quiz.source !== 'ai_generated') throw new Error('Wrong source'); if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic'); }); await test('GET /api/quizzes list includes source and questionCount', async () => { const { data } = await request('GET', '/api/quizzes'); const quizzes = data as Record[]; if (quizzes.length === 0) throw new Error('Expected at least one quiz'); const quiz = quizzes.find((q) => q.id === aiQuizId); if (!quiz) throw new Error('AI quiz not in list'); if (quiz.source !== 'ai_generated') throw new Error('Missing source in list'); if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount'); if (quiz.questionCount !== 1) throw new Error('Wrong questionCount'); }); await test('DELETE cleanup AI quiz', async () => { if (!aiQuizId) throw new Error('No AI quiz to delete'); await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204); }); console.log('\nQuiz with Multiple Questions Tests:'); let multiQuizId: string | null = null; await test('POST /api/quizzes with multiple questions', async () => { const multiQuiz = { title: 'Multi-Question Quiz', source: 'manual', questions: [ { text: 'Question 1?', timeLimit: 15, options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, { text: 'Question 2?', timeLimit: 25, options: [ { text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: 'Y', isCorrect: true, shape: 'square', color: 'green' }, ], }, { text: 'Question 3?', timeLimit: 30, options: [ { text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' }, { text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', multiQuiz, 201); const result = data as { id: string }; multiQuizId = result.id; }); await test('GET /api/quizzes/:id returns all questions with correct order', async () => { if (!multiQuizId) throw new Error('No multi-question quiz created'); const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] }; if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`); if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1'); if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2'); if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3'); if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1'); if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field'); }); await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => { const { data } = await request('GET', '/api/quizzes'); const quizzes = data as Record[]; const quiz = quizzes.find((q) => q.id === multiQuizId); if (!quiz) throw new Error('Multi-question quiz not in list'); if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`); }); await test('PUT /api/quizzes/:id replaces all questions', async () => { if (!multiQuizId) throw new Error('No multi-question quiz created'); const updatedQuiz = { title: 'Updated Multi Quiz', questions: [ { text: 'Only One Question Now', timeLimit: 10, options: [ { text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'Duo', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz); const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); const quiz = data as { title: string; questions: unknown[] }; if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated'); if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`); }); await test('DELETE cleanup multi-question quiz', async () => { if (!multiQuizId) throw new Error('No multi-question quiz to delete'); await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204); }); console.log('\nTimestamp Tests:'); let timestampQuizId: string | null = null; await test('POST /api/quizzes returns quiz with timestamps', async () => { const quiz = { title: 'Timestamp Test Quiz', source: 'manual', questions: [ { text: 'Timestamp Q?', 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); timestampQuizId = (createData as { id: string }).id; const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`); const result = data as Record; if (!result.createdAt) throw new Error('Missing createdAt'); if (!result.updatedAt) throw new Error('Missing updatedAt'); }); await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => { if (!timestampQuizId) throw new Error('No timestamp quiz created'); const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`); const beforeUpdatedAt = (beforeData as Record).updatedAt; await new Promise((resolve) => setTimeout(resolve, 1100)); await request('PUT', `/api/quizzes/${timestampQuizId}`, { title: 'Updated Timestamp Quiz', questions: [ { text: 'Updated Q?', options: [ { text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }, { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' }, ], }, ], }); const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`); const afterUpdatedAt = (afterData as Record).updatedAt; if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed'); }); await test('DELETE cleanup timestamp quiz', async () => { if (!timestampQuizId) throw new Error('No timestamp quiz to delete'); await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); }); console.log('\nSave Integration Tests (Phase 5):'); let manualSaveQuizId: string | null = null; let aiSaveQuizId: string | null = null; await test('POST /api/quizzes manual quiz without aiTopic', async () => { const manualQuiz = { title: 'Manual Save Test Quiz', source: 'manual', questions: [ { text: 'What is 1+1?', timeLimit: 20, options: [ { text: '1', isCorrect: false, shape: 'triangle', color: 'red' }, { text: '2', isCorrect: true, shape: 'diamond', color: 'blue' }, { text: '3', isCorrect: false, shape: 'circle', color: 'yellow' }, { text: '4', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', manualQuiz, 201); const result = data as { id: string }; manualSaveQuizId = result.id; }); await test('GET manual quiz has null aiTopic', async () => { if (!manualSaveQuizId) throw new Error('No manual quiz created'); const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`); const quiz = data as Record; if (quiz.source !== 'manual') throw new Error('Wrong source'); if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`); }); await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => { const aiQuiz = { title: 'AI Quiz Empty Topic', source: 'ai_generated', aiTopic: '', questions: [ { text: 'Test?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); aiSaveQuizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`); const quiz = getResult as Record; if (quiz.aiTopic !== null && quiz.aiTopic !== '') { throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`); } }); await test('DELETE cleanup manual save quiz', async () => { if (manualSaveQuizId) { await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204); } }); await test('DELETE cleanup ai save quiz', async () => { if (aiSaveQuizId) { await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204); } }); console.log('\nOption Preservation Tests:'); let optionQuizId: string | null = null; await test('POST /api/quizzes preserves all option fields including reason', async () => { const quizWithReasons = { title: 'Quiz With Reasons', source: 'manual', questions: [ { text: 'Capital of France?', timeLimit: 15, options: [ { text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' }, { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' }, { text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' }, { text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201); optionQuizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`); const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] }; const options = quiz.questions[0].options; if (options.length !== 4) throw new Error('Expected 4 options'); const parisOpt = options.find(o => o.text === 'Paris'); if (!parisOpt) throw new Error('Paris option not found'); if (!parisOpt.isCorrect) throw new Error('Paris should be correct'); if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved'); const londonOpt = options.find(o => o.text === 'London'); if (!londonOpt) throw new Error('London option not found'); if (londonOpt.isCorrect) throw new Error('London should not be correct'); if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved'); }); await test('POST /api/quizzes options without reason field are preserved', async () => { const quizNoReasons = { title: 'Quiz Without Reasons', source: 'ai_generated', aiTopic: 'Geography', questions: [ { text: 'Largest ocean?', options: [ { text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' }, { text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const quiz = getResult as { questions: { options: { reason?: string }[] }[] }; const options = quiz.questions[0].options; const pacificOpt = options.find((o: any) => o.text === 'Pacific'); if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) { throw new Error('Expected null/undefined reason for option without reason'); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('DELETE cleanup option quiz', async () => { if (optionQuizId) { await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204); } }); console.log('\nConcurrent Save Tests:'); await test('Multiple quizzes can be saved by same user', async () => { const quiz1 = { title: 'Concurrent Quiz 1', source: 'manual', questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], }; const quiz2 = { title: 'Concurrent Quiz 2', source: 'ai_generated', aiTopic: 'Science', questions: [{ text: 'Q2?', options: [{ text: 'C', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }] }], }; const [res1, res2] = await Promise.all([ request('POST', '/api/quizzes', quiz1, 201), request('POST', '/api/quizzes', quiz2, 201), ]); const id1 = (res1.data as { id: string }).id; const id2 = (res2.data as { id: string }).id; if (id1 === id2) throw new Error('Quiz IDs should be unique'); const { data: listData } = await request('GET', '/api/quizzes'); const list = listData as { id: string; title: string }[]; const found1 = list.find(q => q.id === id1); const found2 = list.find(q => q.id === id2); if (!found1) throw new Error('Quiz 1 not in list'); if (!found2) throw new Error('Quiz 2 not in list'); await Promise.all([ request('DELETE', `/api/quizzes/${id1}`, undefined, 204), request('DELETE', `/api/quizzes/${id2}`, undefined, 204), ]); }); console.log('\nEdge Case Tests:'); await test('POST /api/quizzes with very long title', async () => { const longTitle = 'A'.repeat(500); const quiz = { title: longTitle, source: 'manual', questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); if ((getResult as { title: string }).title !== longTitle) { throw new Error('Long title not preserved'); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('POST /api/quizzes with special characters in title', async () => { const specialTitle = 'Quiz with "quotes" & and emoji test'; const quiz = { title: specialTitle, source: 'manual', questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); if ((getResult as { title: string }).title !== specialTitle) { throw new Error('Special characters not preserved'); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('POST /api/quizzes with whitespace-only title returns 400', async () => { const quiz = { title: ' ', source: 'manual', questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }], }; await request('POST', '/api/quizzes', quiz, 400); }); console.log('\nPhase 6 - Error Handling Tests:'); await test('POST /api/quizzes with question without text returns 400', async () => { const invalidQuiz = { title: 'Quiz with empty question', source: 'manual', questions: [ { text: '', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with question with only one option returns 400', async () => { const invalidQuiz = { title: 'Quiz with single option', source: 'manual', questions: [ { text: 'Question with one option?', options: [ { text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with question with no correct answer returns 400', async () => { const invalidQuiz = { title: 'Quiz with no correct answer', source: 'manual', questions: [ { text: 'Question with no correct?', options: [ { text: 'A', isCorrect: false, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with invalid source type returns 400', async () => { const invalidQuiz = { title: 'Quiz with invalid source', source: 'invalid_source_type', questions: [ { text: 'Question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with null questions returns 400', async () => { const invalidQuiz = { title: 'Quiz with null questions', source: 'manual', questions: null, }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('POST /api/quizzes with question missing options returns 400', async () => { const invalidQuiz = { title: 'Quiz missing options', source: 'manual', questions: [ { text: 'Question without options?', }, ], }; await request('POST', '/api/quizzes', invalidQuiz, 400); }); await test('PUT /api/quizzes/:id with invalid data returns 400', async () => { const validQuiz = { title: 'Valid Quiz for Update Test', source: 'manual', questions: [ { text: 'Valid question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: '', questions: [], }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); console.log('\nPhase 6 - Malformed Request Tests:'); await test('POST /api/quizzes with malformed JSON returns 400', async () => { const res = await fetch(`${API_URL}/api/quizzes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: '{ invalid json }', }); if (res.status !== 400) throw new Error(`Expected 400, got ${res.status}`); }); await test('GET /api/quizzes/:id with very long ID returns 404', async () => { const longId = 'a'.repeat(1000); await request('GET', `/api/quizzes/${longId}`, undefined, 404); }); await test('DELETE /api/quizzes/:id with SQL injection attempt returns 404', async () => { const maliciousId = "'; DROP TABLE quizzes; --"; await request('DELETE', `/api/quizzes/${encodeURIComponent(maliciousId)}`, undefined, 404); }); console.log('\nPhase 6 - Content Type Tests:'); await test('POST /api/quizzes without Content-Type still works with JSON body', async () => { const quiz = { title: 'No Content-Type Quiz', source: 'manual', questions: [ { text: 'Question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const res = await fetch(`${API_URL}/api/quizzes`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(quiz), }); if (res.status !== 201) throw new Error(`Expected 201, got ${res.status}`); const data = await res.json(); await request('DELETE', `/api/quizzes/${data.id}`, undefined, 204); }); console.log('\nPhase 6 - Boundary Tests:'); await test('POST /api/quizzes with many questions succeeds', async () => { const manyQuestions = Array.from({ length: 50 }, (_, i) => ({ text: `Question ${i + 1}?`, timeLimit: 20, options: [ { text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' }, { text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' }, ], })); const quiz = { title: '50 Question Quiz', source: 'manual', questions: manyQuestions, }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const savedQuiz = getResult as { questions: unknown[] }; if (savedQuiz.questions.length !== 50) { throw new Error(`Expected 50 questions, got ${savedQuiz.questions.length}`); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('POST /api/quizzes with many options per question succeeds', async () => { const manyOptions = Array.from({ length: 10 }, (_, i) => ({ text: `Option ${i + 1}`, isCorrect: i === 0, shape: 'triangle', color: 'red', })); const quiz = { title: '10 Options Quiz', source: 'manual', questions: [ { text: 'Question with 10 options?', options: manyOptions, }, ], }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const savedQuiz = getResult as { questions: { options: unknown[] }[] }; if (savedQuiz.questions[0].options.length !== 10) { throw new Error(`Expected 10 options, got ${savedQuiz.questions[0].options.length}`); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('POST /api/quizzes with unicode characters in all fields', async () => { const unicodeQuiz = { title: 'Emoji Quiz title test', source: 'manual', aiTopic: 'Japanese test', questions: [ { text: 'What is this character?', timeLimit: 30, options: [ { text: 'Option A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Because Emoji test!' }, { text: 'Chinese characters', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', unicodeQuiz, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const savedQuiz = getResult as { title: string; questions: { text: string; options: { text: string }[] }[] }; if (savedQuiz.title !== 'Emoji Quiz title test') throw new Error('Unicode title not preserved'); if (savedQuiz.questions[0].text !== 'What is this character?') throw new Error('Unicode question not preserved'); if (savedQuiz.questions[0].options[1].text !== 'Chinese characters') throw new Error('Unicode option not preserved'); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); console.log('\nPUT Endpoint Edge Case Tests:'); await test('PUT /api/quizzes/:id with whitespace-only title returns 400', async () => { const validQuiz = { title: 'Quiz for PUT whitespace test', source: 'manual', questions: [ { text: 'Question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: ' ', questions: [ { text: 'Q?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id with question without text returns 400', async () => { const validQuiz = { title: 'Quiz for PUT empty question test', source: 'manual', questions: [ { text: 'Original question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: 'Updated title', questions: [ { text: '', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id with single option returns 400', async () => { const validQuiz = { title: 'Quiz for PUT single option test', source: 'manual', questions: [ { text: 'Original question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: 'Updated title', questions: [ { text: 'Question with one option?', options: [ { text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id with no correct answer returns 400', async () => { const validQuiz = { title: 'Quiz for PUT no correct test', source: 'manual', questions: [ { text: 'Original question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: 'Updated title', questions: [ { text: 'Question with no correct?', options: [ { text: 'A', isCorrect: false, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id with null questions returns 400', async () => { const validQuiz = { title: 'Quiz for PUT null questions test', source: 'manual', questions: [ { text: 'Original question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const invalidUpdate = { title: 'Updated title', questions: null, }; await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id preserves source and aiTopic', async () => { const aiQuiz = { title: 'AI Quiz for PUT preserve test', source: 'ai_generated', aiTopic: 'History', questions: [ { text: 'Original question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); const quizId = (data as { id: string }).id; const update = { title: 'Updated AI Quiz', questions: [ { text: 'New question?', options: [ { text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'Y', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, update); const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const quiz = getResult as Record; if (quiz.source !== 'ai_generated') throw new Error('Source should be preserved'); if (quiz.aiTopic !== 'History') throw new Error('aiTopic should be preserved'); if (quiz.title !== 'Updated AI Quiz') throw new Error('Title should be updated'); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id on another users quiz returns 404', async () => { const quiz = { title: 'User isolation test', questions: [ { text: 'Q?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; await request('PUT', '/api/quizzes/non-existent-user-quiz-id', quiz, 404); }); await test('PUT /api/quizzes/:id with many questions succeeds', async () => { const validQuiz = { title: 'Quiz for PUT many questions test', source: 'manual', questions: [ { text: 'Original?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const manyQuestions = Array.from({ length: 30 }, (_, i) => ({ text: `Updated question ${i + 1}?`, timeLimit: 15, options: [ { text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' }, { text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' }, ], })); const update = { title: 'Quiz with 30 questions', questions: manyQuestions, }; await request('PUT', `/api/quizzes/${quizId}`, update); const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const quiz = getResult as { questions: unknown[] }; if (quiz.questions.length !== 30) { throw new Error(`Expected 30 questions, got ${quiz.questions.length}`); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('PUT /api/quizzes/:id preserves reason fields in options', async () => { const validQuiz = { title: 'Quiz for PUT reason test', source: 'manual', questions: [ { text: 'Original?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', validQuiz, 201); const quizId = (data as { id: string }).id; const updateWithReasons = { title: 'Quiz with reasons', questions: [ { text: 'Why is the sky blue?', options: [ { text: 'Rayleigh scattering', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Light scatters in atmosphere' }, { text: 'Paint', isCorrect: false, shape: 'diamond', color: 'blue', reason: 'That is not how it works' }, ], }, ], }; await request('PUT', `/api/quizzes/${quizId}`, updateWithReasons); const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const quiz = getResult as { questions: { options: { reason?: string }[] }[] }; const correctOpt = quiz.questions[0].options.find((o: any) => o.isCorrect); if (correctOpt?.reason !== 'Light scatters in atmosphere') { throw new Error('Reason not preserved on update'); } await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); console.log('\nGame Config Tests:'); let gameConfigQuizId: string | null = null; await test('POST /api/quizzes with gameConfig saves config', async () => { const quizWithConfig = { title: 'Quiz With Game Config', source: 'manual', gameConfig: { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: false, streakBonusEnabled: true, streakThreshold: 5, streakMultiplier: 1.5, comebackBonusEnabled: true, comebackBonusPoints: 100, penaltyForWrongAnswer: true, penaltyPercent: 30, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 75, }, questions: [ { text: 'Config test question?', timeLimit: 20, options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quizWithConfig, 201); gameConfigQuizId = (data as { id: string }).id; }); await test('GET /api/quizzes/:id returns gameConfig', async () => { if (!gameConfigQuizId) throw new Error('No game config quiz created'); const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); const quiz = data as Record; if (!quiz.gameConfig) throw new Error('Missing gameConfig'); const config = quiz.gameConfig as Record; if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not preserved'); if (config.shuffleAnswers !== true) throw new Error('shuffleAnswers not preserved'); if (config.hostParticipates !== false) throw new Error('hostParticipates not preserved'); if (config.streakBonusEnabled !== true) throw new Error('streakBonusEnabled not preserved'); if (config.streakThreshold !== 5) throw new Error('streakThreshold not preserved'); if (config.streakMultiplier !== 1.5) throw new Error('streakMultiplier not preserved'); if (config.comebackBonusEnabled !== true) throw new Error('comebackBonusEnabled not preserved'); if (config.comebackBonusPoints !== 100) throw new Error('comebackBonusPoints not preserved'); if (config.penaltyForWrongAnswer !== true) throw new Error('penaltyForWrongAnswer not preserved'); if (config.penaltyPercent !== 30) throw new Error('penaltyPercent not preserved'); if (config.firstCorrectBonusEnabled !== true) throw new Error('firstCorrectBonusEnabled not preserved'); if (config.firstCorrectBonusPoints !== 75) throw new Error('firstCorrectBonusPoints not preserved'); }); await test('PUT /api/quizzes/:id updates gameConfig', async () => { if (!gameConfigQuizId) throw new Error('No game config quiz created'); const updatedQuiz = { title: 'Updated Config Quiz', gameConfig: { shuffleQuestions: false, 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: 'Updated question?', options: [ { text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'Y', isCorrect: false, shape: 'square', color: 'green' }, ], }, ], }; await request('PUT', `/api/quizzes/${gameConfigQuizId}`, updatedQuiz); const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); const quiz = data as Record; const config = quiz.gameConfig as Record; if (config.shuffleQuestions !== false) throw new Error('shuffleQuestions not updated'); if (config.hostParticipates !== true) throw new Error('hostParticipates not updated'); if (config.streakBonusEnabled !== false) throw new Error('streakBonusEnabled not updated'); }); await test('PATCH /api/quizzes/:id/config updates only gameConfig', async () => { if (!gameConfigQuizId) throw new Error('No game config quiz created'); const newConfig = { gameConfig: { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: true, streakBonusEnabled: true, streakThreshold: 4, streakMultiplier: 1.3, comebackBonusEnabled: true, comebackBonusPoints: 150, penaltyForWrongAnswer: true, penaltyPercent: 20, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 100, }, }; await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, newConfig); const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); const quiz = data as Record; const config = quiz.gameConfig as Record; if (config.shuffleQuestions !== true) throw new Error('PATCH did not update shuffleQuestions'); if (config.streakThreshold !== 4) throw new Error('PATCH did not update streakThreshold'); if (config.comebackBonusPoints !== 150) throw new Error('PATCH did not update comebackBonusPoints'); if (quiz.title !== 'Updated Config Quiz') throw new Error('PATCH should not have changed title'); }); await test('PATCH /api/quizzes/:id/config with non-existent ID returns 404', async () => { const config = { 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, }, }; await request('PATCH', '/api/quizzes/non-existent-id/config', config, 404); }); await test('PATCH /api/quizzes/:id/config with null gameConfig clears config', async () => { if (!gameConfigQuizId) throw new Error('No game config quiz created'); await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, { gameConfig: null }); const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); const quiz = data as Record; if (quiz.gameConfig !== null) throw new Error('gameConfig should be null after clearing'); }); await test('POST /api/quizzes without gameConfig sets null config', async () => { const quizNoConfig = { title: 'Quiz Without Config', source: 'manual', questions: [ { text: 'No config question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quizNoConfig, 201); const quizId = (data as { id: string }).id; const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); const quiz = getResult as Record; if (quiz.gameConfig !== null) throw new Error('Expected null gameConfig for quiz without config'); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); await test('DELETE cleanup game config quiz', async () => { if (gameConfigQuizId) { await request('DELETE', `/api/quizzes/${gameConfigQuizId}`, undefined, 204); } }); console.log('\nUser Default Config Tests:'); await test('GET /api/users/me returns defaultGameConfig', async () => { const { data } = await request('GET', '/api/users/me'); const user = data as Record; if (!('defaultGameConfig' in user)) throw new Error('Missing defaultGameConfig field'); }); await test('PUT /api/users/me/default-config saves default config', async () => { const defaultConfig = { defaultGameConfig: { shuffleQuestions: true, shuffleAnswers: true, hostParticipates: false, streakBonusEnabled: true, streakThreshold: 4, streakMultiplier: 1.25, comebackBonusEnabled: true, comebackBonusPoints: 75, penaltyForWrongAnswer: true, penaltyPercent: 15, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 60, }, }; await request('PUT', '/api/users/me/default-config', defaultConfig); const { data } = await request('GET', '/api/users/me'); const user = data as Record; const config = user.defaultGameConfig as Record; if (!config) throw new Error('defaultGameConfig not saved'); if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not saved'); if (config.streakThreshold !== 4) throw new Error('streakThreshold not saved'); if (config.comebackBonusPoints !== 75) throw new Error('comebackBonusPoints not saved'); }); await test('PUT /api/users/me/default-config with null clears config', async () => { await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null }); const { data } = await request('GET', '/api/users/me'); const user = data as Record; if (user.defaultGameConfig !== null) throw new Error('defaultGameConfig should be null after clearing'); }); await test('PUT /api/users/me/default-config with partial config saves as-is', async () => { const partialConfig = { defaultGameConfig: { shuffleQuestions: true, hostParticipates: false, }, }; await request('PUT', '/api/users/me/default-config', partialConfig); const { data } = await request('GET', '/api/users/me'); const user = data as Record; const config = user.defaultGameConfig as Record; if (!config) throw new Error('Partial config not saved'); if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not in partial config'); if (config.hostParticipates !== false) throw new Error('hostParticipates not in partial config'); }); await test('PUT /api/users/me/default-config cleanup - reset to null', async () => { await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null }); }); console.log('\nPhase 6 - Duplicate/Idempotency Tests:'); await test('POST /api/quizzes with same data creates separate quizzes', async () => { const quiz = { title: 'Duplicate Test Quiz', source: 'manual', questions: [ { text: 'Same question?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data: data1 } = await request('POST', '/api/quizzes', quiz, 201); const { data: data2 } = await request('POST', '/api/quizzes', quiz, 201); const id1 = (data1 as { id: string }).id; const id2 = (data2 as { id: string }).id; if (id1 === id2) throw new Error('Duplicate POST should create separate quizzes with unique IDs'); await request('DELETE', `/api/quizzes/${id1}`, undefined, 204); await request('DELETE', `/api/quizzes/${id2}`, undefined, 204); }); await test('DELETE /api/quizzes/:id twice returns 404 on second call', async () => { const quiz = { title: 'Double Delete Quiz', source: 'manual', questions: [ { text: 'Q?', options: [ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, ], }, ], }; const { data } = await request('POST', '/api/quizzes', quiz, 201); const quizId = (data as { id: string }).id; await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); await request('DELETE', `/api/quizzes/${quizId}`, undefined, 404); }); console.log('\n=== Game Session Tests ==='); async function gameRequest( method: string, path: string, body?: unknown, headers?: Record, expectStatus = 200 ): Promise<{ status: number; data: unknown }> { const response = await fetch(`${API_URL}${path}`, { method, headers: { 'Content-Type': 'application/json', ...headers, }, body: body ? JSON.stringify(body) : undefined, }); const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : null; if (response.status !== expectStatus) { throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`); } return { status: response.status, data }; } let testGamePin: string | null = null; let testHostSecret: string | null = null; console.log('\nGame Session CRUD Tests:'); await test('POST /api/games creates game session', async () => { const gameData = { pin: '123456', hostPeerId: 'kaboot-123456', quiz: { title: 'Test Quiz', questions: [ { id: 'q1', text: 'What is 2+2?', options: [ { text: '3', isCorrect: false, shape: 'triangle', color: 'red' }, { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' }, ], timeLimit: 20, }, ], }, gameConfig: { shuffleQuestions: false, shuffleAnswers: false, hostParticipates: true, streakBonusEnabled: false, streakThreshold: 3, streakMultiplier: 1.1, comebackBonusEnabled: false, comebackBonusPoints: 50, penaltyForWrongAnswer: false, penaltyPercent: 25, firstCorrectBonusEnabled: false, firstCorrectBonusPoints: 50, }, }; const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); const result = data as { success: boolean; hostSecret: string }; if (!result.success) throw new Error('Expected success: true'); if (!result.hostSecret) throw new Error('Missing hostSecret'); if (result.hostSecret.length < 32) throw new Error('hostSecret too short'); testGamePin = '123456'; testHostSecret = result.hostSecret; }); await test('GET /api/games/:pin returns game info', async () => { if (!testGamePin) throw new Error('No game created'); const { data } = await gameRequest('GET', `/api/games/${testGamePin}`); const game = data as Record; if (game.pin !== testGamePin) throw new Error('Wrong PIN'); if (game.hostPeerId !== 'kaboot-123456') throw new Error('Wrong hostPeerId'); if (game.gameState !== 'LOBBY') throw new Error('Wrong initial gameState'); if (game.currentQuestionIndex !== 0) throw new Error('Wrong initial currentQuestionIndex'); if (game.quizTitle !== 'Test Quiz') throw new Error('Wrong quizTitle'); if (game.playerCount !== 0) throw new Error('Wrong initial playerCount'); }); await test('GET /api/games/:pin/host returns full game state with valid secret', async () => { if (!testGamePin || !testHostSecret) throw new Error('No game created'); const { data } = await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, { 'X-Host-Secret': testHostSecret, }); const game = data as Record; if (game.pin !== testGamePin) throw new Error('Wrong PIN'); if (!game.quiz) throw new Error('Missing quiz'); if (!game.gameConfig) throw new Error('Missing gameConfig'); if (!Array.isArray(game.players)) throw new Error('Missing players array'); const quiz = game.quiz as Record; if (quiz.title !== 'Test Quiz') throw new Error('Wrong quiz title'); }); await test('PATCH /api/games/:pin updates game state', async () => { if (!testGamePin || !testHostSecret) throw new Error('No game created'); const update = { gameState: 'QUESTION', currentQuestionIndex: 1, players: [ { id: 'player1', name: 'Alice', score: 500, previousScore: 0, streak: 1, lastAnswerCorrect: true, pointsBreakdown: null, isBot: false, avatarSeed: 0.5, color: '#2563eb', }, ], }; await gameRequest('PATCH', `/api/games/${testGamePin}`, update, { 'X-Host-Secret': testHostSecret, }); const { data } = await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, { 'X-Host-Secret': testHostSecret, }); const game = data as Record; if (game.gameState !== 'QUESTION') throw new Error('gameState not updated'); if (game.currentQuestionIndex !== 1) throw new Error('currentQuestionIndex not updated'); const players = game.players as Record[]; if (players.length !== 1) throw new Error('players not updated'); if (players[0].name !== 'Alice') throw new Error('player name not saved'); if (players[0].score !== 500) throw new Error('player score not saved'); }); await test('PATCH /api/games/:pin updates hostPeerId', async () => { if (!testGamePin || !testHostSecret) throw new Error('No game created'); await gameRequest('PATCH', `/api/games/${testGamePin}`, { hostPeerId: 'new-peer-id-12345' }, { 'X-Host-Secret': testHostSecret, }); const { data } = await gameRequest('GET', `/api/games/${testGamePin}`); const game = data as Record; if (game.hostPeerId !== 'new-peer-id-12345') throw new Error('hostPeerId not updated'); }); console.log('\nGame Session Auth Tests:'); await test('GET /api/games/:pin/host without secret returns 401', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, {}, 401); }); await test('GET /api/games/:pin/host with wrong secret returns 404', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, { 'X-Host-Secret': 'wrong-secret-12345', }, 404); }); await test('PATCH /api/games/:pin without secret returns 401', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('PATCH', `/api/games/${testGamePin}`, { gameState: 'LOBBY' }, {}, 401); }); await test('PATCH /api/games/:pin with wrong secret returns 404', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('PATCH', `/api/games/${testGamePin}`, { gameState: 'LOBBY' }, { 'X-Host-Secret': 'wrong-secret-12345', }, 404); }); await test('DELETE /api/games/:pin without secret returns 401', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, {}, 401); }); await test('DELETE /api/games/:pin with wrong secret returns 404', async () => { if (!testGamePin) throw new Error('No game created'); await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, { 'X-Host-Secret': 'wrong-secret-12345', }, 404); }); console.log('\nGame Session Not Found Tests:'); await test('GET /api/games/:pin with non-existent PIN returns 404', async () => { await gameRequest('GET', '/api/games/999999', undefined, {}, 404); }); await test('GET /api/games/:pin/host with non-existent PIN returns 404', async () => { await gameRequest('GET', '/api/games/999999/host', undefined, { 'X-Host-Secret': 'any-secret', }, 404); }); await test('PATCH /api/games/:pin with non-existent PIN returns 404', async () => { await gameRequest('PATCH', '/api/games/999999', { gameState: 'LOBBY' }, { 'X-Host-Secret': 'any-secret', }, 404); }); console.log('\nGame Session Validation Tests:'); await test('POST /api/games without pin returns 400', async () => { const invalidGame = { hostPeerId: 'peer-123', quiz: { title: 'Test', questions: [] }, gameConfig: {}, }; await gameRequest('POST', '/api/games', invalidGame, {}, 400); }); await test('POST /api/games without hostPeerId returns 400', async () => { const invalidGame = { pin: '111111', quiz: { title: 'Test', questions: [] }, gameConfig: {}, }; await gameRequest('POST', '/api/games', invalidGame, {}, 400); }); await test('POST /api/games without quiz returns 400', async () => { const invalidGame = { pin: '111111', hostPeerId: 'peer-123', gameConfig: {}, }; await gameRequest('POST', '/api/games', invalidGame, {}, 400); }); await test('POST /api/games without gameConfig returns 400', async () => { const invalidGame = { pin: '111111', hostPeerId: 'peer-123', quiz: { title: 'Test', questions: [] }, }; await gameRequest('POST', '/api/games', invalidGame, {}, 400); }); await test('POST /api/games with duplicate PIN returns 409', async () => { if (!testGamePin) throw new Error('No game created'); const duplicateGame = { pin: testGamePin, hostPeerId: 'another-peer', quiz: { title: 'Another Quiz', questions: [] }, gameConfig: {}, }; await gameRequest('POST', '/api/games', duplicateGame, {}, 409); }); await test('PATCH /api/games/:pin with no updates returns 400', async () => { if (!testGamePin || !testHostSecret) throw new Error('No game created'); await gameRequest('PATCH', `/api/games/${testGamePin}`, {}, { 'X-Host-Secret': testHostSecret, }, 400); }); console.log('\nGame Session Delete Tests:'); await test('DELETE /api/games/:pin with valid secret succeeds', async () => { if (!testGamePin || !testHostSecret) throw new Error('No game created'); await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, { 'X-Host-Secret': testHostSecret, }); await gameRequest('GET', `/api/games/${testGamePin}`, undefined, {}, 404); }); await test('DELETE /api/games/:pin twice returns 404 on second call', async () => { const gameData = { pin: '222222', hostPeerId: 'kaboot-222222', quiz: { title: 'Delete Test Quiz', questions: [] }, gameConfig: {}, }; const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); const secret = (data as { hostSecret: string }).hostSecret; await gameRequest('DELETE', '/api/games/222222', undefined, { 'X-Host-Secret': secret, }); await gameRequest('DELETE', '/api/games/222222', undefined, { 'X-Host-Secret': secret, }, 404); }); console.log('\nGame Session Edge Cases:'); await test('POST /api/games with special characters in quiz title', async () => { const gameData = { pin: '333333', hostPeerId: 'kaboot-333333', quiz: { title: 'Quiz with "quotes" & and special chars', questions: [], }, gameConfig: {}, }; const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); const secret = (data as { hostSecret: string }).hostSecret; const { data: getResult } = await gameRequest('GET', '/api/games/333333'); const game = getResult as Record; if (game.quizTitle !== 'Quiz with "quotes" & and special chars') { throw new Error('Special characters not preserved in quiz title'); } await gameRequest('DELETE', '/api/games/333333', undefined, { 'X-Host-Secret': secret, }); }); await test('POST /api/games with large player data', async () => { const gameData = { pin: '444444', hostPeerId: 'kaboot-444444', quiz: { title: 'Large Players Test', questions: [] }, gameConfig: {}, }; const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); const secret = (data as { hostSecret: string }).hostSecret; const manyPlayers = Array.from({ length: 50 }, (_, i) => ({ id: `player-${i}`, name: `Player ${i}`, score: i * 100, previousScore: 0, streak: i % 5, lastAnswerCorrect: i % 2 === 0, pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: '#2563eb', })); await gameRequest('PATCH', '/api/games/444444', { players: manyPlayers }, { 'X-Host-Secret': secret, }); const { data: getResult } = await gameRequest('GET', '/api/games/444444/host', undefined, { 'X-Host-Secret': secret, }); const game = getResult as Record; const players = game.players as unknown[]; if (players.length !== 50) throw new Error(`Expected 50 players, got ${players.length}`); await gameRequest('DELETE', '/api/games/444444', undefined, { 'X-Host-Secret': secret, }); }); await test('GET /api/games/:pin public endpoint does not expose hostSecret', async () => { const gameData = { pin: '555555', hostPeerId: 'kaboot-555555', quiz: { title: 'Secret Test', questions: [] }, gameConfig: {}, }; const { data: createResult } = await gameRequest('POST', '/api/games', gameData, {}, 201); const secret = (createResult as { hostSecret: string }).hostSecret; const { data: getResult } = await gameRequest('GET', '/api/games/555555'); const game = getResult as Record; if ('hostSecret' in game) throw new Error('hostSecret should not be exposed in public endpoint'); if ('host_secret' in game) throw new Error('host_secret should not be exposed in public endpoint'); await gameRequest('DELETE', '/api/games/555555', undefined, { 'X-Host-Secret': secret, }); }); await test('Game session tracks updated_at on PATCH', async () => { const gameData = { pin: '666666', hostPeerId: 'kaboot-666666', quiz: { title: 'Timestamp Test', questions: [] }, gameConfig: {}, }; const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); const secret = (data as { hostSecret: string }).hostSecret; await new Promise((resolve) => setTimeout(resolve, 100)); await gameRequest('PATCH', '/api/games/666666', { gameState: 'COUNTDOWN' }, { 'X-Host-Secret': secret, }); await gameRequest('DELETE', '/api/games/666666', undefined, { 'X-Host-Secret': secret, }); }); console.log('\n=== AI Generate Endpoint Tests ==='); console.log('\nGenerate Status Tests:'); await test('GET /api/generate/status returns availability status', async () => { const res = await fetch(`${API_URL}/api/generate/status`); if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`); const data = await res.json(); if (typeof data.available !== 'boolean') throw new Error('Missing or invalid available field'); if (typeof data.model !== 'string') throw new Error('Missing or invalid model field'); }); console.log('\nGenerate Auth Tests:'); await test('POST /api/generate without token returns 401', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topic: 'Test topic' }), }); if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); }); await test('POST /api/generate with invalid token returns 401', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer invalid-token-12345', }, body: JSON.stringify({ topic: 'Test topic' }), }); if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); }); console.log('\nGenerate Validation Tests:'); await test('POST /api/generate without topic or documents returns error', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({}), }); const VALID_STATUSES = [400, 403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); await test('POST /api/generate with empty topic and no documents returns error', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ topic: '', documents: [] }), }); const VALID_STATUSES = [400, 403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); await test('POST /api/generate with only questionCount returns error', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ questionCount: 5 }), }); const VALID_STATUSES = [400, 403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); console.log('\nGenerate Access Control Tests:'); await test('POST /api/generate with valid auth checks group access', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ topic: 'Test topic' }), }); const VALID_STATUSES = [200, 403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); console.log('\nGenerate with Topic Tests:'); await test('POST /api/generate with valid topic returns quiz or expected error', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ topic: 'World History', questionCount: 3 }), }); const data = await res.json(); if (res.status === 403) { if (!data.error) throw new Error('403 response missing error message'); } else if (res.status === 503) { if (!data.error) throw new Error('503 response missing error message'); } else if (res.status === 200) { if (!data.title) throw new Error('Missing quiz title'); if (!Array.isArray(data.questions)) throw new Error('Missing questions array'); if (data.questions.length === 0) throw new Error('Empty questions array'); const q = data.questions[0]; if (!q.id) throw new Error('Missing question id'); if (!q.text) throw new Error('Missing question text'); if (!Array.isArray(q.options)) throw new Error('Missing options array'); if (q.options.length !== 4) throw new Error('Expected 4 options per question'); const opt = q.options[0]; if (!opt.text) throw new Error('Missing option text'); if (typeof opt.isCorrect !== 'boolean') throw new Error('Missing option isCorrect'); if (!opt.shape) throw new Error('Missing option shape'); if (!opt.color) throw new Error('Missing option color'); } else { throw new Error(`Unexpected status ${res.status}: ${JSON.stringify(data)}`); } }); await test('POST /api/generate with documents validates structure', async () => { const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ topic: 'Document content', documents: [ { type: 'text', content: 'This is test content about programming.' } ], questionCount: 2 }), }); const VALID_STATUSES = [200, 403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); await test('GET /api/payments/status returns expected generation limit for access type', async () => { const { data } = await request('GET', '/api/payments/status'); const status = data as Record; const accessType = status.accessType as string; const limit = status.generationLimit as number | null; if (accessType === 'group') { if (limit !== null) throw new Error('Expected null generationLimit for group access'); return; } if (accessType === 'subscription') { if (limit !== 250) throw new Error(`Expected generationLimit 250, got ${limit}`); return; } if (accessType === 'none') { if (limit !== 5) throw new Error(`Expected free tier generationLimit 5, got ${limit}`); return; } throw new Error(`Unexpected accessType: ${accessType}`); }); await test('POST /api/generate with two documents blocks free tier users', async () => { const statusRes = await request('GET', '/api/payments/status'); const status = statusRes.data as Record; const accessType = status.accessType as string; const res = await fetch(`${API_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ topic: 'Document content', documents: [ { type: 'text', content: 'Doc A' }, { type: 'text', content: 'Doc B' } ], questionCount: 2 }), }); if (accessType === 'none') { if (res.status !== 403) throw new Error(`Expected 403 for free tier, got ${res.status}`); const data = await res.json(); if (!data.error || !String(data.error).toLowerCase().includes('document')) { throw new Error('Expected document limit error message'); } return; } const VALID_STATUSES = [200, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); await test('POST /api/upload with OCR blocks free tier users', async () => { const statusRes = await request('GET', '/api/payments/status'); const status = statusRes.data as Record; const accessType = status.accessType as string; const formData = new FormData(); const blob = new Blob(['test content'], { type: 'text/plain' }); formData.append('document', blob, 'test.txt'); formData.append('useOcr', 'true'); const res = await fetch(`${API_URL}/api/upload`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, }, body: formData, }); if (accessType === 'none') { if (res.status !== 403) throw new Error(`Expected 403 for free tier OCR, got ${res.status}`); const data = await res.json(); if (!data.error || !String(data.error).toLowerCase().includes('ocr')) { throw new Error('Expected OCR access error message'); } return; } const VALID_STATUSES = [200, 400]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); console.log('\nPayments Refund Tests:'); await test('POST /api/payments/refund without token returns 401', async () => { const res = await fetch(`${API_URL}/api/payments/refund`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentIntentId: 'pi_test' }), }); if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`); }); await test('POST /api/payments/refund with non-admin returns 403 or 503', async () => { const res = await fetch(`${API_URL}/api/payments/refund`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}`, }, body: JSON.stringify({ paymentIntentId: 'pi_test' }), }); const VALID_STATUSES = [403, 503]; if (!VALID_STATUSES.includes(res.status)) { throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`); } }); 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; console.log(`Passed: ${passed}/${results.length}`); console.log(`Failed: ${failed}/${results.length}`); if (failed > 0) { console.log('\nFailed tests:'); results .filter((r) => !r.passed) .forEach((r) => console.log(` - ${r.name}: ${r.error}`)); process.exit(1); } console.log('\nAll tests passed!'); } runTests().catch((err) => { console.error('Test runner error:', err); process.exit(1); });