/** * 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); }); });