🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
This commit is contained in:
265
backend/tests/integration/auth.integration.test.ts
Normal file
265
backend/tests/integration/auth.integration.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Integration Tests - Auth API
|
||||
*
|
||||
* Tests for:
|
||||
* - User registration
|
||||
* - User login
|
||||
* - JWT token validation
|
||||
* - Protected routes
|
||||
* - Password strength validation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express, { ErrorRequestHandler } from 'express';
|
||||
import { prisma } from '../../src/shared/database/prisma.client';
|
||||
import { authRoutes } from '../../src/modules/auth/auth.routes';
|
||||
|
||||
// Simple error handler for tests
|
||||
const testErrorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
const code = err.code || 'INTERNAL_ERROR';
|
||||
const message = err.message || 'An unexpected error occurred';
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Auth API Integration', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure test database is clean
|
||||
await prisma.$connect();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test
|
||||
await prisma.refreshToken.deleteMany({});
|
||||
await prisma.passwordResetToken.deleteMany({});
|
||||
await prisma.exerciseAttempt.deleteMany({});
|
||||
await prisma.progress.deleteMany({});
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: {
|
||||
contains: 'test@',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create fresh app instance
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Add error handler last
|
||||
app.use(testErrorHandler);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// REGISTRATION TESTS
|
||||
// ============================================
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('should register a new user successfully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'testuser',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
expect(response.body.data.user).toHaveProperty('id');
|
||||
expect(response.body.data.user.email).toBe('test@example.com');
|
||||
expect(response.body.data.user).not.toHaveProperty('passwordHash');
|
||||
});
|
||||
|
||||
it('should reject weak passwords', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'test2@example.com',
|
||||
password: '123',
|
||||
username: 'testuser2',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject duplicate emails', async () => {
|
||||
// Create first user
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'duplicate@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'user1',
|
||||
});
|
||||
|
||||
// Try to create second user with same email
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'duplicate@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'user2',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject duplicate usernames', async () => {
|
||||
// Create first user
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'user1@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'uniqueuser',
|
||||
});
|
||||
|
||||
// Try to create second user with same username
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'user2@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'uniqueuser',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// LOGIN TESTS
|
||||
// ============================================
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
beforeEach(async () => {
|
||||
// Register a user for login tests
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'login@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'loginuser',
|
||||
});
|
||||
});
|
||||
|
||||
it('should login with correct credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'login@example.com',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
expect(response.body.data.user.email).toBe('login@example.com');
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'login@example.com',
|
||||
password: 'WrongPass123!',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-existent user', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PROFILE TESTS
|
||||
// ============================================
|
||||
|
||||
describe('GET /api/auth/profile', () => {
|
||||
let authToken: string;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any existing profile test user
|
||||
await prisma.refreshToken.deleteMany({});
|
||||
await prisma.exerciseAttempt.deleteMany({});
|
||||
await prisma.progress.deleteMany({});
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: 'profile@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const registerResponse = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'profile@example.com',
|
||||
password: 'SecurePass123!',
|
||||
username: 'profileuser',
|
||||
});
|
||||
|
||||
authToken = registerResponse.body.data.token;
|
||||
userId = registerResponse.body.data.user.id;
|
||||
});
|
||||
|
||||
it('should get user profile with valid token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.id).toBe(userId);
|
||||
expect(response.body.data.email).toBe('profile@example.com');
|
||||
});
|
||||
|
||||
it('should reject request without token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject request with invalid token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid-token');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
316
backend/tests/integration/exercise.integration.test.ts
Normal file
316
backend/tests/integration/exercise.integration.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Integration Tests - Exercise API
|
||||
*
|
||||
* Tests for:
|
||||
* - Fetching exercises
|
||||
* - Submitting answers
|
||||
* - Progress tracking
|
||||
* - Concurrent submission handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express, { ErrorRequestHandler } from 'express';
|
||||
import { prisma } from '../../src/shared/database/prisma.client';
|
||||
import exerciseRoutes from '../../src/modules/exercise/exercise.routes';
|
||||
import { authRoutes } from '../../src/modules/auth/auth.routes';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Simple error handler for tests
|
||||
const testErrorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
const code = err.code || 'INTERNAL_ERROR';
|
||||
const message = err.message || 'An unexpected error occurred';
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Exercise API Integration', () => {
|
||||
let app: express.Application;
|
||||
let authToken: string;
|
||||
let userId: string;
|
||||
let moduleId: string;
|
||||
let exerciseId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.$connect();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data in correct order (respecting FK constraints)
|
||||
await prisma.exerciseAttempt.deleteMany({});
|
||||
await prisma.progress.deleteMany({});
|
||||
await prisma.exercise.deleteMany({
|
||||
where: {
|
||||
statement: {
|
||||
contains: 'Test Exercise',
|
||||
},
|
||||
},
|
||||
});
|
||||
// Delete test modules to avoid unique constraint violations
|
||||
await prisma.modules.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: 'Test Module',
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.refreshToken.deleteMany({});
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: {
|
||||
contains: 'test@',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create fresh app
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/exercises', exerciseRoutes);
|
||||
|
||||
// Add error handler last
|
||||
app.use(testErrorHandler);
|
||||
|
||||
// Create test user
|
||||
const registerResponse = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: `test-${randomUUID()}@example.com`,
|
||||
password: 'SecurePass123!',
|
||||
username: `testuser-${randomUUID()}`,
|
||||
});
|
||||
|
||||
authToken = registerResponse.body.data.token;
|
||||
userId = registerResponse.body.data.user.id;
|
||||
|
||||
// Create test module with unique order (random to avoid collisions)
|
||||
const testModule = await prisma.modules.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
name: 'Test Module',
|
||||
description: 'Test module for exercises',
|
||||
order: Math.floor(Math.random() * 1000000), // Random unique order
|
||||
type: 'FUNDAMENTOS',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
moduleId = testModule.id;
|
||||
|
||||
// Create test exercise
|
||||
const exercise = await prisma.exercise.create({
|
||||
data: {
|
||||
moduleId: moduleId,
|
||||
topicId: null,
|
||||
type: 'CALCULATION',
|
||||
difficulty: 'BASIC',
|
||||
order: 1,
|
||||
statement: 'Test Exercise: What is 2+2?',
|
||||
correctAnswer: '4',
|
||||
points: 10,
|
||||
timeLimitSeconds: 120,
|
||||
hints: [],
|
||||
solutionSteps: [],
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
exerciseId = exercise.id;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// GET EXERCISE TESTS
|
||||
// ============================================
|
||||
|
||||
describe('GET /api/exercises/:id', () => {
|
||||
it('should get exercise details without exposing correct answer', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/exercises/${exerciseId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.id).toBe(exerciseId);
|
||||
expect(response.body.data.statement).toBe('Test Exercise: What is 2+2?');
|
||||
expect(response.body.data.correctAnswer).toBe('');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent exercise', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/exercises/non-existent-id')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SUBMIT ATTEMPT TESTS
|
||||
// ============================================
|
||||
|
||||
describe('POST /api/exercises/:id/attempt', () => {
|
||||
it('should submit correct answer successfully', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '4',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.isCorrect).toBe(true);
|
||||
expect(response.body.data.points).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle incorrect answer', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '5',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.isCorrect).toBe(false);
|
||||
expect(response.body.data.points).toBe(0);
|
||||
});
|
||||
|
||||
it('should validate LaTeX answer format', async () => {
|
||||
// Create exercise with LaTeX answer
|
||||
const latexExercise = await prisma.exercise.create({
|
||||
data: {
|
||||
moduleId: moduleId,
|
||||
type: 'CALCULATION',
|
||||
difficulty: 'BASIC',
|
||||
order: 2,
|
||||
statement: 'Test LaTeX: What is the fraction?',
|
||||
correctAnswer: '\\frac{1}{2}',
|
||||
points: 15,
|
||||
timeLimitSeconds: 120,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/exercises/${latexExercise.id}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '\\frac{1}{2}',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.isCorrect).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect and reject XSS attempts in LaTeX', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '\\href{javascript:alert(1)}{x}',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle skipped exercises', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '',
|
||||
timeSpent: 0,
|
||||
skipped: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.isCorrect).toBe(false);
|
||||
expect(response.body.data.skipped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// CONCURRENT SUBMISSION TESTS
|
||||
// ============================================
|
||||
|
||||
describe('Concurrent submission handling', () => {
|
||||
it('should handle multiple rapid submissions gracefully', async () => {
|
||||
const submissions = Array(5).fill(null).map(() =>
|
||||
request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '4',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(submissions);
|
||||
|
||||
// All requests should complete without server errors
|
||||
results.forEach(result => {
|
||||
expect(result.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PROGRESS TESTS
|
||||
// ============================================
|
||||
|
||||
describe('GET /api/exercises/:id/attempts', () => {
|
||||
it('should get user attempts for exercise', async () => {
|
||||
// Submit a few attempts first
|
||||
await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '5',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post(`/api/exercises/${exerciseId}/attempt`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
answer: '4',
|
||||
timeSpent: 30,
|
||||
hintsUsed: 0,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/exercises/${exerciseId}/attempts`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThanOrEqual(2);
|
||||
expect(response.body.meta.hasCompleted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
330
backend/tests/redis.client.test.ts
Normal file
330
backend/tests/redis.client.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Tests for Redis Client - Token Blacklist Security
|
||||
*
|
||||
* Verifies fail-closed behavior when Redis is unavailable.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach, beforeAll } from 'vitest';
|
||||
|
||||
// Hoist the mock so it applies before module imports
|
||||
const mockRedisFns = vi.hoisted(() => ({
|
||||
exists: vi.fn(),
|
||||
setex: vi.fn(),
|
||||
on: vi.fn(),
|
||||
quit: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock ioredis before any imports
|
||||
vi.mock('ioredis', async () => {
|
||||
return {
|
||||
default: class MockRedis {
|
||||
status = 'ready';
|
||||
on = mockRedisFns.on;
|
||||
exists = mockRedisFns.exists;
|
||||
setex = mockRedisFns.setex;
|
||||
quit = mockRedisFns.quit;
|
||||
constructor() {
|
||||
// Simulate async connection
|
||||
setTimeout(() => {
|
||||
this.on('connect', () => {});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../src/shared/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
let redisModule: typeof import('../src/shared/database/redis.client');
|
||||
|
||||
describe('Token Blacklist - Security (Fail-Closed)', () => {
|
||||
beforeAll(async () => {
|
||||
// Dynamic import after mocks are established
|
||||
redisModule = await import('../src/shared/database/redis.client');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redisModule.resetBlacklistMetrics();
|
||||
vi.clearAllMocks();
|
||||
// Reset mock implementations
|
||||
mockRedisFns.exists.mockReset();
|
||||
mockRedisFns.setex.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return true when token is blacklisted in Redis', async () => {
|
||||
mockRedisFns.exists.mockResolvedValue(1);
|
||||
|
||||
const result = await redisModule.isTokenBlacklisted('blacklisted-token');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRedisFns.exists).toHaveBeenCalledWith('token_blacklist:blacklisted-token');
|
||||
});
|
||||
|
||||
it('should return false when token is not blacklisted in Redis', async () => {
|
||||
mockRedisFns.exists.mockResolvedValue(0);
|
||||
|
||||
const result = await redisModule.isTokenBlacklisted('valid-token');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should FAIL-CLOSED when Redis throws error on exists check', async () => {
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Connection lost'));
|
||||
|
||||
// Should throw AuthenticationError instead of returning false (fail-open)
|
||||
await expect(redisModule.isTokenBlacklisted('any-token'))
|
||||
.rejects
|
||||
.toThrow('Unable to verify token status. Service temporarily unavailable.');
|
||||
|
||||
// Verify metrics tracked the failure
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
|
||||
expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should FAIL-CLOSED when Redis is unavailable (multiple consecutive failures)', async () => {
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable'));
|
||||
|
||||
// Reset metrics to start fresh
|
||||
redisModule.resetBlacklistMetrics();
|
||||
|
||||
// Simulate multiple failures to trigger circuit breaker
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
await redisModule.isTokenBlacklisted('token-' + i);
|
||||
} catch (e) {
|
||||
// Expected to fail
|
||||
}
|
||||
}
|
||||
|
||||
// After 5 failures, circuit breaker should have opened
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should retry Redis operation with exponential backoff before failing', async () => {
|
||||
// First two calls fail, third succeeds
|
||||
mockRedisFns.exists
|
||||
.mockRejectedValueOnce(new Error('Temporary error'))
|
||||
.mockRejectedValueOnce(new Error('Temporary error'))
|
||||
.mockResolvedValueOnce(0);
|
||||
|
||||
const result = await redisModule.isTokenBlacklisted('test-token');
|
||||
|
||||
// Should succeed on third attempt after retries
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedisFns.exists).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should track success metrics when Redis check succeeds', async () => {
|
||||
mockRedisFns.exists.mockResolvedValue(0);
|
||||
|
||||
redisModule.resetBlacklistMetrics();
|
||||
|
||||
await redisModule.isTokenBlacklisted('test-token');
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blacklist Token Operations', () => {
|
||||
beforeAll(async () => {
|
||||
if (!redisModule) {
|
||||
redisModule = await import('../src/shared/database/redis.client');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redisModule.resetBlacklistMetrics();
|
||||
vi.clearAllMocks();
|
||||
mockRedisFns.setex.mockReset();
|
||||
});
|
||||
|
||||
it('should blacklist token successfully in Redis', async () => {
|
||||
mockRedisFns.setex.mockResolvedValue('OK');
|
||||
|
||||
await redisModule.blacklistToken('token-to-blacklist');
|
||||
|
||||
expect(mockRedisFns.setex).toHaveBeenCalledWith(
|
||||
'token_blacklist:token-to-blacklist',
|
||||
604800, // 7 days in seconds
|
||||
'1'
|
||||
);
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should store token in memory cache when Redis fails', async () => {
|
||||
mockRedisFns.setex.mockRejectedValue(new Error('Redis down'));
|
||||
|
||||
// Should not throw - graceful degradation to memory cache
|
||||
await expect(redisModule.blacklistToken('token-to-blacklist')).resolves.not.toThrow();
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Circuit Breaker Behavior', () => {
|
||||
beforeAll(async () => {
|
||||
if (!redisModule) {
|
||||
redisModule = await import('../src/shared/database/redis.client');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redisModule.resetBlacklistMetrics();
|
||||
vi.clearAllMocks();
|
||||
mockRedisFns.exists.mockReset();
|
||||
});
|
||||
|
||||
it('should open circuit breaker after 5 consecutive failures', async () => {
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable'));
|
||||
|
||||
// Attempt 5 times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
await redisModule.isTokenBlacklisted('test-token');
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.circuitBreakerOpens).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should reset consecutive failures counter on successful operation', async () => {
|
||||
mockRedisFns.exists
|
||||
.mockRejectedValueOnce(new Error('Error 1'))
|
||||
.mockRejectedValueOnce(new Error('Error 2'))
|
||||
.mockResolvedValueOnce(0);
|
||||
|
||||
// First two should fail
|
||||
try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {}
|
||||
try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {}
|
||||
|
||||
// Third should succeed after retries and reset counter
|
||||
const result = await redisModule.isTokenBlacklisted('test-token');
|
||||
expect(result).toBe(false);
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
// After success, consecutive failures should be reset to 0
|
||||
expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security - Preventing Authentication Bypass', () => {
|
||||
beforeAll(async () => {
|
||||
if (!redisModule) {
|
||||
redisModule = await import('../src/shared/database/redis.client');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redisModule.resetBlacklistMetrics();
|
||||
vi.clearAllMocks();
|
||||
mockRedisFns.exists.mockReset();
|
||||
});
|
||||
|
||||
it('should never return false (allow access) when Redis is unavailable', async () => {
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
// Even with a normally valid token, Redis failure should cause rejection
|
||||
try {
|
||||
await redisModule.isTokenBlacklisted('potentially-valid-token');
|
||||
// If we reach here, the test fails - we should have thrown
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('Unable to verify token status');
|
||||
}
|
||||
});
|
||||
|
||||
it('should log security events with proper token prefix (never full token)', async () => {
|
||||
const { logger } = await import('../src/shared/utils/logger');
|
||||
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Redis down'));
|
||||
|
||||
const longToken = 'this-is-a-very-long-secret-token-that-should-not-be-logged';
|
||||
|
||||
try {
|
||||
await redisModule.isTokenBlacklisted(longToken);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Check that logger.error was called
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
|
||||
// Get all calls to logger.error
|
||||
const errorCalls = vi.mocked(logger.error).mock.calls;
|
||||
expect(errorCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify no full token in logs - check all arguments
|
||||
const allArgs = JSON.stringify(errorCalls);
|
||||
expect(allArgs).not.toContain(longToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics and Observability', () => {
|
||||
beforeAll(async () => {
|
||||
if (!redisModule) {
|
||||
redisModule = await import('../src/shared/database/redis.client');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redisModule.resetBlacklistMetrics();
|
||||
vi.clearAllMocks();
|
||||
mockRedisFns.exists.mockReset();
|
||||
mockRedisFns.setex.mockReset();
|
||||
});
|
||||
|
||||
it('should track all relevant metrics', async () => {
|
||||
mockRedisFns.exists.mockResolvedValue(0);
|
||||
mockRedisFns.setex.mockResolvedValue('OK');
|
||||
|
||||
await redisModule.isTokenBlacklisted('test-1');
|
||||
await redisModule.isTokenBlacklisted('test-2');
|
||||
await redisModule.blacklistToken('token-1');
|
||||
|
||||
const metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(3);
|
||||
expect(metrics.redisBlacklistFailures).toBe(0);
|
||||
expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
|
||||
});
|
||||
|
||||
it('should reset metrics when resetBlacklistMetrics is called', async () => {
|
||||
// First, accumulate some metrics with a failure
|
||||
mockRedisFns.exists.mockRejectedValue(new Error('Error'));
|
||||
|
||||
try { await redisModule.isTokenBlacklisted('test'); } catch (e) {}
|
||||
|
||||
let metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Reset
|
||||
redisModule.resetBlacklistMetrics();
|
||||
|
||||
metrics = redisModule.getBlacklistMetrics();
|
||||
expect(metrics.redisBlacklistFailures).toBe(0);
|
||||
expect(metrics.redisBlacklistSuccesses).toBe(0);
|
||||
expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
|
||||
expect(metrics.circuitBreakerOpens).toBe(0);
|
||||
});
|
||||
});
|
||||
58
backend/tests/setup.ts
Normal file
58
backend/tests/setup.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Vitest Test Setup
|
||||
*
|
||||
* Creates mock objects for testing
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock environment variables
|
||||
vi.stubEnv('JWT_SECRET', 'test-jwt-secret-for-unit-tests');
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
|
||||
// ============================================
|
||||
// MOCK PRISMA
|
||||
// ============================================
|
||||
|
||||
export const mockPrisma = {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
exercise: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
exerciseAttempt: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
progress: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
ranking: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
module: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrisma)),
|
||||
};
|
||||
250
backend/tests/system-config.test.ts
Normal file
250
backend/tests/system-config.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* System Config Service Tests
|
||||
*
|
||||
* Unit tests for SystemConfigService
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { SystemConfigService } from '../src/modules/system-config/system-config.service';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const service = new SystemConfigService(prisma);
|
||||
|
||||
describe('SystemConfigService', () => {
|
||||
beforeAll(async () => {
|
||||
// Clean up test configs
|
||||
await prisma.systemConfig.deleteMany({
|
||||
where: { key: { startsWith: 'test.' } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up after tests
|
||||
await prisma.systemConfig.deleteMany({
|
||||
where: { key: { startsWith: 'test.' } },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('should create a new config', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.create',
|
||||
value: 'test-value',
|
||||
category: 'platform',
|
||||
dataType: 'string',
|
||||
});
|
||||
|
||||
const value = await service.get('test.create');
|
||||
expect(value).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should update existing config', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.update',
|
||||
value: 'original',
|
||||
category: 'platform',
|
||||
});
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.update',
|
||||
value: 'updated',
|
||||
category: 'platform',
|
||||
});
|
||||
|
||||
const value = await service.get('test.update');
|
||||
expect(value).toBe('updated');
|
||||
});
|
||||
|
||||
it('should track change history', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.history',
|
||||
value: 'v1',
|
||||
category: 'platform',
|
||||
}, 'user-1');
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.history',
|
||||
value: 'v2',
|
||||
category: 'platform',
|
||||
}, 'user-2');
|
||||
|
||||
const history = await service.getChangeHistory('test.history');
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].value).toBe('v1');
|
||||
expect(history[0].user).toBe('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParsed', () => {
|
||||
it('should parse boolean values', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.bool.true',
|
||||
value: 'true',
|
||||
category: 'platform',
|
||||
dataType: 'boolean',
|
||||
});
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.bool.false',
|
||||
value: 'false',
|
||||
category: 'platform',
|
||||
dataType: 'boolean',
|
||||
});
|
||||
|
||||
expect(await service.getParsed('test.bool.true')).toBe(true);
|
||||
expect(await service.getParsed('test.bool.false')).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse number values', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.number',
|
||||
value: '42.5',
|
||||
category: 'platform',
|
||||
dataType: 'number',
|
||||
});
|
||||
|
||||
const value = await service.getParsed('test.number');
|
||||
expect(value).toBe(42.5);
|
||||
});
|
||||
|
||||
it('should parse json values', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.json',
|
||||
value: '{"key": "value", "num": 123}',
|
||||
category: 'platform',
|
||||
dataType: 'json',
|
||||
});
|
||||
|
||||
const value = await service.getParsed('test.json');
|
||||
expect(value).toEqual({ key: 'value', num: 123 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByCategory', () => {
|
||||
it('should filter by category', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.cat.platform',
|
||||
value: '1',
|
||||
category: 'platform',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.cat.ai',
|
||||
value: '2',
|
||||
category: 'ai',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
const platformConfigs = await service.getByCategory('platform');
|
||||
expect(platformConfigs.some(c => c.key === 'test.cat.platform')).toBe(true);
|
||||
expect(platformConfigs.some(c => c.key === 'test.cat.ai')).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter public/private configs', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.public',
|
||||
value: 'public',
|
||||
category: 'platform',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.private',
|
||||
value: 'private',
|
||||
category: 'platform',
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const publicConfigs = await service.getByCategory('platform');
|
||||
expect(publicConfigs.some(c => c.key === 'test.public')).toBe(true);
|
||||
expect(publicConfigs.some(c => c.key === 'test.private')).toBe(false);
|
||||
|
||||
const allConfigs = await service.getByCategory('platform', true);
|
||||
expect(allConfigs.some(c => c.key === 'test.private')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicConfigs', () => {
|
||||
it('should return only public configs', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.public.all',
|
||||
value: 'visible',
|
||||
category: 'platform',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
await service.upsert({
|
||||
key: 'test.private.all',
|
||||
value: 'hidden',
|
||||
category: 'platform',
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const configs = await service.getPublicConfigs();
|
||||
expect(configs.some(c => c.key === 'test.public.all')).toBe(true);
|
||||
expect(configs.some(c => c.key === 'test.private.all')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateValue', () => {
|
||||
it('should update only the value', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.update.value',
|
||||
value: 'original',
|
||||
description: 'Original description',
|
||||
category: 'platform',
|
||||
});
|
||||
|
||||
await service.updateValue('test.update.value', 'new-value', 'admin-1');
|
||||
|
||||
const config = await prisma.systemConfig.findUnique({
|
||||
where: { key: 'test.update.value' },
|
||||
});
|
||||
|
||||
expect(config?.value).toBe('new-value');
|
||||
expect(config?.description).toBe('Original description');
|
||||
expect(config?.category).toBe('platform');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent key', async () => {
|
||||
await expect(
|
||||
service.updateValue('test.nonexistent', 'value')
|
||||
).rejects.toThrow("Config with key 'test.nonexistent' not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete config', async () => {
|
||||
await service.upsert({
|
||||
key: 'test.delete',
|
||||
value: 'to-delete',
|
||||
category: 'platform',
|
||||
});
|
||||
|
||||
await service.delete('test.delete');
|
||||
|
||||
const value = await service.get('test.delete');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseValue', () => {
|
||||
it('should parse different data types correctly', () => {
|
||||
expect(service.parseValue('123', 'number')).toBe(123);
|
||||
expect(service.parseValue('45.67', 'number')).toBe(45.67);
|
||||
expect(service.parseValue('true', 'boolean')).toBe(true);
|
||||
expect(service.parseValue('1', 'boolean')).toBe(true);
|
||||
expect(service.parseValue('false', 'boolean')).toBe(false);
|
||||
expect(service.parseValue('{"a":1}', 'json')).toEqual({ a: 1 });
|
||||
expect(service.parseValue('2024-01-01', 'date')).toEqual(new Date('2024-01-01'));
|
||||
expect(service.parseValue('plain string', 'string')).toBe('plain string');
|
||||
});
|
||||
|
||||
it('should handle invalid json gracefully', () => {
|
||||
expect(service.parseValue('invalid json', 'json')).toBe('invalid json');
|
||||
});
|
||||
});
|
||||
});
|
||||
339
backend/tests/unit/auth.service.test.ts
Normal file
339
backend/tests/unit/auth.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
784
backend/tests/unit/exercise.service.test.ts
Normal file
784
backend/tests/unit/exercise.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
570
backend/tests/unit/score.calculator.test.ts
Normal file
570
backend/tests/unit/score.calculator.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
485
backend/tests/unit/streak.calculator.test.ts
Normal file
485
backend/tests/unit/streak.calculator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user