kaboot/server/tests/api.test.ts

2784 lines
100 KiB
TypeScript

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="<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<void>) {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>;
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<string, unknown>).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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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" & <tags> 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<string, unknown>;
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<string, unknown>;
if (!quiz.gameConfig) throw new Error('Missing gameConfig');
const config = quiz.gameConfig as Record<string, unknown>;
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<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
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<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
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<string, string>,
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>;
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" & <tags> 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<string, unknown>;
if (game.quizTitle !== 'Quiz with "quotes" & <tags> 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<string, unknown>;
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<string, unknown>;
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('\nHost Proof Validation Tests:');
let authGamePin: string | null = null;
await test('POST /api/games with Authorization token stores host_user_id', async () => {
const gameData = {
pin: '777777',
hostPeerId: 'kaboot-777777',
quiz: { title: 'Auth Test Quiz', questions: [] },
gameConfig: {},
};
const { data } = await gameRequest('POST', '/api/games', gameData, {
'Authorization': `Bearer ${TOKEN}`,
}, 201);
if (!(data as { success: boolean }).success) throw new Error('Expected success: true');
authGamePin = '777777';
});
await test('GET /api/games/:pin/host with valid Authorization token succeeds', async () => {
if (!authGamePin) throw new Error('No auth game created');
const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {
'Authorization': `Bearer ${TOKEN}`,
});
const game = data as Record<string, unknown>;
if (game.pin !== authGamePin) throw new Error('Wrong PIN');
if (!game.quiz) throw new Error('Missing quiz');
});
await test('PATCH /api/games/:pin with valid Authorization token succeeds', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('PATCH', `/api/games/${authGamePin}`, {
gameState: 'QUESTION',
}, {
'Authorization': `Bearer ${TOKEN}`,
});
const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {
'Authorization': `Bearer ${TOKEN}`,
});
const game = data as Record<string, unknown>;
if (game.gameState !== 'QUESTION') throw new Error('gameState not updated');
});
await test('GET /api/games/:pin/host with neither secret nor token returns 401', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {}, 401);
});
await test('PATCH /api/games/:pin with neither secret nor token returns 401', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, {}, 401);
});
await test('DELETE /api/games/:pin with neither secret nor token returns 401', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, {}, 401);
});
await test('GET /api/games/:pin/host with invalid token returns 401', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {
'Authorization': 'Bearer invalid-token-12345',
}, 401);
});
await test('PATCH /api/games/:pin with invalid token returns 401', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, {
'Authorization': 'Bearer invalid-token-12345',
}, 401);
});
await test('DELETE /api/games/:pin with valid Authorization token succeeds', async () => {
if (!authGamePin) throw new Error('No auth game created');
await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, {
'Authorization': `Bearer ${TOKEN}`,
});
await gameRequest('GET', `/api/games/${authGamePin}`, undefined, {}, 404);
});
await test('POST /api/games without auth allows access via secret only', async () => {
const gameData = {
pin: '888888',
hostPeerId: 'kaboot-888888',
quiz: { title: 'No Auth Quiz', questions: [] },
gameConfig: {},
};
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
const secret = (data as { hostSecret: string }).hostSecret;
await gameRequest('GET', `/api/games/888888/host`, undefined, {
'X-Host-Secret': secret,
});
await gameRequest('GET', `/api/games/888888/host`, undefined, {
'Authorization': `Bearer ${TOKEN}`,
}, 404);
await gameRequest('DELETE', '/api/games/888888', 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});