✨ 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 ✅
784 lines
24 KiB
TypeScript
784 lines
24 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
}); |