test: add tests and questions service tests
Made-with: Cursor
This commit is contained in:
230
tests/services/tests.service.test.ts
Normal file
230
tests/services/tests.service.test.ts
Normal file
@@ -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<typeof createMockDb>;
|
||||
let mockQuestionService: { getQuestionsForTest: ReturnType<typeof vi.fn> };
|
||||
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<typeof vi.fn>)
|
||||
.mockReturnValueOnce(insertChain([mockTest]))
|
||||
.mockReturnValueOnce(insertChain([]));
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([mockTest]))
|
||||
.mockReturnValueOnce(selectChain([mockTq]));
|
||||
(mockDb.update as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.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<typeof vi.fn>)
|
||||
.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user