import { BadRequestException, ForbiddenException, NotFoundException, 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, ); } function updateQb(affected = 1) { const qb = { update: jest.fn(), set: jest.fn(), where: jest.fn(), execute: jest.fn().mockResolvedValue({ affected }), }; qb.update.mockReturnValue(qb); qb.set.mockReturnValue(qb); qb.where.mockReturnValue(qb); return qb; } 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 empty and invalid ids for batch archive and restore', async () => { const service = createService(async () => undefined, { examRepo: { find: jest.fn() } }); for (const call of [ (ids: number[]) => service.batchArchive(ids, 1, true), (ids: number[]) => service.batchRestore(ids, 1, true), ]) { await expect(call([])).rejects.toBeInstanceOf(BadRequestException); await expect(call([0])).rejects.toBeInstanceOf(BadRequestException); await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException); } }); it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => { const qb = updateQb(); const examRepo = { find: jest.fn().mockResolvedValue([ { id: 8, classId: 3, status: 'active' }, { id: 9, classId: 4, status: 'archived' }, ]), createQueryBuilder: jest.fn(() => qb), }; const scoreRepo = { update: jest.fn(), save: jest.fn() }; const service = createService(async () => undefined, { examRepo, scoreRepo }); await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({ message: '已批量归档 1 场考试', archived: 1, skipped: 1, }); expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } }); expect(qb.set).toHaveBeenCalledWith({ status: 'archived' }); expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] }); expect(scoreRepo.update).not.toHaveBeenCalled(); expect(scoreRepo.save).not.toHaveBeenCalled(); }); it('restores archived exams and skips active exams', async () => { const qb = updateQb(); const examRepo = { find: jest.fn().mockResolvedValue([ { id: 8, classId: 3, status: 'archived' }, { id: 9, classId: 4, status: 'active' }, ]), createQueryBuilder: jest.fn(() => qb), }; const service = createService(async () => undefined, { examRepo }); await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({ message: '已批量恢复 1 场考试', restored: 1, skipped: 1, }); expect(qb.set).toHaveBeenCalledWith({ status: 'active' }); expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] }); }); it('rejects a batch when any exam is missing before updating', async () => { const examRepo = { find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]), createQueryBuilder: jest.fn(), }; const service = createService(async () => undefined, { examRepo }); await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException); expect(examRepo.createQueryBuilder).not.toHaveBeenCalled(); }); it('checks access for every selected exam before a batch update', async () => { const examRepo = { find: jest.fn().mockResolvedValue([ { id: 8, classId: 3, status: 'active' }, { id: 9, classId: 4, status: 'active' }, ]), createQueryBuilder: jest.fn(), }; const classTeacherRepo = { findOne: jest .fn() .mockResolvedValueOnce({ id: 1 }) .mockResolvedValueOnce(null), }; const service = createService(async () => undefined, { examRepo, classTeacherRepo }); await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf( ForbiddenException, ); expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, { where: { userId: 21, classId: 3 }, }); expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, { where: { userId: 21, classId: 4 }, }); expect(examRepo.createQueryBuilder).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 }); }); });