From 8453448cfb0bc63beb79f52ba7ead7d6a24c2403 Mon Sep 17 00:00:00 2001 From: ugoocreates-pixel Date: Fri, 28 Aug 2026 03:28:09 +0100 Subject: [PATCH 1/2] test: add unit tests for rubrics service --- .../grading/rubrics.service.spec.ts | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/assessment/grading/rubrics.service.spec.ts diff --git a/src/assessment/grading/rubrics.service.spec.ts b/src/assessment/grading/rubrics.service.spec.ts new file mode 100644 index 00000000..29462523 --- /dev/null +++ b/src/assessment/grading/rubrics.service.spec.ts @@ -0,0 +1,303 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { RubricsService } from './rubrics.service'; +import { Rubric } from './entities/rubric.entity'; +import { RubricCriterion } from './entities/rubric-criterion.entity'; +import { RubricLevel } from './entities/rubric-level.entity'; + +describe('RubricsService', () => { + let service: RubricsService; + let rubricRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RubricsService, + { + provide: getRepositoryToken(Rubric), + useValue: { + findAndCount: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + { + provide: getRepositoryToken(RubricCriterion), + useValue: {}, + }, + { + provide: getRepositoryToken(RubricLevel), + useValue: {}, + }, + { + provide: DataSource, + useValue: { + transaction: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RubricsService); + rubricRepo = module.get(getRepositoryToken(Rubric)); + dataSource = module.get(DataSource); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('create', () => { + let mockTxManager: any; + let mockTxRubricRepo: any; + let mockTxCriterionRepo: any; + let mockTxLevelRepo: any; + + beforeEach(() => { + mockTxRubricRepo = { + create: jest.fn((dto) => ({ ...dto })), + save: jest.fn(async (v) => { v.id = 'r1'; return v; }), + findOne: jest.fn(), + }; + mockTxCriterionRepo = { + create: jest.fn((dto) => ({ ...dto })), + save: jest.fn(async (v) => { v.id = 'c1'; return v; }), + }; + mockTxLevelRepo = { + create: jest.fn((dto) => ({ ...dto })), + save: jest.fn(async (v) => { v.id = 'l1'; return v; }), + }; + + mockTxManager = { + getRepository: jest.fn((entity) => { + if (entity === Rubric) return mockTxRubricRepo; + if (entity === RubricCriterion) return mockTxCriterionRepo; + if (entity === RubricLevel) return mockTxLevelRepo; + }), + }; + + dataSource.transaction.mockImplementation(async (cb: any) => cb(mockTxManager)); + }); + + it('creates a rubric with criteria and levels successfully', async () => { + const dto = { + name: 'My Rubric', + description: 'Desc', + assessmentId: 'a1', + criteria: [ + { + title: 'Crit1', + description: 'cDesc', + maxPoints: 10, + levels: [ + { label: 'L1', points: 10 }, + { label: 'L2', points: 5 }, + ], + }, + ], + }; + + const finalRubric = { id: 'r1', criteria: [] }; + mockTxRubricRepo.findOne.mockResolvedValue(finalRubric); + + const result = await service.create(dto, 'owner-1'); + + expect(dataSource.transaction).toHaveBeenCalled(); + expect(mockTxRubricRepo.save).toHaveBeenCalled(); + expect(mockTxCriterionRepo.save).toHaveBeenCalled(); + expect(mockTxLevelRepo.save).toHaveBeenCalled(); + expect(result).toEqual(finalRubric); + }); + + it('sets autoGradeEnabled to true if all criteria have a defaultLevelIndex', async () => { + const dto = { + name: 'Auto Rubric', + autoGradeEnabled: true, + criteria: [ + { + title: 'C1', + maxPoints: 10, + defaultLevelIndex: 0, + levels: [{ label: 'L1', points: 10 }], + }, + ], + }; + + const finalRubric = { id: 'r1', autoGradeEnabled: true, criteria: [] }; + mockTxRubricRepo.findOne.mockResolvedValue(finalRubric); + + const result = await service.create(dto); + expect(result.autoGradeEnabled).toBe(true); + expect(mockTxRubricRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ autoGradeEnabled: true }) + ); + }); + + it('throws BadRequestException if autoGradeEnabled=true but missing defaultLevelIndex', async () => { + const dto = { + name: 'Auto Rubric', + autoGradeEnabled: true, + criteria: [ + { + title: 'C1', + maxPoints: 10, + levels: [{ label: 'L1', points: 10 }], + }, + ], + }; + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if defaultLevelIndex is out of range', async () => { + const dto = { + name: 'Rubric', + criteria: [ + { + title: 'C1', + maxPoints: 10, + defaultLevelIndex: 5, + levels: [{ label: 'L1', points: 10 }], + }, + ], + }; + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if criterion has no levels', async () => { + const dto = { + name: 'Rubric', + criteria: [ + { + title: 'C1', + maxPoints: 10, + levels: [], + }, + ], + }; + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException if maxPoints is less than max level points', async () => { + const dto = { + name: 'Rubric', + criteria: [ + { + title: 'C1', + maxPoints: 5, + levels: [{ label: 'L1', points: 10 }], + }, + ], + }; + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('findOne', () => { + it('returns the rubric when found and sorts criteria/levels', async () => { + const rubric = { + id: 'r1', + criteria: [ + { id: 'c2', orderIndex: 2, levels: [{ id: 'l2', orderIndex: 2 }, { id: 'l1', orderIndex: 1 }] }, + { id: 'c1', orderIndex: 1, levels: [] }, + ], + } as unknown as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + + const result = await service.findOne('r1'); + expect(result.criteria?.[0].id).toBe('c1'); + expect(result.criteria?.[1].id).toBe('c2'); + expect(result.criteria?.[1].levels?.[0].id).toBe('l1'); + }); + + it('throws NotFoundException when missing', async () => { + rubricRepo.findOne.mockResolvedValue(null); + await expect(service.findOne('missing')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAll', () => { + it('paginates and scopes by owner when provided', async () => { + const rubrics = [{ id: 'r1' }] as Rubric[]; + rubricRepo.findAndCount.mockResolvedValue([rubrics, 1]); + + const result = await service.findAll('owner-1', 1, 10); + + expect(rubricRepo.findAndCount).toHaveBeenCalledWith({ + where: { ownerId: 'owner-1' }, + order: { createdAt: 'DESC' }, + skip: 0, + take: 10, + }); + expect(result.data).toBe(rubrics); + expect(result.totalPages).toBe(1); + }); + + it('does not scope by owner when omitted', async () => { + rubricRepo.findAndCount.mockResolvedValue([[], 0]); + await service.findAll(); + expect(rubricRepo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ where: {} })); + }); + }); + + describe('update', () => { + it('applies provided fields and saves', async () => { + const rubric = { id: 'r1', name: 'old', autoGradeEnabled: false } as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + rubricRepo.save.mockImplementation(async (v) => v as Rubric); + + await service.update('r1', { name: 'new' }); + expect(rubricRepo.save).toHaveBeenCalledWith(expect.objectContaining({ name: 'new' })); + }); + + it('throws ForbiddenException when requester is not the owner', async () => { + const rubric = { id: 'r1', ownerId: 'owner-1' } as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + await expect(service.update('r1', { name: 'new' }, 'someone-else')).rejects.toThrow(ForbiddenException); + }); + + it('throws BadRequestException when enabling autoGrade but missing default level on criteria', async () => { + const rubric = { + id: 'r1', + ownerId: 'owner-1', + criteria: [{ id: 'c1', defaultLevelId: null }], + } as unknown as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + + await expect(service.update('r1', { autoGradeEnabled: true }, 'owner-1')).rejects.toThrow(BadRequestException); + }); + + it('enables autoGrade if all criteria have default level', async () => { + const rubric = { + id: 'r1', + ownerId: 'owner-1', + criteria: [{ id: 'c1', defaultLevelId: 'l1' }], + } as unknown as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + + await service.update('r1', { autoGradeEnabled: true }, 'owner-1'); + expect(rubricRepo.save).toHaveBeenCalledWith(expect.objectContaining({ autoGradeEnabled: true })); + }); + }); + + describe('remove', () => { + it('soft-deletes the rubric', async () => { + const rubric = { id: 'r1', ownerId: 'owner-1' } as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + + await service.remove('r1', 'owner-1'); + expect(rubricRepo.softDelete).toHaveBeenCalledWith('r1'); + }); + + it('throws ForbiddenException when requester is not the owner', async () => { + const rubric = { id: 'r1', ownerId: 'owner-1' } as Rubric; + rubricRepo.findOne.mockResolvedValue(rubric); + await expect(service.remove('r1', 'someone-else')).rejects.toThrow(ForbiddenException); + }); + }); +}); From 55be4da7672ca2bb289f45c31aee428195d20cac Mon Sep 17 00:00:00 2001 From: ugoocreates-pixel Date: Sun, 30 Aug 2026 15:31:31 +0100 Subject: [PATCH 2/2] chore: fix prettier linting errors in rubrics service spec --- .../grading/rubrics.service.spec.ts | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/assessment/grading/rubrics.service.spec.ts b/src/assessment/grading/rubrics.service.spec.ts index 29462523..b27f103d 100644 --- a/src/assessment/grading/rubrics.service.spec.ts +++ b/src/assessment/grading/rubrics.service.spec.ts @@ -58,16 +58,25 @@ describe('RubricsService', () => { beforeEach(() => { mockTxRubricRepo = { create: jest.fn((dto) => ({ ...dto })), - save: jest.fn(async (v) => { v.id = 'r1'; return v; }), + save: jest.fn(async (v) => { + v.id = 'r1'; + return v; + }), findOne: jest.fn(), }; mockTxCriterionRepo = { create: jest.fn((dto) => ({ ...dto })), - save: jest.fn(async (v) => { v.id = 'c1'; return v; }), + save: jest.fn(async (v) => { + v.id = 'c1'; + return v; + }), }; mockTxLevelRepo = { create: jest.fn((dto) => ({ ...dto })), - save: jest.fn(async (v) => { v.id = 'l1'; return v; }), + save: jest.fn(async (v) => { + v.id = 'l1'; + return v; + }), }; mockTxManager = { @@ -131,7 +140,7 @@ describe('RubricsService', () => { const result = await service.create(dto); expect(result.autoGradeEnabled).toBe(true); expect(mockTxRubricRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ autoGradeEnabled: true }) + expect.objectContaining({ autoGradeEnabled: true }), ); }); @@ -203,7 +212,14 @@ describe('RubricsService', () => { const rubric = { id: 'r1', criteria: [ - { id: 'c2', orderIndex: 2, levels: [{ id: 'l2', orderIndex: 2 }, { id: 'l1', orderIndex: 1 }] }, + { + id: 'c2', + orderIndex: 2, + levels: [ + { id: 'l2', orderIndex: 2 }, + { id: 'l1', orderIndex: 1 }, + ], + }, { id: 'c1', orderIndex: 1, levels: [] }, ], } as unknown as Rubric; @@ -258,7 +274,9 @@ describe('RubricsService', () => { it('throws ForbiddenException when requester is not the owner', async () => { const rubric = { id: 'r1', ownerId: 'owner-1' } as Rubric; rubricRepo.findOne.mockResolvedValue(rubric); - await expect(service.update('r1', { name: 'new' }, 'someone-else')).rejects.toThrow(ForbiddenException); + await expect(service.update('r1', { name: 'new' }, 'someone-else')).rejects.toThrow( + ForbiddenException, + ); }); it('throws BadRequestException when enabling autoGrade but missing default level on criteria', async () => { @@ -269,7 +287,9 @@ describe('RubricsService', () => { } as unknown as Rubric; rubricRepo.findOne.mockResolvedValue(rubric); - await expect(service.update('r1', { autoGradeEnabled: true }, 'owner-1')).rejects.toThrow(BadRequestException); + await expect(service.update('r1', { autoGradeEnabled: true }, 'owner-1')).rejects.toThrow( + BadRequestException, + ); }); it('enables autoGrade if all criteria have default level', async () => { @@ -281,7 +301,9 @@ describe('RubricsService', () => { rubricRepo.findOne.mockResolvedValue(rubric); await service.update('r1', { autoGradeEnabled: true }, 'owner-1'); - expect(rubricRepo.save).toHaveBeenCalledWith(expect.objectContaining({ autoGradeEnabled: true })); + expect(rubricRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ autoGradeEnabled: true }), + ); }); });