import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TestsService } from '../../src/services/tests/tests.service.js'; import { createMockDb, selectChain, selectChainOrdered, selectChainWhere, insertChain, updateChain, updateChainReturning, } from '../test-utils.js'; const mockQuestions = [ { questionBankId: 'qb-1', type: 'single_choice' as const, questionText: 'What is 2+2?', options: [{ key: 'a', text: '4' }, { key: 'b', text: '3' }], correctAnswer: 'a', explanation: 'Basic math', }, ]; describe('TestsService', () => { let mockDb: ReturnType; let mockQuestionService: { getQuestionsForTest: ReturnType }; let testsService: TestsService; beforeEach(() => { vi.clearAllMocks(); mockDb = createMockDb(); mockQuestionService = { getQuestionsForTest: vi.fn().mockResolvedValue(mockQuestions), }; testsService = new TestsService(mockDb as never, mockQuestionService as never); }); describe('createTest', () => { it('creates a test with questions from QuestionService', async () => { const mockTest = { id: 'test-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'in_progress', score: null, startedAt: new Date(), finishedAt: null, timeLimitSeconds: null, }; const mockTqRows = [ { id: 'tq-1', testId: 'test-1', questionBankId: 'qb-1', orderNumber: 1, type: 'single_choice', questionText: 'What is 2+2?', options: [{ key: 'a', text: '4' }], correctAnswer: 'a', explanation: 'Basic math', userAnswer: null, isCorrect: null, answeredAt: null, }, ]; (mockDb.insert as ReturnType) .mockReturnValueOnce(insertChain([mockTest])) .mockReturnValueOnce(insertChain([])); (mockDb.select as ReturnType).mockReturnValueOnce( selectChainOrdered(mockTqRows) ); const result = await testsService.createTest('user-1', { stack: 'js', level: 'beginner', questionCount: 1, }); expect(result.id).toBe('test-1'); expect(result.questions).toHaveLength(1); expect(mockQuestionService.getQuestionsForTest).toHaveBeenCalledWith( 'user-1', 'js', 'beginner', 1 ); }); it('throws when questionCount is out of range', async () => { await expect( testsService.createTest('user-1', { stack: 'js', level: 'beginner', questionCount: 0, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); await expect( testsService.createTest('user-1', { stack: 'js', level: 'beginner', questionCount: 51, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); }); describe('answerQuestion', () => { it('returns updated question snapshot when answer is correct', async () => { const mockTest = { id: 't-1', userId: 'user-1', status: 'in_progress' }; const mockTq = { id: 'tq-1', testId: 't-1', correctAnswer: 'a', userAnswer: null, questionBankId: 'qb-1', orderNumber: 1, type: 'single_choice', questionText: 'Q?', options: [{ key: 'a', text: 'A' }], explanation: 'exp', }; const updatedTq = { ...mockTq, userAnswer: 'a', isCorrect: true, answeredAt: new Date() }; (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([mockTest])) .mockReturnValueOnce(selectChain([mockTq])); (mockDb.update as ReturnType).mockReturnValueOnce( updateChainReturning([updatedTq]) ); const result = await testsService.answerQuestion('user-1', 't-1', 'tq-1', 'a'); expect(result.isCorrect).toBe(true); expect(result.userAnswer).toBe('a'); }); it('throws when test not found', async () => { (mockDb.select as ReturnType).mockReturnValueOnce(selectChain([])); await expect( testsService.answerQuestion('user-1', 'bad-id', 'tq-1', 'a') ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); it('throws when test is already finished', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 't-1', userId: 'user-1', status: 'completed' }]) ); await expect( testsService.answerQuestion('user-1', 't-1', 'tq-1', 'a') ).rejects.toMatchObject({ code: 'TEST_ALREADY_FINISHED' }); }); }); describe('finishTest', () => { it('throws when there are unanswered questions', async () => { const mockTest = { id: 't-1', userId: 'user-1', status: 'in_progress', stack: 'js', level: 'beginner' }; const mockQuestionsWithUnanswered = [ { id: 'tq-1', testId: 't-1', userAnswer: 'a', isCorrect: true }, { id: 'tq-2', testId: 't-1', userAnswer: null, isCorrect: null }, ]; (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([mockTest])) .mockReturnValueOnce(selectChainWhere(mockQuestionsWithUnanswered)); await expect( testsService.finishTest('user-1', 't-1') ).rejects.toMatchObject({ code: 'NO_ANSWERS' }); }); }); describe('getById', () => { it('returns null when test not found', async () => { (mockDb.select as ReturnType).mockReturnValueOnce(selectChain([])); const result = await testsService.getById('user-1', 'bad-id'); expect(result).toBeNull(); }); it('returns test with questions when found', async () => { const mockTest = { id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 2, mode: 'fixed', status: 'in_progress', score: null, startedAt: new Date(), finishedAt: null, timeLimitSeconds: null, }; const mockTqRows = [ { id: 'tq-1', testId: 't-1', orderNumber: 1, questionBankId: 'qb-1', type: 'single_choice', questionText: 'Q1', options: [], correctAnswer: 'a', explanation: 'e', userAnswer: null, isCorrect: null, answeredAt: null }, ]; (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([mockTest])) .mockReturnValueOnce(selectChainOrdered(mockTqRows)); const result = await testsService.getById('user-1', 't-1'); expect(result).not.toBeNull(); expect(result!.id).toBe('t-1'); expect(result!.questions).toHaveLength(1); }); }); describe('getHistory', () => { it('returns paginated test history', async () => { const mockTests = [ { id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'completed', score: 100, startedAt: new Date(), finishedAt: new Date(), timeLimitSeconds: null }, ]; const mockTqRows = []; (mockDb.select as ReturnType) .mockReturnValueOnce(selectChainOrdered(mockTests)) .mockReturnValueOnce(selectChainOrdered(mockTqRows)); const result = await testsService.getHistory('user-1', { limit: 10, offset: 0 }); expect(result.tests).toHaveLength(1); expect(result.total).toBe(1); }); }); });