Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
1596 lines
56 KiB
TypeScript
1596 lines
56 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=== 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);
|
|
});
|