test: harden business boundary conditions
This commit is contained in:
102
apps/server/src/archive/archive.boundaries.spec.ts
Normal file
102
apps/server/src/archive/archive.boundaries.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ArchiveService } from './archive.service';
|
||||
|
||||
function createService(repos: Partial<Record<string, Record<string, jest.Mock>>> = {}) {
|
||||
return new ArchiveService(
|
||||
(repos.student ?? {}) as never,
|
||||
(repos.profile ?? {}) as never,
|
||||
(repos.enrollment ?? {}) as never,
|
||||
(repos.exam ?? {}) as never,
|
||||
(repos.learning ?? {}) as never,
|
||||
(repos.result ?? {}) as never,
|
||||
(repos.attachment ?? {}) as never,
|
||||
(repos.attendance ?? {}) as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ArchiveService — resource and relationship boundaries', () => {
|
||||
it('rejects adding archive records for a missing student', async () => {
|
||||
const student = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
const service = createService({ student });
|
||||
|
||||
await expect(
|
||||
service.addEnrollment(404, { courseCategory: '文化', classType: '冲刺' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
service.addLearningRecord(404, {
|
||||
recordDate: '2026-07-14',
|
||||
recordType: '沟通',
|
||||
content: '内容',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects linking an exam score to another student enrollment', async () => {
|
||||
const exam = { create: jest.fn(), save: jest.fn() };
|
||||
const service = createService({
|
||||
student: { findOne: jest.fn().mockResolvedValue({ id: 7 }) },
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addExamScore(7, {
|
||||
examType: '月考',
|
||||
subject: '语文',
|
||||
score: 90,
|
||||
enrollmentId: 99,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects moving an existing exam score to another student enrollment', async () => {
|
||||
const exam = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, enrollmentId: 1 }),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = createService({
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam,
|
||||
});
|
||||
|
||||
await expect(service.updateExamScore(3, { enrollmentId: 99 })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing attachment upload before writing to disk', async () => {
|
||||
const service = createService({ student: { findOne: jest.fn() } });
|
||||
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects attachment path traversal', async () => {
|
||||
const service = createService({
|
||||
attachment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
filePath: '../../etc/passwd',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await expect(service.getAttachmentFile(7, 1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns not found for update/delete of absent child records', async () => {
|
||||
const service = createService({
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
learning: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
attachment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteExamScore(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteLearningRecord(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteAttachment(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -61,20 +61,27 @@ export class ArchiveService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
|
||||
await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||
}),
|
||||
]);
|
||||
const [
|
||||
profileRaw,
|
||||
enrollments,
|
||||
examScores,
|
||||
learningRecords,
|
||||
resultArchive,
|
||||
attachments,
|
||||
attendances,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
student,
|
||||
@@ -123,9 +130,18 @@ export class ArchiveService {
|
||||
return { message: '已删除' };
|
||||
}
|
||||
|
||||
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
|
||||
if (enrollmentId === undefined) return;
|
||||
const enrollment = await this.enrollmentRepo.findOne({
|
||||
where: { id: enrollmentId, studentId },
|
||||
});
|
||||
if (!enrollment) throw new BadRequestException('报名记录不属于该学生');
|
||||
}
|
||||
|
||||
async addExamScore(studentId: number, dto: CreateExamScoreDto) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId);
|
||||
|
||||
const entity = this.examScoreRepo.create({ ...dto, studentId });
|
||||
return this.examScoreRepo.save(entity);
|
||||
@@ -134,6 +150,7 @@ export class ArchiveService {
|
||||
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
|
||||
Object.assign(entity, dto);
|
||||
return this.examScoreRepo.save(entity);
|
||||
}
|
||||
@@ -181,6 +198,8 @@ export class ArchiveService {
|
||||
}
|
||||
|
||||
async addAttachment(studentId: number, file: Express.Multer.File, category: string) {
|
||||
if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件');
|
||||
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
|
||||
|
||||
export class UpsertProfileDto {
|
||||
@IsOptional() @IsString() targetCollege?: string;
|
||||
@@ -11,8 +11,8 @@ export class UpsertProfileDto {
|
||||
}
|
||||
|
||||
export class CreateEnrollmentDto {
|
||||
@IsString() courseCategory: string;
|
||||
@IsString() classType: string;
|
||||
@IsString() @IsNotEmpty() courseCategory: string;
|
||||
@IsString() @IsNotEmpty() classType: string;
|
||||
@IsOptional() @IsString() className?: string;
|
||||
@IsOptional() @IsString() headTeacher?: string;
|
||||
@IsOptional() @IsString() subjectTeacher?: string;
|
||||
@@ -24,12 +24,12 @@ export class CreateEnrollmentDto {
|
||||
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
|
||||
|
||||
export class CreateExamScoreDto {
|
||||
@IsString() examType: string;
|
||||
@IsString() @IsNotEmpty() examType: string;
|
||||
@IsOptional() @IsString() examName?: string;
|
||||
@IsString() subject: string;
|
||||
@IsNumber() score: number;
|
||||
@IsOptional() @IsNumber() classAvg?: number;
|
||||
@IsOptional() @IsNumber() rank?: number;
|
||||
@IsString() @IsNotEmpty() subject: string;
|
||||
@IsNumber() @Min(0) score: number;
|
||||
@IsOptional() @IsNumber() @Min(0) classAvg?: number;
|
||||
@IsOptional() @IsNumber() @Min(1) rank?: number;
|
||||
@IsOptional() @IsDateString() examDate?: string;
|
||||
@IsOptional() @IsNumber() enrollmentId?: number;
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
|
||||
|
||||
export class CreateLearningRecordDto {
|
||||
@IsDateString() recordDate: string;
|
||||
@IsString() recordType: string;
|
||||
@IsString() content: string;
|
||||
@IsString() @IsNotEmpty() recordType: string;
|
||||
@IsString() @IsNotEmpty() content: string;
|
||||
@IsOptional() @IsString() followUpMethod?: string;
|
||||
@IsOptional() @IsString() nextStep?: string;
|
||||
}
|
||||
@@ -47,8 +47,8 @@ export class CreateLearningRecordDto {
|
||||
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
|
||||
|
||||
export class UpsertResultDto {
|
||||
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
||||
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) cultureFinalScore?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) professionalFinalScore?: number;
|
||||
@IsOptional() @IsString() admissionStatus?: string;
|
||||
@IsOptional() @IsString() admittedCollege?: string;
|
||||
@IsOptional() @IsString() admittedMajor?: string;
|
||||
|
||||
Reference in New Issue
Block a user