This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
@@ -22,6 +23,16 @@ export class QueryExamDto {
|
||||
@IsOptional() @IsString() keyword?: string;
|
||||
@IsOptional() @IsString() examType?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateExamScoreValueDto {
|
||||
|
||||
@@ -71,6 +71,48 @@ export class ExamsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/archive')
|
||||
@RequirePermission('exam:view')
|
||||
async archive(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result = await this.service.archive(id, req.user.id, this.canManageAll(req));
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '归档考试',
|
||||
targetId: id,
|
||||
targetType: 'exam',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/restore')
|
||||
@RequirePermission('exam:view')
|
||||
async restore(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result = await this.service.restore(id, req.user.id, this.canManageAll(req));
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '恢复考试',
|
||||
targetId: id,
|
||||
targetType: 'exam',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':examId/scores/:scoreId')
|
||||
@RequirePermission('exam:view')
|
||||
async updateScore(
|
||||
@@ -100,5 +142,4 @@ export class ExamsController {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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<unknown>) => Promise<unknown>) {
|
||||
function createService(
|
||||
transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>,
|
||||
repositories: {
|
||||
examRepo?: Record<string, jest.Mock>;
|
||||
scoreRepo?: Record<string, jest.Mock>;
|
||||
classTeacherRepo?: Record<string, jest.Mock>;
|
||||
} = {},
|
||||
) {
|
||||
return new ExamsService(
|
||||
(repositories.examRepo ?? {}) as never,
|
||||
(repositories.scoreRepo ?? {}) as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never,
|
||||
(repositories.classTeacherRepo ?? {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||||
}) as never,
|
||||
{ transaction } as never,
|
||||
);
|
||||
}
|
||||
@@ -124,4 +134,122 @@ describe('ExamsService', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export class ExamsService {
|
||||
|
||||
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'active',
|
||||
status: query.isArchived ? 'archived' : 'active',
|
||||
};
|
||||
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
||||
if (query.examType) where.examType = query.examType;
|
||||
@@ -131,6 +131,24 @@ export class ExamsService {
|
||||
});
|
||||
}
|
||||
|
||||
async archive(id: number, userId: number, canManageAll: boolean) {
|
||||
const exam = await this.examRepo.findOne({ where: { id } });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
if (exam.status === 'archived') throw new BadRequestException('考试已归档');
|
||||
await this.examRepo.update(id, { status: 'archived' });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async restore(id: number, userId: number, canManageAll: boolean) {
|
||||
const exam = await this.examRepo.findOne({ where: { id } });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
if (exam.status !== 'archived') throw new BadRequestException('考试未归档');
|
||||
await this.examRepo.update(id, { status: 'active' });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
||||
const rows = members.map((member) =>
|
||||
manager.create(ExamScore, {
|
||||
|
||||
Reference in New Issue
Block a user