From e00f9e197c13bdbbe4651105bf6fb705ab565613 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 15:44:28 +0300 Subject: [PATCH] test: add tests and questions service tests Made-with: Cursor --- tests/services/question.service.test.ts | 117 ++++++++++++ tests/services/tests.service.test.ts | 230 ++++++++++++++++++++++++ tests/test-utils.ts | 46 +++++ 3 files changed, 393 insertions(+) create mode 100644 tests/services/question.service.test.ts create mode 100644 tests/services/tests.service.test.ts diff --git a/tests/services/question.service.test.ts b/tests/services/question.service.test.ts new file mode 100644 index 0000000..7aa867d --- /dev/null +++ b/tests/services/question.service.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QuestionService } from '../../src/services/questions/question.service.js'; +import { + createMockDb, + selectChainOrdered, + selectChainSimple, + insertChain, + updateChain, +} from '../test-utils.js'; + +const mockLlmQuestions = [ + { + questionBankId: 'qb-new', + type: 'single_choice' as const, + questionText: 'New question?', + options: [{ key: 'a', text: 'A' }, { key: 'b', text: 'B' }], + correctAnswer: 'a', + explanation: 'Explanation', + stack: 'js' as const, + level: 'beginner' as const, + }, +]; + +describe('QuestionService', () => { + let mockDb: ReturnType; + let mockLlmService: { generateQuestions: ReturnType }; + let questionService: QuestionService; + + beforeEach(() => { + vi.clearAllMocks(); + mockDb = createMockDb(); + mockLlmService = { + generateQuestions: vi.fn().mockResolvedValue({ + questions: mockLlmQuestions, + meta: { + llmModel: 'test-model', + promptHash: 'hash', + generationTimeMs: 100, + rawResponse: {}, + }, + }), + }; + questionService = new QuestionService(mockDb as never, mockLlmService as never); + }); + + describe('getQuestionsForTest', () => { + it('returns questions from bank when enough approved exist', async () => { + const approvedRows = [ + { + id: 'qb-1', + type: 'single_choice', + questionText: 'Q1?', + options: [{ key: 'a', text: 'A' }], + correctAnswer: 'a', + explanation: 'e', + }, + ]; + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChainOrdered(approvedRows)) + .mockReturnValueOnce(selectChainSimple([])); + (mockDb.insert as ReturnType).mockReturnValueOnce(insertChain([])); + (mockDb.update as ReturnType).mockReturnValueOnce(updateChain([])); + + const result = await questionService.getQuestionsForTest( + 'user-1', + 'js', + 'beginner', + 1 + ); + + expect(result).toHaveLength(1); + expect(result[0].questionBankId).toBe('qb-1'); + expect(mockLlmService.generateQuestions).not.toHaveBeenCalled(); + }); + + it('calls LLM when not enough questions in bank', async () => { + const approvedRows: unknown[] = []; + const seenIds: unknown[] = []; + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChainOrdered(approvedRows)) + .mockReturnValueOnce(selectChainSimple(seenIds)); + (mockDb.insert as ReturnType) + .mockReturnValueOnce(insertChain([{ id: 'qb-new', ...mockLlmQuestions[0] }])) + .mockReturnValueOnce(insertChain([])) + .mockReturnValueOnce(insertChain([])); + (mockDb.update as ReturnType).mockReturnValueOnce(updateChain([])); + + const result = await questionService.getQuestionsForTest( + 'user-1', + 'js', + 'beginner', + 1 + ); + + expect(result).toHaveLength(1); + expect(mockLlmService.generateQuestions).toHaveBeenCalledWith({ + stack: 'js', + level: 'beginner', + count: 1, + }); + }); + + it('throws when not enough questions available', async () => { + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChainOrdered([])) + .mockReturnValueOnce(selectChainSimple([])); + mockLlmService.generateQuestions.mockResolvedValueOnce({ + questions: [], + meta: { llmModel: 'x', promptHash: 'y', generationTimeMs: 0, rawResponse: {} }, + }); + + await expect( + questionService.getQuestionsForTest('user-1', 'js', 'beginner', 5) + ).rejects.toMatchObject({ code: 'QUESTIONS_UNAVAILABLE' }); + }); + }); +}); diff --git a/tests/services/tests.service.test.ts b/tests/services/tests.service.test.ts new file mode 100644 index 0000000..023b120 --- /dev/null +++ b/tests/services/tests.service.test.ts @@ -0,0 +1,230 @@ +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); + }); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 651fb47..6a61121 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -24,6 +24,40 @@ export function selectChain(resolveAtLimit: unknown[] = []) { }; } +/** Build a select chain for .from().where().orderBy() - orderBy is terminal */ +export function selectChainOrdered(resolveAtOrderBy: unknown[] = []) { + const orderByThenable = { + then: (resolve: (v: unknown) => void) => resolve(resolveAtOrderBy), + }; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue(orderByThenable), + }), + }), + }; +} + +/** Build a select chain for .from().where() with no orderBy - used by select({...}).from() */ +export function selectChainSimple(resolveRows: unknown[] = []) { + const thenable = { then: (resolve: (v: unknown) => void) => resolve(resolveRows) }; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue(thenable), + }), + }; +} + +/** Build a select chain for .from().where() - where is terminal (no orderBy/limit) */ +export function selectChainWhere(resolveAtWhere: unknown[] = []) { + const thenable = { then: (resolve: (v: unknown) => void) => resolve(resolveAtWhere) }; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue(thenable), + }), + }; +} + /** Build an insert chain that resolves at .returning() or .values() */ export function insertChain(resolveAtReturning: unknown[] = []) { const returningFn = vi.fn().mockResolvedValue(resolveAtReturning); @@ -46,6 +80,18 @@ export function updateChain(resolveAtWhere: unknown[] = []) { }; } +/** Build an update chain with .where().returning() */ +export function updateChainReturning(resolveAtReturning: unknown[] = []) { + const returningFn = vi.fn().mockResolvedValue(resolveAtReturning); + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: returningFn, + }), + }), + }; +} + /** Build a delete chain that resolves at .where() */ export function deleteChain() { return {