/** * Integration Tests - Exercise API * * Tests for: * - Fetching exercises * - Submitting answers * - Progress tracking * - Concurrent submission handling */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import request from 'supertest'; import express, { ErrorRequestHandler } from 'express'; import { prisma } from '../../src/shared/database/prisma.client'; import exerciseRoutes from '../../src/modules/exercise/exercise.routes'; import { authRoutes } from '../../src/modules/auth/auth.routes'; import { randomUUID } from 'crypto'; // Simple error handler for tests const testErrorHandler: ErrorRequestHandler = (err, req, res, _next) => { const statusCode = err.statusCode || err.status || 500; const code = err.code || 'INTERNAL_ERROR'; const message = err.message || 'An unexpected error occurred'; res.status(statusCode).json({ success: false, error: { code, message, }, meta: { timestamp: new Date().toISOString(), }, }); }; describe('Exercise API Integration', () => { let app: express.Application; let authToken: string; let userId: string; let moduleId: string; let exerciseId: string; beforeAll(async () => { await prisma.$connect(); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { // Clean up test data in correct order (respecting FK constraints) await prisma.exerciseAttempt.deleteMany({}); await prisma.progress.deleteMany({}); await prisma.exercise.deleteMany({ where: { statement: { contains: 'Test Exercise', }, }, }); // Delete test modules to avoid unique constraint violations await prisma.modules.deleteMany({ where: { name: { contains: 'Test Module', }, }, }); await prisma.refreshToken.deleteMany({}); await prisma.user.deleteMany({ where: { email: { contains: 'test@', }, }, }); // Create fresh app app = express(); app.use(express.json()); app.use('/api/auth', authRoutes); app.use('/api/exercises', exerciseRoutes); // Add error handler last app.use(testErrorHandler); // Create test user const registerResponse = await request(app) .post('/api/auth/register') .send({ email: `test-${randomUUID()}@example.com`, password: 'SecurePass123!', username: `testuser-${randomUUID()}`, }); authToken = registerResponse.body.data.token; userId = registerResponse.body.data.user.id; // Create test module with unique order (random to avoid collisions) const testModule = await prisma.modules.create({ data: { id: randomUUID(), name: 'Test Module', description: 'Test module for exercises', order: Math.floor(Math.random() * 1000000), // Random unique order type: 'FUNDAMENTOS', updatedAt: new Date(), }, }); moduleId = testModule.id; // Create test exercise const exercise = await prisma.exercise.create({ data: { moduleId: moduleId, topicId: null, type: 'CALCULATION', difficulty: 'BASIC', order: 1, statement: 'Test Exercise: What is 2+2?', correctAnswer: '4', points: 10, timeLimitSeconds: 120, hints: [], solutionSteps: [], updatedAt: new Date(), }, }); exerciseId = exercise.id; }); // ============================================ // GET EXERCISE TESTS // ============================================ describe('GET /api/exercises/:id', () => { it('should get exercise details without exposing correct answer', async () => { const response = await request(app) .get(`/api/exercises/${exerciseId}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.id).toBe(exerciseId); expect(response.body.data.statement).toBe('Test Exercise: What is 2+2?'); expect(response.body.data.correctAnswer).toBe(''); }); it('should return 404 for non-existent exercise', async () => { const response = await request(app) .get('/api/exercises/non-existent-id') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(404); }); }); // ============================================ // SUBMIT ATTEMPT TESTS // ============================================ describe('POST /api/exercises/:id/attempt', () => { it('should submit correct answer successfully', async () => { const response = await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '4', timeSpent: 30, hintsUsed: 0, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.isCorrect).toBe(true); expect(response.body.data.points).toBeGreaterThan(0); }); it('should handle incorrect answer', async () => { const response = await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '5', timeSpent: 30, hintsUsed: 0, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.isCorrect).toBe(false); expect(response.body.data.points).toBe(0); }); it('should validate LaTeX answer format', async () => { // Create exercise with LaTeX answer const latexExercise = await prisma.exercise.create({ data: { moduleId: moduleId, type: 'CALCULATION', difficulty: 'BASIC', order: 2, statement: 'Test LaTeX: What is the fraction?', correctAnswer: '\\frac{1}{2}', points: 15, timeLimitSeconds: 120, }, }); const response = await request(app) .post(`/api/exercises/${latexExercise.id}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '\\frac{1}{2}', timeSpent: 30, hintsUsed: 0, }); expect(response.status).toBe(200); expect(response.body.data.isCorrect).toBe(true); }); it('should detect and reject XSS attempts in LaTeX', async () => { const response = await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '\\href{javascript:alert(1)}{x}', timeSpent: 30, hintsUsed: 0, }); expect(response.status).toBe(400); expect(response.body.success).toBe(false); }); it('should handle skipped exercises', async () => { const response = await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '', timeSpent: 0, skipped: true, }); expect(response.status).toBe(200); expect(response.body.data.isCorrect).toBe(false); expect(response.body.data.skipped).toBe(true); }); }); // ============================================ // CONCURRENT SUBMISSION TESTS // ============================================ describe('Concurrent submission handling', () => { it('should handle multiple rapid submissions gracefully', async () => { const submissions = Array(5).fill(null).map(() => request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '4', timeSpent: 30, hintsUsed: 0, }) ); const results = await Promise.all(submissions); // All requests should complete without server errors results.forEach(result => { expect(result.status).toBeLessThan(500); }); }); }); // ============================================ // PROGRESS TESTS // ============================================ describe('GET /api/exercises/:id/attempts', () => { it('should get user attempts for exercise', async () => { // Submit a few attempts first await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '5', timeSpent: 30, hintsUsed: 0, }); await request(app) .post(`/api/exercises/${exerciseId}/attempt`) .set('Authorization', `Bearer ${authToken}`) .send({ answer: '4', timeSpent: 30, hintsUsed: 0, }); const response = await request(app) .get(`/api/exercises/${exerciseId}/attempts`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.length).toBeGreaterThanOrEqual(2); expect(response.body.meta.hasCompleted).toBe(true); }); }); });