feat: 支持考试批量归档与恢复
All checks were successful
CI / check (pull_request) Successful in 2m4s

This commit is contained in:
2026-07-25 09:45:58 +08:00
parent 0ef86e65ce
commit 095eccea76
8 changed files with 393 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
import { ExamScore } from '../entities';
import { QueryExamDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';
@@ -23,6 +23,19 @@ function createService(
);
}
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 = [
@@ -224,6 +237,102 @@ describe('ExamsService', () => {
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' }),