🎓 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,339 @@
/**
* Auth Service Unit Tests
*
* Tests for:
* - Login with correct credentials
* - Login with incorrect password
* - Register with duplicate email
* - Token generation and validation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock external dependencies FIRST before importing anything else
vi.mock('bcrypt', () => ({
default: {
hash: vi.fn(),
compare: vi.fn(),
},
}));
vi.mock('jsonwebtoken', () => {
const mockSign = vi.fn();
const mockVerify = vi.fn();
return {
default: {
sign: mockSign,
verify: mockVerify,
TokenExpiredError: class TokenExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = 'TokenExpiredError';
}
},
JsonWebTokenError: class JsonWebTokenError extends Error {
constructor(message: string) {
super(message);
this.name = 'JsonWebTokenError';
}
},
},
};
});
// Mock internal dependencies
vi.mock('../../src/shared/database/prisma.client', () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
refreshToken: {
create: vi.fn(),
deleteMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
passwordResetToken: {
create: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock('../../src/shared/database/redis.client', () => ({
blacklistToken: vi.fn().mockResolvedValue(undefined),
isTokenBlacklisted: vi.fn().mockResolvedValue(false),
}));
vi.mock('../../src/shared/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/modules/notification/telegram/telegram.client', () => ({
telegramClient: {
sendMessage: vi.fn(),
},
}));
vi.mock('../../src/config/telegram', () => ({
isTelegramEnabled: vi.fn().mockReturnValue(false),
getTelegramAdminChatId: vi.fn().mockReturnValue(null),
}));
// NOW import the mocked modules and the service
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { AuthService } from '../../src/modules/auth/auth.service';
import { prisma } from '../../src/shared/database/prisma.client';
import { ConflictError, AuthenticationError, NotFoundError } from '../../src/shared/types';
const authService = new AuthService();
describe('AuthService', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.JWT_SECRET = 'test-jwt-secret';
});
afterEach(() => {
vi.restoreAllMocks();
});
// ============================================
// LOGIN TESTS
// ============================================
describe('login', () => {
it('should login successfully with correct credentials', async () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
passwordHash: 'hashed-password',
isActive: true,
lastLoginAt: null,
role: 'STUDENT',
};
(prisma.user.findUnique as any).mockResolvedValueOnce(mockUser);
(prisma.user.update as any).mockResolvedValueOnce({
...mockUser,
lastLoginAt: new Date(),
});
(prisma.refreshToken.deleteMany as any).mockResolvedValueOnce({ count: 0 });
(prisma.refreshToken.create as any).mockResolvedValueOnce({});
(bcrypt.compare as any).mockResolvedValueOnce(true);
(jwt.sign as any).mockReturnValueOnce('mock-jwt-token');
const result = await authService.login({
email: 'test@example.com',
password: 'correct-password',
});
expect(result.user.email).toBe('test@example.com');
expect(result.user.username).toBe('testuser');
expect(result.token).toBe('mock-jwt-token');
});
it('should throw AuthenticationError with incorrect password', async () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
passwordHash: 'hashed-password',
isActive: true,
role: 'STUDENT',
};
(prisma.user.findUnique as any).mockResolvedValueOnce(mockUser);
(bcrypt.compare as any).mockResolvedValueOnce(false);
await expect(
authService.login({
email: 'test@example.com',
password: 'wrong-password',
})
).rejects.toThrow(AuthenticationError);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('should throw AuthenticationError when user does not exist', async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(null);
await expect(
authService.login({
email: 'nonexistent@example.com',
password: 'any-password',
})
).rejects.toThrow(AuthenticationError);
});
it('should throw AuthenticationError when user is inactive', async () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
passwordHash: 'hashed-password',
isActive: false,
role: 'STUDENT',
};
(prisma.user.findUnique as any).mockResolvedValueOnce(mockUser);
await expect(
authService.login({
email: 'test@example.com',
password: 'any-password',
})
).rejects.toThrow(AuthenticationError);
expect(bcrypt.compare).not.toHaveBeenCalled();
});
});
// ============================================
// REGISTER TESTS
// ============================================
describe('register', () => {
it('should register a new user successfully', async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(null); // email check
(prisma.user.findUnique as any).mockResolvedValueOnce(null); // username check
(bcrypt.hash as any).mockResolvedValueOnce('hashed-password');
const mockCreatedUser = {
id: 'new-user-123',
email: 'newuser@example.com',
username: 'newuser',
isActive: true,
createdAt: new Date(),
role: 'STUDENT',
};
(prisma.user.create as any).mockResolvedValueOnce(mockCreatedUser);
(prisma.refreshToken.create as any).mockResolvedValueOnce({});
(jwt.sign as any).mockReturnValueOnce('mock-jwt-token');
const result = await authService.register({
email: 'newuser@example.com',
username: 'newuser',
password: 'secure-password',
});
expect(result.user.email).toBe('newuser@example.com');
expect(result.user.username).toBe('newuser');
expect(result.token).toBe('mock-jwt-token');
expect(bcrypt.hash).toHaveBeenCalledWith('secure-password', 10);
});
it('should throw ConflictError with duplicate email', async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce({ id: 'existing-user', role: 'STUDENT' });
await expect(
authService.register({
email: 'existing@example.com',
username: 'newuser',
password: 'password',
})
).rejects.toThrow(ConflictError);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('should throw ConflictError with duplicate username', async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(null); // email unique
(prisma.user.findUnique as any).mockResolvedValueOnce({ id: 'existing-user', role: 'STUDENT' }); // username exists
await expect(
authService.register({
email: 'newemail@example.com',
username: 'existingusername',
password: 'password',
})
).rejects.toThrow(ConflictError);
});
});
// ============================================
// TOKEN TESTS
// ============================================
describe('verifyToken', () => {
it('should verify a valid token successfully', async () => {
const mockPayload = {
userId: 'user-123',
email: 'test@example.com',
username: 'testuser',
};
(jwt.verify as any).mockReturnValueOnce(mockPayload);
const result = authService.verifyToken('valid-token');
expect(result.userId).toBe('user-123');
expect(result.email).toBe('test@example.com');
});
it('should throw AuthenticationError for expired token', async () => {
const TokenExpiredError = (jwt as any).TokenExpiredError;
(jwt.verify as any).mockImplementationOnce(() => {
throw new TokenExpiredError('jwt expired');
});
expect(() => authService.verifyToken('expired-token')).toThrow(AuthenticationError);
});
it('should throw AuthenticationError for invalid token', async () => {
const JsonWebTokenError = (jwt as any).JsonWebTokenError;
(jwt.verify as any).mockImplementationOnce(() => {
throw new JsonWebTokenError('invalid signature');
});
expect(() => authService.verifyToken('invalid-token')).toThrow(AuthenticationError);
});
});
// ============================================
// PROFILE TESTS
// ============================================
describe('getProfile', () => {
it('should return user profile successfully', async () => {
const mockProfile = {
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
isActive: true,
telegramChatId: null,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
role: 'STUDENT',
};
(prisma.user.findUnique as any).mockResolvedValueOnce(mockProfile);
const result = await authService.getProfile('user-123');
expect(result.id).toBe('user-123');
expect(result.email).toBe('test@example.com');
expect(result.username).toBe('testuser');
});
it('should throw NotFoundError when user not found', async () => {
(prisma.user.findUnique as any).mockResolvedValueOnce(null);
await expect(authService.getProfile('nonexistent-user')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,784 @@
/**
* Exercise Service Unit Tests
*
* Tests for:
* - submitAttempt with correct answer
* - submitAttempt with incorrect answer
* - compareAnswers with exact matches
* - compareAnswers with LaTeX expressions
*/
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(),
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
},
exerciseAttempt: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
aggregate: vi.fn(),
},
progress: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn((callback) => callback(prisma)),
},
}));
vi.mock('../../src/shared/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/modules/ranking/calculators/score.calculator', () => ({
ScoreCalculator: {
calculate: vi.fn().mockResolvedValue({
basePoints: 10,
streakMultiplier: 1.0,
firstAttemptMultiplier: 1.2,
speedMultiplier: 1.0,
hintPenalty: 0,
finalPoints: 12,
breakdown: ['Base: 10 puntos (BASIC)', 'Primer intento: +2 puntos (+20%)'],
}),
},
}));
vi.mock('../../src/modules/ranking/ranking.service', () => ({
RankingService: {
processExerciseSubmission: vi.fn().mockResolvedValue(undefined),
},
}));
// Import mocked modules and service - use the singleton export
import { prisma } from '../../src/shared/database/prisma.client';
import exerciseService from '../../src/modules/exercise/exercise.service';
import { NotFoundError } from '../../src/shared/types';
import { ScoreCalculator } from '../../src/modules/ranking/calculators/score.calculator';
describe('ExerciseService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ============================================
// SUBMIT ATTEMPT TESTS
// ============================================
describe('submitAttempt', () => {
const mockExercise = {
id: 'exercise-123',
moduleId: 'module-123',
correctAnswer: '42',
solutionSteps: [{ step: 1, explanation: 'Calculate the result' }],
points: 10,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [{ level: 1, content: 'Use basic arithmetic' }],
multipleChoiceOptions: null,
};
it('should submit correct attempt successfully', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
const mockAttempt = {
id: 'attempt-123',
userId: 'user-123',
exerciseId: 'exercise-123',
status: 'CORRECT',
pointsEarned: 12,
timeSpentSeconds: 30,
hintsUsed: 0,
feedback: '¡Excelente! Respuesta correcta en el primer intento.',
attemptNumber: 1,
isPerfect: true,
skipped: false,
createdAt: new Date(),
};
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce(mockAttempt);
(prisma.progress.create as any).mockResolvedValueOnce({});
const result = await exerciseService.submitAttempt(
'exercise-123',
'user-123',
{
answer: '42',
timeSpent: 30,
hintsUsed: 0,
}
);
expect(result.isCorrect).toBe(true);
expect(result.points).toBe(12);
expect(result.message).toContain('Excelente');
expect(result.correctAnswer).toBe('42');
});
it('should submit incorrect attempt successfully', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({
status: 'INCORRECT',
pointsEarned: 0,
feedback: 'Respuesta incorrecta',
});
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.upsert as any).mockResolvedValueOnce({});
const result = await exerciseService.submitAttempt(
'exercise-123',
'user-123',
{
answer: '35', // Wrong answer
timeSpent: 60,
hintsUsed: 1,
}
);
expect(result.isCorrect).toBe(false);
expect(result.points).toBe(0);
expect(result.correctAnswer).toBeUndefined();
});
it('should throw NotFoundError when exercise does not exist', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(null);
await expect(
exerciseService.submitAttempt('nonexistent-exercise', 'user-123', {
answer: '42',
timeSpent: 30,
})
).rejects.toThrow(NotFoundError);
});
it('should handle skipped exercise', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({
status: 'PENDING',
pointsEarned: 0,
feedback: 'Ejercicio omitido. Puedes volver a intentarlo más tarde.',
skipped: true,
});
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.upsert as any).mockResolvedValueOnce({});
const result = await exerciseService.submitAttempt(
'exercise-123',
'user-123',
{
answer: '',
timeSpent: 0,
skipped: true,
}
);
expect(result.isCorrect).toBe(false);
expect(result.points).toBe(0);
expect(result.message).toContain('omitido');
});
});
// ============================================
// GET EXERCISE BY ID TESTS
// ============================================
describe('getExerciseById', () => {
it('should return exercise with details', async () => {
const mockExercise = {
id: 'exercise-123',
moduleId: 'module-123',
topicId: 'topic-123',
type: 'CALCULATION',
difficulty: ExerciseDifficulty.BASIC,
order: 1,
statement: 'What is 6 * 7?',
correctAnswer: '42',
solutionSteps: null,
points: 10,
timeLimitSeconds: 120,
isPublished: true,
hints: null,
module: null,
topic: null,
attempts: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
const result = await exerciseService.getExerciseById('exercise-123');
expect(result.id).toBe('exercise-123');
expect(result.correctAnswer).toBe(''); // Hidden by default
});
it('should throw NotFoundError when exercise not found', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(null);
await expect(exerciseService.getExerciseById('nonexistent')).rejects.toThrow(NotFoundError);
});
});
// ============================================
// GET USER ATTEMPTS TESTS
// ============================================
describe('getUserAttempts', () => {
it('should return user attempts for exercise', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({ id: 'exercise-123' });
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(15);
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ status: 'CORRECT', pointsEarned: 12, createdAt: new Date() },
{ status: 'INCORRECT', pointsEarned: 0, createdAt: new Date() },
])
// Second call for aggregate mock
.mockResolvedValueOnce([{ pointsEarned: 12 }]);
(prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({
_max: { pointsEarned: 12 },
});
const result = await exerciseService.getUserAttempts('exercise-123', 'user-123');
expect(result.totalAttempts).toBe(15);
expect(result.attempts.length).toBe(2);
expect(result.hasCompleted).toBe(true);
});
it('should throw NotFoundError when exercise not found', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce(null);
await expect(
exerciseService.getUserAttempts('nonexistent', 'user-123')
).rejects.toThrow(NotFoundError);
});
});
// ============================================
// GET NEXT EXERCISE TESTS
// ============================================
describe('getNextExercise', () => {
it('should return next exercise in order', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
moduleId: 'module-123',
order: 1,
});
const mockNextExercise = {
id: 'exercise-124',
order: 2,
statement: 'Next question',
};
(prisma.exercise.findFirst as any).mockResolvedValueOnce(mockNextExercise);
const result = await exerciseService.getNextExercise('exercise-123');
expect(result?.id).toBe('exercise-124');
});
it('should return null when no next exercise', async () => {
(prisma.exercise.findUnique as any).mockResolvedValueOnce({
moduleId: 'module-123',
order: 10,
});
(prisma.exercise.findFirst as any).mockResolvedValueOnce(null);
const result = await exerciseService.getNextExercise('exercise-last');
expect(result).toBeNull();
});
});
});
// ============================================
// COMPARE ANSWERS FUNCTION TESTS
// ============================================
describe('compareAnswers (via submitAttempt)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should match exact answers', async () => {
const mockExercise = {
id: 'exercise-123',
moduleId: 'module-123',
correctAnswer: '42',
points: 10,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({ status: 'CORRECT' });
(prisma.progress.create as any).mockResolvedValueOnce({});
(ScoreCalculator.calculate as any).mockResolvedValueOnce({ finalPoints: 12 });
const result = await exerciseService.submitAttempt(
'exercise-123',
'user-123',
{ answer: '42', timeSpent: 30 }
);
expect(result.isCorrect).toBe(true);
});
it('should match answers with whitespace differences', async () => {
const mockExercise = {
id: 'exercise-123',
moduleId: 'module-123',
correctAnswer: 'matrix multiplication',
points: 10,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({ status: 'CORRECT' });
(prisma.progress.create as any).mockResolvedValueOnce({});
(ScoreCalculator.calculate as any).mockResolvedValueOnce({ finalPoints: 12 });
const result = await exerciseService.submitAttempt(
'exercise-123',
'user-123',
{ answer: ' matrix multiplication ', timeSpent: 30 }
);
expect(result.isCorrect).toBe(true);
});
it('should preserve LaTeX case sensitivity', async () => {
const mockExercise = {
id: 'exercise-latex',
moduleId: 'module-123',
correctAnswer: '\\Lambda',
points: 20,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({ status: 'CORRECT' });
(prisma.progress.create as any).mockResolvedValueOnce({});
(ScoreCalculator.calculate as any).mockResolvedValueOnce({ finalPoints: 24 });
const result = await exerciseService.submitAttempt(
'exercise-latex',
'user-123',
{ answer: '\\Lambda', timeSpent: 30 }
);
expect(result.isCorrect).toBe(true);
});
it('should normalize LaTeX delimiters', async () => {
const mockExercise = {
id: 'exercise-latex',
moduleId: 'module-123',
correctAnswer: '$$x^2 + y^2$$',
points: 20,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({ status: 'CORRECT' });
(prisma.progress.create as any).mockResolvedValueOnce({});
(ScoreCalculator.calculate as any).mockResolvedValueOnce({ finalPoints: 24 });
const result = await exerciseService.submitAttempt(
'exercise-latex',
'user-123',
{ answer: 'x^2 + y^2', timeSpent: 30 }
);
expect(result.isCorrect).toBe(true);
});
it('should handle \\left and \\right parentheses normalization', async () => {
const mockExercise = {
id: 'exercise-latex',
moduleId: 'module-123',
correctAnswer: '\\left(x + y\\right)',
points: 20,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
(prisma.exerciseAttempt.findFirst as any).mockResolvedValueOnce(null);
(prisma.exercise.count as any).mockResolvedValueOnce(5);
(prisma.progress.findUnique as any).mockResolvedValueOnce(null);
(prisma.exerciseAttempt.create as any).mockResolvedValueOnce({ status: 'CORRECT' });
(prisma.progress.create as any).mockResolvedValueOnce({});
(ScoreCalculator.calculate as any).mockResolvedValueOnce({ finalPoints: 24 });
const result = await exerciseService.submitAttempt(
'exercise-latex',
'user-123',
{ answer: '(x + y)', timeSpent: 30 }
);
expect(result.isCorrect).toBe(true);
});
});
// ============================================
// RACE CONDITION TESTS
// ============================================
describe('Exercise Progress Race Condition', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should exclude newly created attempt when checking for previous correct attempts', async () => {
const mockExercise = {
id: 'exercise-456',
moduleId: 'module-123',
correctAnswer: '42',
points: 10,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
const newAttemptId = 'attempt-new-123';
const newAttemptCreatedAt = new Date();
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
// Mock the transaction behavior
const mockTx = {
exerciseAttempt: {
create: vi.fn().mockResolvedValueOnce({
id: newAttemptId,
userId: 'user-123',
exerciseId: 'exercise-456',
status: 'CORRECT',
createdAt: newAttemptCreatedAt,
isPerfect: true,
}),
findFirst: vi.fn().mockResolvedValueOnce(null), // No previous correct attempts
},
exercise: {
findUnique: vi.fn().mockResolvedValueOnce(mockExercise),
count: vi.fn().mockResolvedValueOnce(10),
},
progress: {
findUnique: vi.fn().mockResolvedValueOnce(null),
create: vi.fn().mockResolvedValueOnce({}),
},
};
(prisma.$transaction as any).mockImplementationOnce(async (callback: any) => {
return await callback(mockTx);
});
const result = await exerciseService.submitAttempt(
'exercise-456',
'user-123',
{ answer: '42', timeSpent: 30 }
);
// Verify that findFirst was called with the exclusion of the new attempt
expect(mockTx.exerciseAttempt.findFirst).toHaveBeenCalledWith({
where: {
userId: 'user-123',
exerciseId: 'exercise-456',
status: 'CORRECT',
id: { not: newAttemptId },
createdAt: { lt: newAttemptCreatedAt },
},
select: { id: true },
});
expect(result.isCorrect).toBe(true);
});
it('should not double count exercises when checking previous attempts', async () => {
const mockExercise = {
id: 'exercise-789',
moduleId: 'module-456',
correctAnswer: '100',
points: 15,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
const existingProgress = {
id: 'progress-123',
userId: 'user-123',
moduleId: 'module-456',
exercisesCompleted: 3,
points: 45,
totalExercises: 10,
};
const newAttemptId = 'attempt-new-456';
const newAttemptCreatedAt = new Date();
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(1); // Previous attempts exist
const mockTx = {
exerciseAttempt: {
create: vi.fn().mockResolvedValueOnce({
id: newAttemptId,
userId: 'user-123',
exerciseId: 'exercise-789',
status: 'CORRECT',
createdAt: newAttemptCreatedAt,
isPerfect: true,
}),
findFirst: vi.fn().mockResolvedValueOnce({ id: 'old-attempt-123' }), // Previous correct attempt exists
},
exercise: {
findUnique: vi.fn().mockResolvedValueOnce(mockExercise),
count: vi.fn().mockResolvedValueOnce(10),
},
progress: {
findUnique: vi.fn().mockResolvedValueOnce(existingProgress),
update: vi.fn().mockResolvedValueOnce({}),
},
};
(prisma.$transaction as any).mockImplementationOnce(async (callback: any) => {
return await callback(mockTx);
});
const result = await exerciseService.submitAttempt(
'exercise-789',
'user-123',
{ answer: '100', timeSpent: 25 }
);
// Verify progress was NOT incremented since it's not the first correct attempt
expect(mockTx.progress.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
exercisesCompleted: 3, // Same as before, not incremented
points: expect.any(Number),
}),
})
);
expect(result.isCorrect).toBe(true);
});
});
describe('Exercise Progress Division by Zero', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle module with zero exercises gracefully', async () => {
const mockExercise = {
id: 'exercise-empty',
moduleId: 'module-empty',
correctAnswer: '42',
points: 10,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
const newAttemptId = 'attempt-new-789';
const newAttemptCreatedAt = new Date();
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
const mockTx = {
exerciseAttempt: {
create: vi.fn().mockResolvedValueOnce({
id: newAttemptId,
userId: 'user-123',
exerciseId: 'exercise-empty',
status: 'CORRECT',
createdAt: newAttemptCreatedAt,
isPerfect: true,
}),
findFirst: vi.fn().mockResolvedValueOnce(null),
},
exercise: {
findUnique: vi.fn().mockResolvedValueOnce(mockExercise),
count: vi.fn().mockResolvedValueOnce(0), // Zero exercises
},
progress: {
findUnique: vi.fn().mockResolvedValueOnce(null),
create: vi.fn().mockResolvedValueOnce({}),
},
};
(prisma.$transaction as any).mockImplementationOnce(async (callback: any) => {
return await callback(mockTx);
});
const result = await exerciseService.submitAttempt(
'exercise-empty',
'user-123',
{ answer: '42', timeSpent: 30 }
);
// Verify that percentage is 0 when totalExercises is 0
expect(mockTx.progress.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
percentage: 0, // Should be 0, not NaN
exercisesCompleted: 1,
}),
})
);
expect(result.isCorrect).toBe(true);
});
it('should calculate correct percentage when module has exercises', async () => {
const mockExercise = {
id: 'exercise-normal',
moduleId: 'module-normal',
correctAnswer: '100',
points: 20,
timeLimitSeconds: 120,
type: 'CALCULATION',
hints: [],
multipleChoiceOptions: null,
solutionSteps: [],
};
const existingProgress = {
id: 'progress-456',
userId: 'user-123',
moduleId: 'module-normal',
exercisesCompleted: 4,
points: 80,
totalExercises: 10,
};
const newAttemptId = 'attempt-new-999';
const newAttemptCreatedAt = new Date();
(prisma.exercise.findUnique as any).mockResolvedValueOnce(mockExercise);
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
const mockTx = {
exerciseAttempt: {
create: vi.fn().mockResolvedValueOnce({
id: newAttemptId,
userId: 'user-123',
exerciseId: 'exercise-normal',
status: 'CORRECT',
createdAt: newAttemptCreatedAt,
isPerfect: true,
}),
findFirst: vi.fn().mockResolvedValueOnce(null),
},
exercise: {
findUnique: vi.fn().mockResolvedValueOnce(mockExercise),
count: vi.fn().mockResolvedValueOnce(10), // 10 exercises
},
progress: {
findUnique: vi.fn().mockResolvedValueOnce(existingProgress),
update: vi.fn().mockResolvedValueOnce({}),
},
};
(prisma.$transaction as any).mockImplementationOnce(async (callback: any) => {
return await callback(mockTx);
});
const result = await exerciseService.submitAttempt(
'exercise-normal',
'user-123',
{ answer: '100', timeSpent: 20 }
);
// Verify that percentage is calculated correctly: (5/10) * 100 = 50%
expect(mockTx.progress.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
percentage: 50, // (5/10) * 100
exercisesCompleted: 5,
}),
})
);
expect(result.isCorrect).toBe(true);
});
});

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 },
});
});
});
});

View File

@@ -0,0 +1,485 @@
/**
* Streak Calculator Unit Tests
*
* Tests for:
* - Timezone-aware streak calculation
* - Streak breaking after 2 days without activity
* - Longest streak calculation
* - Edge cases: DST, timezone boundaries, multiple exercises same day
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { subDays, startOfDay } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
// Mock dependencies FIRST before importing
vi.mock('../../src/shared/database/prisma.client', () => ({
prisma: {
exerciseAttempt: {
findMany: vi.fn(),
count: vi.fn(),
},
},
}));
vi.mock('../../src/shared/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Import mocked modules and service
import { prisma } from '../../src/shared/database/prisma.client';
import { StreakCalculator } from '../../src/modules/ranking/calculators/streak.calculator';
describe('StreakCalculator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ============================================
// BASIC STREAK CALCULATION
// ============================================
describe('calculateStreak - basic functionality', () => {
it('should return streak 0 when no activity', async () => {
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([]) // recentActivity
.mockResolvedValueOnce([]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
expect(result.currentStreak).toBe(0);
expect(result.isStreakActive).toBe(false);
expect(result.lastActivityDate).toBeNull();
});
it('should calculate streak of 1 for activity today', async () => {
const today = new Date();
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
expect(result.currentStreak).toBe(1);
expect(result.isStreakActive).toBe(true);
expect(result.lastActivityDate).toBeInstanceOf(Date);
});
it('should calculate streak of 3 for 3 consecutive days', async () => {
const today = new Date();
const yesterday = subDays(today, 1);
const twoDaysAgo = subDays(today, 2);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
{ createdAt: twoDaysAgo },
]) // recentActivity
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
{ createdAt: twoDaysAgo },
]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
expect(result.currentStreak).toBe(3);
expect(result.isStreakActive).toBe(true);
});
});
// ============================================
// STREAK BREAKING
// ============================================
describe('calculateStreak - streak breaking', () => {
it('should break streak if no activity for 2 days', async () => {
const twoDaysAgo = subDays(new Date(), 2);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: twoDaysAgo }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: twoDaysAgo }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
expect(result.currentStreak).toBe(0);
expect(result.isStreakActive).toBe(false);
});
it('should keep streak active if activity yesterday', async () => {
const yesterday = subDays(new Date(), 1);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: yesterday }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: yesterday }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
expect(result.currentStreak).toBe(1);
expect(result.isStreakActive).toBe(true);
});
});
// ============================================
// TIMEZONE HANDLING
// ============================================
describe('calculateStreak - timezone handling', () => {
it('should handle Argentina timezone (UTC-3)', async () => {
// Simular actividad a las 23:00 hora local de Argentina (02:00 UTC del día siguiente)
const now = new Date();
const activityAt23PM = new Date(now);
activityAt23PM.setHours(23, 0, 0, 0);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: activityAt23PM }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: activityAt23PM }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'America/Argentina/Buenos_Aires',
});
// Debe considerar como actividad "hoy" en timezone local
expect(result.currentStreak).toBe(1);
expect(result.isStreakActive).toBe(true);
});
it('should handle user traveling between timezones', async () => {
const today = new Date();
// Mock para calculateStreak (actividad reciente) y getLongestStreak - llamado dos veces
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity for NY
.mockResolvedValueOnce([{ createdAt: today }]) // allActivity for NY
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity for Tokyo
.mockResolvedValueOnce([{ createdAt: today }]); // allActivity for Tokyo
// Usuario viajó de Nueva York a Tokyo
const resultNY = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'America/New_York',
});
const resultTokyo = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'Asia/Tokyo',
});
// Ambos deben mostrar streak activo, posiblemente con diferente currentStreak
// dependiendo de la hora local
expect(resultNY.isStreakActive).toBe(true);
expect(resultTokyo.isStreakActive).toBe(true);
});
it('should use UTC as default timezone', async () => {
const today = new Date();
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
// Sin especificar timezone
});
expect(result.currentStreak).toBe(1);
expect(result.isStreakActive).toBe(true);
});
});
// ============================================
// DAYLIGHT SAVING TIME (DST)
// ============================================
describe('calculateStreak - DST handling', () => {
it('should handle DST transition in New York', async () => {
// Simular actividad durante transición DST
const today = new Date();
const yesterday = subDays(today, 1);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
]) // recentActivity
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'America/New_York',
});
// El cálculo debe ser consistente sin importar DST
expect(result.currentStreak).toBeGreaterThanOrEqual(1);
expect(result.isStreakActive).toBe(true);
});
it('should handle DST transition in Europe', async () => {
const today = new Date();
const yesterday = subDays(today, 1);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
]) // recentActivity
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'Europe/Madrid',
});
expect(result.currentStreak).toBeGreaterThanOrEqual(1);
expect(result.isStreakActive).toBe(true);
});
});
// ============================================
// MULTIPLE EXERCISES SAME DAY
// ============================================
describe('calculateStreak - multiple exercises same day', () => {
it('should count only one day for multiple exercises', async () => {
const today = new Date();
const activity1 = new Date(today);
activity1.setHours(9, 0, 0, 0);
const activity2 = new Date(today);
activity2.setHours(14, 0, 0, 0);
const activity3 = new Date(today);
activity3.setHours(20, 0, 0, 0);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ createdAt: activity1 },
{ createdAt: activity2 },
{ createdAt: activity3 },
]) // recentActivity
.mockResolvedValueOnce([
{ createdAt: activity1 },
{ createdAt: activity2 },
{ createdAt: activity3 },
]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
// Debe contar como 1 día, no 3
expect(result.currentStreak).toBe(1);
});
});
// ============================================
// LONGEST STREAK
// ============================================
describe('getLongestStreak', () => {
it('should return 0 when no activity', async () => {
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]);
const result = await StreakCalculator.getLongestStreak('user-1');
expect(result).toBe(0);
});
it('should calculate longest streak correctly', async () => {
const today = new Date();
const yesterday = subDays(today, 1);
const twoDaysAgo = subDays(today, 2);
const fiveDaysAgo = subDays(today, 5);
const sixDaysAgo = subDays(today, 6);
// Streak actual: 3 días (hoy, ayer, anteayer)
// Streak histórico: 2 días (hace 5 y 6 días)
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
{ createdAt: twoDaysAgo },
{ createdAt: fiveDaysAgo },
{ createdAt: sixDaysAgo },
]);
const result = await StreakCalculator.getLongestStreak('user-1');
// El streak más largo es 3
expect(result).toBe(3);
});
it('should handle single activity', async () => {
const today = new Date();
(prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([
{ createdAt: today },
]);
const result = await StreakCalculator.getLongestStreak('user-1');
expect(result).toBe(1);
});
});
// ============================================
// HAS ACTIVITY ON DATE
// ============================================
describe('hasActivityOnDate', () => {
it('should return true if activity exists on date', async () => {
const date = new Date();
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(1);
const result = await StreakCalculator.hasActivityOnDate(
'user-1',
date,
'UTC'
);
expect(result).toBe(true);
});
it('should return false if no activity on date', async () => {
const date = new Date();
(prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0);
const result = await StreakCalculator.hasActivityOnDate(
'user-1',
date,
'UTC'
);
expect(result).toBe(false);
});
});
// ============================================
// DAYS UNTIL BREAK
// ============================================
describe('daysUntilStreakBreaks', () => {
it('should return 1 day if activity today', async () => {
const today = new Date();
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
// Si actividad fue hoy, tiene hasta mañana (1 día completo)
expect(result.daysUntilStreakBreaks).toBe(1);
});
it('should return fraction of day if activity yesterday', async () => {
const yesterday = subDays(new Date(), 1);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: yesterday }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: yesterday }]); // allActivity for longestStreak
const result = await StreakCalculator.calculateStreak({
userId: 'user-1',
timezone: 'UTC',
});
// Si actividad fue ayer, debe actuar hoy (fracción de día)
expect(result.daysUntilStreakBreaks).toBeGreaterThan(0);
expect(result.daysUntilStreakBreaks).toBeLessThan(1);
});
});
// ============================================
// GET USER STREAK INFO (Optimized version)
// ============================================
describe('getUserStreakInfo', () => {
it('should return simplified streak info', async () => {
const today = new Date();
const yesterday = subDays(today, 1);
const twoDaysAgo = subDays(today, 2);
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
{ createdAt: twoDaysAgo },
]) // recentActivity
.mockResolvedValueOnce([
{ createdAt: today },
{ createdAt: yesterday },
{ createdAt: twoDaysAgo },
]); // allActivity for longestStreak
const result = await StreakCalculator.getUserStreakInfo('user-1', 'UTC');
expect(result.currentStreak).toBe(3);
expect(result.hasStreakBonus).toBe(true);
});
it('should not have streak bonus for less than 3 days', async () => {
const today = new Date();
// Mock para calculateStreak (actividad reciente) y getLongestStreak
(prisma.exerciseAttempt.findMany as any)
.mockResolvedValueOnce([{ createdAt: today }]) // recentActivity
.mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak
const result = await StreakCalculator.getUserStreakInfo('user-1', 'UTC');
expect(result.currentStreak).toBe(1);
expect(result.hasStreakBonus).toBe(false);
});
});
});