import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; import { ExamScore } from '../entities'; import { QueryExamDto } from './dto/exam.dto'; import { ExamsService } from './exams.service'; function createService( transaction: (run: (manager: any) => Promise) => Promise, repositories: { examRepo?: Record; scoreRepo?: Record; classTeacherRepo?: Record; } = {}, ) { return new ExamsService( (repositories.examRepo ?? {}) as never, (repositories.scoreRepo ?? {}) as never, {} as never, {} as never, (repositories.classTeacherRepo ?? { findOne: jest.fn().mockResolvedValue({ id: 1 }), }) as never, { transaction } as never, ); } describe('ExamsService', () => { it('creates score rows from the active class roster snapshot', async () => { const members = [ { studentId: 11, status: 'active' }, { studentId: 12, status: 'active' }, ]; const manager = { findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), find: jest.fn().mockResolvedValue(members), create: jest.fn((_entity, value) => value), save: jest.fn(async (entity, value) => entity.name === 'Exam' ? { ...value, id: 9 } : value, ), }; const service = createService(async (run) => run(manager)); await service.create( { examType: '月考', examName: '七月月考', subject: '数学', examDate: '2026-07-21', classId: 3, }, 1, true, ); expect(manager.find).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ where: { classId: 3, status: 'active' } }), ); expect(manager.save).toHaveBeenCalledWith( ExamScore, expect.arrayContaining([ expect.objectContaining({ examId: 9, studentId: 11, score: null }), expect.objectContaining({ examId: 9, studentId: 12, score: null }), ]), ); }); it('rejects creating an exam for an empty class', async () => { const manager = { findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), find: jest.fn().mockResolvedValue([]), }; const service = createService(async (run) => run(manager)); await expect( service.create( { examType: '月考', examName: '七月月考', subject: '数学', examDate: '2026-07-21', classId: 3, }, 1, true, ), ).rejects.toBeInstanceOf(BadRequestException); }); it('counts zero, ignores empty scores, and uses competition ranking', async () => { const rows = [ { id: 1, score: 90, classAvg: null, rank: null }, { id: 2, score: 90, classAvg: null, rank: null }, { id: 3, score: 60, classAvg: null, rank: null }, { id: 4, score: 0, classAvg: null, rank: null }, { id: 5, score: null, classAvg: null, rank: null }, ]; const manager = { findOne: jest .fn() .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) .mockResolvedValueOnce(rows[0]) .mockResolvedValueOnce(rows[0]), find: jest.fn().mockResolvedValue(rows), save: jest.fn(async (_entity, value) => value), }; const service = createService(async (run) => run(manager)); await service.updateScore(8, 1, 90, 1, true); expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]); expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]); }); it('clears a score and recalculates the remaining rows', async () => { const rows = [ { id: 1, score: 90, classAvg: 80, rank: 1 }, { id: 2, score: 70, classAvg: 80, rank: 2 }, ]; const manager = { findOne: jest .fn() .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) .mockResolvedValueOnce(rows[0]) .mockResolvedValueOnce(rows[0]), find: jest.fn().mockResolvedValue(rows), save: jest.fn(async (_entity, value) => value), }; const service = createService(async (run) => run(manager)); await service.updateScore(8, 1, null, 1, true); expect(rows).toEqual([ expect.objectContaining({ score: null, classAvg: 70, rank: null }), expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }), ]); }); it('lists active exams by default and archived exams on request', async () => { const examRepo = { find: jest.fn().mockResolvedValue([]), }; const service = createService(async () => undefined, { examRepo }); await service.findAll({}); await service.findAll({ isArchived: true }); expect(examRepo.find).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: expect.objectContaining({ status: 'active' }) }), ); expect(examRepo.find).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: expect.objectContaining({ status: 'archived' }) }), ); }); it('preserves the accessible class filter for archived exams', async () => { const examRepo = { find: jest.fn().mockResolvedValue([]), }; const service = createService(async () => undefined, { examRepo }); await service.findAll({ isArchived: true }, [3, 5]); expect(examRepo.find).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: 'archived', classId: expect.anything() }), }), ); }); it('archives an exam without changing score rows', async () => { const examRepo = { findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; const scoreRepo = { update: jest.fn(), save: jest.fn() }; const service = createService(async () => undefined, { examRepo, scoreRepo }); await expect(service.archive(8, 1, true)).resolves.toEqual({ success: true }); expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'archived' }); expect(scoreRepo.update).not.toHaveBeenCalled(); expect(scoreRepo.save).not.toHaveBeenCalled(); }); it('rejects archiving an archived exam', async () => { const examRepo = { findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }), update: jest.fn(), }; const service = createService(async () => undefined, { examRepo }); await expect(service.archive(8, 1, true)).rejects.toBeInstanceOf(BadRequestException); expect(examRepo.update).not.toHaveBeenCalled(); }); it('restores an archived exam and rejects restoring an active exam', async () => { const examRepo = { findOne: jest .fn() .mockResolvedValueOnce({ id: 8, classId: 3, status: 'archived' }) .mockResolvedValueOnce({ id: 9, classId: 3, status: 'active' }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; const service = createService(async () => undefined, { examRepo }); await expect(service.restore(8, 1, true)).resolves.toEqual({ success: true }); await expect(service.restore(9, 1, true)).rejects.toBeInstanceOf(BadRequestException); expect(examRepo.update).toHaveBeenCalledTimes(1); expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'active' }); }); it('requires class access before archiving an exam', async () => { const examRepo = { findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }), update: jest.fn(), }; const classTeacherRepo = { findOne: jest.fn().mockResolvedValue(null) }; const service = createService(async () => undefined, { examRepo, classTeacherRepo }); await expect(service.archive(8, 21, false)).rejects.toBeInstanceOf(ForbiddenException); expect(classTeacherRepo.findOne).toHaveBeenCalledWith({ where: { userId: 21, classId: 3 } }); expect(examRepo.update).not.toHaveBeenCalled(); }); it('rejects score updates for archived exams', async () => { const manager = { findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }), }; const service = createService(async (run) => run(manager)); await expect(service.updateScore(8, 1, 90, 1, true)).rejects.toBeInstanceOf( BadRequestException, ); }); }); describe('QueryExamDto - query transformation', () => { const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }); it.each([ ['false', false], ['0', false], ['true', true], ['1', true], ])('transforms isArchived=%s to %s', async (input, expected) => { await expect( pipe.transform( { isArchived: input }, { type: 'query', metatype: QueryExamDto, data: undefined }, ), ).resolves.toEqual({ isArchived: expected }); }); });