Skip to content

Commit dbfb174

Browse files
Merge pull request #1360 from bobaivigitalpoint-ui/fix/issues-1248-1261-1264-1266
test+feat: unit tests for audit-log/reporting/grading services + notification-preferences indexes
2 parents e334a43 + 7263887 commit dbfb174

5 files changed

Lines changed: 805 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { BadRequestException, NotFoundException } from '@nestjs/common';
4+
import { DataSource } from 'typeorm';
5+
import { GradingService } from './grading.service';
6+
import { SubmissionGrade, SubmissionGradeStatus } from './entities/submission-grade.entity';
7+
import { CriterionGrade } from './entities/criterion-grade.entity';
8+
import { AssessmentAttempt } from '../entities/assessment-attempt.entity';
9+
import { RubricsService } from './rubrics.service';
10+
import { FeedbackTemplatesService } from './feedback-templates.service';
11+
import { AssessmentStatus } from '../enums/assessment-status.enum';
12+
13+
// ── Fixtures ────────────────────────────────────────────────────────────────
14+
15+
const criterionId = 'c1';
16+
const levelId = 'l1';
17+
18+
const mockLevel = { id: levelId, criterionId, points: 8, label: 'Good' };
19+
const mockCriterion = {
20+
id: criterionId,
21+
title: 'Writing',
22+
maxPoints: 10,
23+
defaultLevelId: levelId,
24+
levels: [mockLevel],
25+
};
26+
const mockRubric = {
27+
id: 'r1',
28+
name: 'Essay',
29+
autoGradeEnabled: true,
30+
criteria: [mockCriterion],
31+
};
32+
const mockAttempt = {
33+
id: 'a1',
34+
score: 0,
35+
status: AssessmentStatus.SUBMITTED,
36+
submittedAt: null,
37+
};
38+
const mockGrade = {
39+
id: 'g1',
40+
attemptId: 'a1',
41+
status: SubmissionGradeStatus.GRADED,
42+
totalScore: 8,
43+
maxScore: 10,
44+
percentage: 80,
45+
criterionGrades: [],
46+
rubric: mockRubric,
47+
};
48+
49+
// ── Mock helpers ─────────────────────────────────────────────────────────────
50+
51+
const makeGradeRepo = () => ({
52+
findOne: jest.fn(),
53+
create: jest.fn().mockImplementation((v) => v),
54+
save: jest.fn().mockImplementation(async (v) => v),
55+
});
56+
57+
const makeCriterionGradeRepo = () => ({
58+
create: jest.fn().mockImplementation((v) => v),
59+
save: jest.fn().mockImplementation(async (v) => v),
60+
delete: jest.fn().mockResolvedValue(undefined),
61+
});
62+
63+
const makeAttemptRepo = () => ({
64+
findOne: jest.fn(),
65+
save: jest.fn().mockImplementation(async (v) => v),
66+
});
67+
68+
const makeDataSource = (
69+
gradeRepo: ReturnType<typeof makeGradeRepo>,
70+
cgRepo: ReturnType<typeof makeCriterionGradeRepo>,
71+
attemptRepo: ReturnType<typeof makeAttemptRepo>,
72+
) => ({
73+
transaction: jest.fn().mockImplementation(async (cb: (manager: any) => Promise<any>) => {
74+
const manager = {
75+
getRepository: jest.fn().mockImplementation((entity: any) => {
76+
if (entity === SubmissionGrade) return gradeRepo;
77+
if (entity === CriterionGrade) return cgRepo;
78+
if (entity === AssessmentAttempt) return attemptRepo;
79+
return {};
80+
}),
81+
};
82+
return cb(manager);
83+
}),
84+
});
85+
86+
// ── Tests ─────────────────────────────────────────────────────────────────────
87+
88+
describe('GradingService', () => {
89+
let service: GradingService;
90+
let gradeRepo: ReturnType<typeof makeGradeRepo>;
91+
let criterionGradeRepo: ReturnType<typeof makeCriterionGradeRepo>;
92+
let attemptRepo: ReturnType<typeof makeAttemptRepo>;
93+
let rubricsService: { findOne: jest.Mock };
94+
let feedbackTemplatesService: {
95+
findOne: jest.Mock;
96+
findDefault: jest.Mock;
97+
render: jest.Mock;
98+
};
99+
100+
beforeEach(async () => {
101+
gradeRepo = makeGradeRepo();
102+
criterionGradeRepo = makeCriterionGradeRepo();
103+
attemptRepo = makeAttemptRepo();
104+
105+
rubricsService = { findOne: jest.fn().mockResolvedValue(mockRubric) };
106+
feedbackTemplatesService = {
107+
findOne: jest.fn(),
108+
findDefault: jest.fn().mockResolvedValue(null),
109+
render: jest.fn().mockReturnValue('Good job'),
110+
};
111+
112+
// The outer-scope repos are used by findByAttempt; transaction uses inner repos.
113+
gradeRepo.findOne
114+
.mockResolvedValueOnce(null) // inside transaction: no existing grade
115+
.mockResolvedValueOnce({ ...mockGrade }); // final hydrated load
116+
117+
attemptRepo.findOne.mockResolvedValue({ ...mockAttempt });
118+
119+
const module: TestingModule = await Test.createTestingModule({
120+
providers: [
121+
GradingService,
122+
{ provide: getRepositoryToken(SubmissionGrade), useValue: gradeRepo },
123+
{ provide: getRepositoryToken(CriterionGrade), useValue: criterionGradeRepo },
124+
{ provide: getRepositoryToken(AssessmentAttempt), useValue: attemptRepo },
125+
{ provide: RubricsService, useValue: rubricsService },
126+
{ provide: FeedbackTemplatesService, useValue: feedbackTemplatesService },
127+
{
128+
provide: DataSource,
129+
useValue: makeDataSource(gradeRepo, criterionGradeRepo, attemptRepo),
130+
},
131+
],
132+
}).compile();
133+
134+
service = module.get<GradingService>(GradingService);
135+
});
136+
137+
afterEach(() => jest.clearAllMocks());
138+
139+
it('should be defined', () => {
140+
expect(service).toBeDefined();
141+
});
142+
143+
// ── gradeSubmission ────────────────────────────────────────────────────────
144+
145+
describe('gradeSubmission', () => {
146+
it('throws BadRequestException when score count mismatches rubric criteria', async () => {
147+
const dto = { rubricId: 'r1', attemptId: 'a1', scores: [] };
148+
await expect(service.gradeSubmission(dto)).rejects.toThrow(BadRequestException);
149+
});
150+
151+
it('throws BadRequestException for unknown criterionId', async () => {
152+
const dto = {
153+
rubricId: 'r1',
154+
attemptId: 'a1',
155+
scores: [{ criterionId: 'unknown', points: 5 }],
156+
};
157+
await expect(service.gradeSubmission(dto)).rejects.toThrow(BadRequestException);
158+
});
159+
160+
it('throws BadRequestException when the same criterion is scored twice', async () => {
161+
const twoScoreSameRubric = {
162+
...mockRubric,
163+
criteria: [
164+
{ ...mockCriterion, id: 'c1' },
165+
{ ...mockCriterion, id: 'c2' },
166+
],
167+
};
168+
rubricsService.findOne.mockResolvedValue(twoScoreSameRubric);
169+
170+
const dto = {
171+
rubricId: 'r1',
172+
attemptId: 'a1',
173+
scores: [
174+
{ criterionId: 'c1', points: 5 },
175+
{ criterionId: 'c1', points: 3 }, // duplicate
176+
],
177+
};
178+
await expect(service.gradeSubmission(dto)).rejects.toThrow(BadRequestException);
179+
});
180+
181+
it('throws BadRequestException when criterion score has neither levelId nor points', async () => {
182+
const dto = {
183+
rubricId: 'r1',
184+
attemptId: 'a1',
185+
scores: [{ criterionId }],
186+
};
187+
await expect(service.gradeSubmission(dto)).rejects.toThrow(BadRequestException);
188+
});
189+
190+
it('caps points at criterion maxPoints', async () => {
191+
const dto = {
192+
rubricId: 'r1',
193+
attemptId: 'a1',
194+
scores: [{ criterionId, points: 999 }],
195+
};
196+
await service.gradeSubmission(dto);
197+
// grade should be saved with totalScore = 10 (capped)
198+
const savedGrade = gradeRepo.save.mock.calls[0][0];
199+
expect(savedGrade.totalScore).toBe(10);
200+
});
201+
202+
it('resolves points from levelId when provided', async () => {
203+
const dto = {
204+
rubricId: 'r1',
205+
attemptId: 'a1',
206+
scores: [{ criterionId, levelId }],
207+
};
208+
await service.gradeSubmission(dto);
209+
const savedGrade = gradeRepo.save.mock.calls[0][0];
210+
expect(savedGrade.totalScore).toBe(8);
211+
});
212+
213+
it('throws NotFoundException for missing attempt', async () => {
214+
attemptRepo.findOne.mockResolvedValue(null);
215+
const dto = {
216+
rubricId: 'r1',
217+
attemptId: 'missing',
218+
scores: [{ criterionId, points: 5 }],
219+
};
220+
await expect(service.gradeSubmission(dto)).rejects.toThrow(NotFoundException);
221+
});
222+
});
223+
224+
// ── autoGradeSubmission ────────────────────────────────────────────────────
225+
226+
describe('autoGradeSubmission', () => {
227+
it('throws BadRequestException when rubric is not auto-grade enabled', async () => {
228+
rubricsService.findOne.mockResolvedValue({ ...mockRubric, autoGradeEnabled: false });
229+
await expect(
230+
service.autoGradeSubmission({ rubricId: 'r1', attemptId: 'a1' }),
231+
).rejects.toThrow(BadRequestException);
232+
});
233+
234+
it('throws BadRequestException when a criterion has no defaultLevelId', async () => {
235+
rubricsService.findOne.mockResolvedValue({
236+
...mockRubric,
237+
criteria: [{ ...mockCriterion, defaultLevelId: null }],
238+
});
239+
await expect(
240+
service.autoGradeSubmission({ rubricId: 'r1', attemptId: 'a1' }),
241+
).rejects.toThrow(BadRequestException);
242+
});
243+
244+
it('auto-grades using default level points', async () => {
245+
await service.autoGradeSubmission({ rubricId: 'r1', attemptId: 'a1' });
246+
const savedGrade = gradeRepo.save.mock.calls[0][0];
247+
expect(savedGrade.totalScore).toBe(8);
248+
expect(savedGrade.status).toBe(SubmissionGradeStatus.AUTO_GRADED);
249+
});
250+
});
251+
252+
// ── findByAttempt ──────────────────────────────────────────────────────────
253+
254+
describe('findByAttempt', () => {
255+
it('returns the grade for an attempt', async () => {
256+
gradeRepo.findOne.mockReset();
257+
gradeRepo.findOne.mockResolvedValue(mockGrade);
258+
const result = await service.findByAttempt('a1');
259+
expect(result).toBe(mockGrade);
260+
});
261+
262+
it('throws NotFoundException when no grade exists for the attempt', async () => {
263+
gradeRepo.findOne.mockReset();
264+
gradeRepo.findOne.mockResolvedValue(null);
265+
await expect(service.findByAttempt('missing')).rejects.toThrow(NotFoundException);
266+
});
267+
});
268+
});

0 commit comments

Comments
 (0)