🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled

 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:
Renato
2026-03-31 11:27:11 -03:00
commit bc43c9e772
309 changed files with 84845 additions and 0 deletions

View File

@@ -0,0 +1,570 @@
/**
* Score Calculator Unit Tests
*
* Tests for:
* - Points calculation by difficulty
* - Streak bonuses
* - Hint penalties
* - First attempt bonus
* - Speed bonus
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExerciseDifficulty } from '@prisma/client';
// Mock dependencies FIRST before importing
vi.mock('../../src/shared/database/prisma.client', () => ({
prisma: {
exercise: {
findUnique: vi.fn(),
},
exerciseAttempt: {
findMany: vi.fn(),
aggregate: vi.fn(),
},
},
}));
vi.mock('../../src/shared/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock StreakCalculator to avoid prisma calls
vi.mock('../../src/modules/ranking/calculators/streak.calculator', () => ({
StreakCalculator: {
calculateStreak: vi.fn().mockResolvedValue({
currentStreak: 0,
longestStreak: 0,
lastActivityDate: null,
isStreakActive: false,
daysUntilStreakBreaks: 0,
}),
getLongestStreak: vi.fn().mockResolvedValue(0),
getUserStreakInfo: vi.fn().mockResolvedValue({
currentStreak: 0,
hasStreakBonus: false,
}),
hasActivityOnDate: vi.fn().mockResolvedValue(false),
},
}));
// Import mocked modules and service
import { prisma } from '../../src/shared/database/prisma.client';
import { ScoreCalculator } from '../../src/modules/ranking/calculators/score.calculator';
import { StreakCalculator } from '../../src/modules/ranking/calculators/streak.calculator';
describe('ScoreCalculator', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.JWT_SECRET = 'test-jwt-secret';
});
afterEach(() => {
vi.restoreAllMocks();
});
// ============================================
// BASE POINTS TESTS
// ============================================
describe('calculate - base points', () => {
it('should return 10 base points for BASIC difficulty', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.basePoints).toBe(10);
expect(result.breakdown).toContain('Base: 10 puntos (BASIC)');
});
it('should return 20 base points for INTERMEDIATE difficulty', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.INTERMEDIATE,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.basePoints).toBe(20);
});
it('should return 30 base points for ADVANCED difficulty', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.ADVANCED,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.basePoints).toBe(30);
});
it('should return 40 base points for EXPERT difficulty', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.EXPERT,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.basePoints).toBe(40);
});
it('should throw error when exercise not found', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(null);
await expect(
ScoreCalculator.calculate({
exerciseId: 'nonexistent',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 30,
hintsUsed: 0,
attemptNumber: 1,
})
).rejects.toThrow('Exercise nonexistent not found');
});
});
// ============================================
// INCORRECT ANSWER TESTS
// ============================================
describe('calculate - incorrect answers', () => {
it('should return 0 points for incorrect answer', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.ADVANCED,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: false,
timeSpentSeconds: 60,
hintsUsed: 2,
attemptNumber: 1,
});
expect(result.finalPoints).toBe(0);
expect(result.breakdown).toContain('Incorrecto: 0 puntos');
});
});
// ============================================
// FIRST ATTEMPT BONUS TESTS
// ============================================
describe('calculate - first attempt bonus', () => {
it('should add 20% bonus for first attempt (correct)', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.firstAttemptMultiplier).toBe(1.2);
// Base 10 + 2 (20%) = 12
expect(result.finalPoints).toBe(12);
expect(result.breakdown).toContain('Primer intento: +2 puntos (+20%)');
});
it('should NOT add bonus for subsequent attempts', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 3, // Third attempt
});
expect(result.firstAttemptMultiplier).toBe(1.0);
expect(result.finalPoints).toBe(10); // Only base points
});
});
// ============================================
// SPEED BONUS TESTS
// ============================================
describe('calculate - speed bonus', () => {
it('should add 10% bonus for completing in under 60 seconds', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 45, // Under 60s threshold
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.speedMultiplier).toBe(1.1);
// Base 10 + 2 (first attempt) + 1 (speed) = 13
expect(result.finalPoints).toBe(13);
expect(result.breakdown).toContain('Velocidad: +1 puntos (+10%, <60s)');
});
it('should NOT add speed bonus for over 60 seconds', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 90, // Over 60s
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.speedMultiplier).toBe(1.0);
});
});
// ============================================
// STREAK BONUS TESTS
// ============================================
describe('calculate - streak bonus', () => {
it('should add 50% bonus for 3+ day streak', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
// Mock streak info with 3-day streak
(StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({
currentStreak: 3,
hasStreakBonus: true,
});
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.streakMultiplier).toBe(1.5);
// Base 10 + 2 (first) + 5 (streak) = 17
expect(result.finalPoints).toBe(17);
expect(result.breakdown.some(b => b.includes('Racha') && b.includes('+50%'))).toBe(true);
});
it('should NOT add streak bonus for less than 3 days', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
// Mock streak info with 1-day streak (no bonus)
(StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({
currentStreak: 1,
hasStreakBonus: false,
});
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 0,
attemptNumber: 1,
});
expect(result.streakMultiplier).toBe(1.0);
});
});
// ============================================
// HINT PENALTY TESTS
// ============================================
describe('calculate - hint penalty', () => {
it('should deduct 2 points per hint used', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 3, // 3 hints = -6 points
attemptNumber: 1,
});
expect(result.hintPenalty).toBe(6);
// Base 10 + 2 (first) - 6 (hints) = 6
expect(result.finalPoints).toBe(6);
expect(result.breakdown).toContain('Pistas: -6 puntos (-2 cada una)');
});
it('should not return negative points', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.BASIC,
});
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 120,
hintsUsed: 10, // 10 hints = -20 points (more than base 10)
attemptNumber: 3, // Not first attempt (no bonus)
});
expect(result.finalPoints).toBe(0); // Minimum 0
});
});
// ============================================
// COMBINED BONUSES TESTS
// ============================================
describe('calculate - combined bonuses', () => {
it('should calculate all bonuses combined correctly', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
difficulty: ExerciseDifficulty.INTERMEDIATE, // Base 20
});
// Mock 3-day streak
(StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({
currentStreak: 3,
hasStreakBonus: true,
});
const result = await ScoreCalculator.calculate({
exerciseId: 'exercise-123',
userId: 'user-123',
isCorrect: true,
timeSpentSeconds: 45, // Speed bonus
hintsUsed: 2, // -4 points
attemptNumber: 1, // First attempt bonus
});
// Base: 20
// First attempt: +4 (20% of 20)
// Speed: +2 (10% of 20)
// Streak: +10 (50% of 20)
// Hint penalty: -4
// Total: 20 + 4 + 2 + 10 - 4 = 32
expect(result.basePoints).toBe(20);
expect(result.firstAttemptMultiplier).toBe(1.2);
expect(result.speedMultiplier).toBe(1.1);
expect(result.streakMultiplier).toBe(1.5);
expect(result.hintPenalty).toBe(4);
expect(result.finalPoints).toBe(32);
});
});
// ============================================
// GET USER STREAK TESTS
// ============================================
describe('getUserStreak', () => {
it('should return streak 0 when no attempts', async () => {
(StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({
currentStreak: 0,
hasStreakBonus: false,
});
const result = await ScoreCalculator.getUserStreak('user-123');
expect(result.currentStreak).toBe(0);
expect(result.hasStreakBonus).toBe(false);
});
it('should break streak if last attempt is older than yesterday', async () => {
(StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({
currentStreak: 0,
hasStreakBonus: false,
});
const result = await ScoreCalculator.getUserStreak('user-123');
expect(result.currentStreak).toBe(0);
expect(result.hasStreakBonus).toBe(false);
});
});
// ============================================
// QUICK SCORE TESTS
// ============================================
describe('getQuickScore', () => {
it('should return quick score without full calculation', () => {
const result = ScoreCalculator.getQuickScore(
ExerciseDifficulty.ADVANCED,
true,
2 // 2 hints
);
// Base 30 - 4 (2 hints * 2) = 26
expect(result).toBe(26);
});
it('should return 0 for incorrect answer', () => {
const result = ScoreCalculator.getQuickScore(
ExerciseDifficulty.ADVANCED,
false,
0
);
expect(result).toBe(0);
});
it('should not return negative score', () => {
const result = ScoreCalculator.getQuickScore(
ExerciseDifficulty.BASIC,
true,
10 // More hints than base points
);
expect(result).toBe(0);
});
});
// ============================================
// MAX POSSIBLE POINTS TESTS
// ============================================
describe('getMaxPossiblePoints', () => {
it('should calculate maximum possible points with streak', () => {
const result = ScoreCalculator.getMaxPossiblePoints(
ExerciseDifficulty.INTERMEDIATE,
true // Has streak
);
// Base 20
// First attempt (20%): 24
// Speed (10%): 27
// Streak (50%): 41
expect(result).toBeGreaterThan(30);
});
it('should calculate maximum points without streak', () => {
const result = ScoreCalculator.getMaxPossiblePoints(
ExerciseDifficulty.BASIC,
false
);
// Base 10, with first attempt and speed only
expect(result).toBeGreaterThan(10);
});
});
// ============================================
// CALCULATE USER TOTAL POINTS TESTS
// ============================================
describe('calculateUserTotalPoints', () => {
it('should aggregate all correct attempt points', async () => {
(prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({
_sum: { pointsEarned: 150 },
});
const result = await ScoreCalculator.calculateUserTotalPoints('user-123');
expect(result).toBe(150);
});
it('should return 0 when no points earned', async () => {
(prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({
_sum: { pointsEarned: null },
});
const result = await ScoreCalculator.calculateUserTotalPoints('user-123');
expect(result).toBe(0);
});
});
// ============================================
// CALCULATE USER MODULE POINTS TESTS
// ============================================
describe('calculateUserModulePoints', () => {
it('should aggregate points for specific module', async () => {
(prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({
_sum: { pointsEarned: 50 },
});
const result = await ScoreCalculator.calculateUserModulePoints(
'user-123',
'module-123'
);
expect(result).toBe(50);
expect(prisma.exerciseAttempt.aggregate).toHaveBeenCalledWith({
where: {
userId: 'user-123',
status: 'CORRECT',
exercise: { moduleId: 'module-123' },
},
_sum: { pointsEarned: true },
});
});
});
});