kaboot/server/tests/api.test.ts

449 lines
16 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' }],
},
],
};
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' },
],
},
],
};
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' }],
},
],
};
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' }],
},
],
});
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('\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);
});