test: harden business boundary conditions
This commit is contained in:
69
apps/server/src/classes/classes.boundaries.spec.ts
Normal file
69
apps/server/src/classes/classes.boundaries.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user