✨ 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 ✅
570 lines
17 KiB
TypeScript
570 lines
17 KiB
TypeScript
/**
|
|
* 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 },
|
|
});
|
|
});
|
|
});
|
|
}); |