test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -0,0 +1,69 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClassesService } from './classes.service';
function createService(classRepo: Record<string, jest.Mock>, classTeacherRepo = {}) {
return new ClassesService(
classRepo as never,
{} as never,
classTeacherRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}
describe('ClassesService — archive and teacher boundaries', () => {
it('rejects repeated archive and restore operations', async () => {
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: true }) }).archive(1),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: false }) }).restore(1),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects assigning a teacher to a missing class', async () => {
const classTeacherRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() };
await expect(
createService({ findOne: jest.fn().mockResolvedValue(null) }, classTeacherRepo).addTeacher(
9,
{
userId: 2,
roleType: 'head_teacher',
},
),
).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.save).not.toHaveBeenCalled();
});
it('rejects duplicate teacher roles', async () => {
const classTeacherRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3 }),
create: jest.fn(),
save: jest.fn(),
};
await expect(
createService(
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) },
classTeacherRepo,
).addTeacher(1, {
userId: 2,
roleType: 'head_teacher',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects removing a teacher or assignment that is not in the class', async () => {
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
delete: jest.fn(),
};
const service = createService({}, classTeacherRepo);
await expect(service.removeTeacher(1, 2)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.removeTeacherAssignment(1, 3)).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.delete).not.toHaveBeenCalled();
});
});

View File

@@ -41,7 +41,10 @@ describe('ClassesService — teacher data scope', () => {
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
const classRepo = { update: jest.fn() };
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]),
find: jest
.fn()
.mockResolvedValueOnce([{ id: 1, classId: 8, userId: 21 }])
.mockResolvedValueOnce([]),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const service = new ClassesService(

View File

@@ -286,6 +286,7 @@ export class ClassesService {
async archive(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (cls.isArchived) throw new BadRequestException('班级已归档');
await this.classRepo.update(id, { isArchived: true });
return { success: true };
}
@@ -294,6 +295,7 @@ export class ClassesService {
async restore(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('班级未归档');
await this.classRepo.update(id, { isArchived: false });
return { success: true };
}
@@ -392,6 +394,9 @@ export class ClassesService {
}
async addTeacher(classId: number, dto: AddTeacherDto) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
const existing = await this.classTeacherRepo.findOne({
where: { classId, userId: dto.userId, roleType: dto.roleType },
});
@@ -410,12 +415,18 @@ export class ClassesService {
}
async removeTeacher(classId: number, userId: number) {
const assignments = await this.classTeacherRepo.find({ where: { classId, userId } });
if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级');
await this.classTeacherRepo.delete({ classId, userId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
async removeTeacherAssignment(classId: number, assignmentId: number) {
const assignment = await this.classTeacherRepo.findOne({
where: { id: assignmentId, classId },
});
if (!assignment) throw new NotFoundException('教师角色分配不存在');
await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId);
return { success: true };

View File

@@ -1,43 +1,81 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator';
import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsArray,
IsDateString,
IsEnum,
ArrayNotEmpty,
ValidateNested,
Min,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
export class ClassTeacherItemDto {
@IsInt()
userId: number;
@IsEnum(TeacherRoleType)
roleType: string;
@IsOptional()
@IsString()
subject?: string;
}
export class CreateClassDto {
@IsString() @IsNotEmpty()
@IsString()
@IsNotEmpty()
name: string;
@IsString() @IsNotEmpty()
@IsString()
@IsNotEmpty()
code: string;
@IsEnum(ClassType) @IsString() @IsNotEmpty()
@IsEnum(ClassType)
@IsString()
@IsNotEmpty()
classType: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString()
@IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
headTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
lifeTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
academicTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
@Min(0)
maxStudents?: number;
@IsOptional() @IsString()
@IsOptional()
@IsString()
notes?: string;
@IsOptional() @IsArray()
@IsOptional()
@IsArray()
@IsInt({ each: true })
studentIds?: number[];
@IsOptional()
@@ -46,55 +84,73 @@ export class CreateClassDto {
@Type(() => ImportUserItem)
users?: ImportUserItem[];
@IsOptional() @IsArray()
teachers?: Array<{ userId: number; roleType: string; subject?: string }>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ClassTeacherItemDto)
teachers?: ClassTeacherItemDto[];
}
export class UpdateClassDto {
@IsOptional() @IsString()
@IsOptional()
@IsString()
name?: string;
@IsOptional() @IsString()
@IsOptional()
@IsString()
code?: string;
@IsEnum(ClassType) @IsOptional() @IsString()
@IsEnum(ClassType)
@IsOptional()
@IsString()
classType?: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString()
@IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
headTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
lifeTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
academicTeacherId?: number;
@IsOptional() @IsInt()
@IsOptional()
@IsInt()
@Min(0)
maxStudents?: number;
@IsOptional() @IsString()
@IsOptional()
@IsString()
notes?: string;
}
export class QueryClassDto {
@IsOptional() @IsString()
@IsOptional()
@IsString()
status?: string;
@IsOptional() @IsString()
@IsOptional()
@IsString()
classType?: string;
@IsOptional() @IsString()
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@@ -108,7 +164,8 @@ export class QueryClassDto {
}
export class AddStudentsDto {
@IsArray() @IsInt({ each: true })
@IsArray()
@IsInt({ each: true })
studentIds: number[];
}
@@ -119,23 +176,28 @@ export class AddTeacherDto {
@IsEnum(TeacherRoleType)
roleType: string;
@IsOptional() @IsString()
@IsOptional()
@IsString()
subject?: string;
}
export class QueryClassScheduleDto {
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
endDate?: string;
}
export class QueryClassAttendanceSummaryDto {
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional() @IsDateString()
@IsOptional()
@IsDateString()
endDate?: string;
}
export class BatchImportStudentsDto {
@@ -147,12 +209,15 @@ export class BatchImportStudentsDto {
}
export class ImportUserItem {
@IsString() @IsNotEmpty()
@IsString()
@IsNotEmpty()
dingUserId: string;
@IsString() @IsNotEmpty()
@IsString()
@IsNotEmpty()
name: string;
@IsOptional() @IsString()
@IsOptional()
@IsString()
mobile?: string;
}