🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
This commit is contained in:
316
backend/tests/integration/exercise.integration.test.ts
Normal file
316
backend/tests/integration/exercise.integration.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user