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

@@ -60,3 +60,30 @@ it('removes the retired departmentId field from create requests', async () => {
expect(dto).not.toHaveProperty('departmentId');
});
describe('schedule boundary validation', () => {
it.each(['24:00', '09:60', '99:99', '9:00'])('rejects invalid time %s', async (time) => {
const dto = createSchedule('');
dto.startTime = time;
expect((await validate(dto)).some((error) => error.property === 'startTime')).toBe(true);
});
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only value %s',
async (date) => {
const dto = createSchedule('');
dto.startDate = date;
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
},
);
it('accepts inclusive attendance-window limits and a leap-day date', async () => {
const zero = createSchedule('');
zero.attendanceAdvanceMinutes = 0;
zero.startDate = '2028-02-29';
const fullDay = createSchedule('');
fullDay.attendanceAdvanceMinutes = 1440;
expect(await validate(zero)).toEqual([]);
expect(await validate(fullDay)).toEqual([]);
});
});

View File

@@ -3,7 +3,8 @@ import {
IsString,
IsNotEmpty,
IsInt,
IsDateString,
IsISO8601,
IsMilitaryTime,
Matches,
Min,
Max,
@@ -26,11 +27,11 @@ export class CreateScheduleDto {
@IsNotEmpty()
weekDay: number;
@Matches(/^\d{2}:\d{2}$/)
@IsMilitaryTime()
@IsNotEmpty()
startTime: string;
@Matches(/^\d{2}:\d{2}$/)
@IsMilitaryTime()
@IsNotEmpty()
endTime: string;
@@ -40,11 +41,13 @@ export class CreateScheduleDto {
@Max(1440)
attendanceAdvanceMinutes?: number;
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsNotEmpty()
startDate: string;
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsNotEmpty()
endDate: string;
@@ -87,11 +90,11 @@ export class UpdateScheduleDto {
weekDay?: number;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
@IsMilitaryTime()
startTime?: string;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
@IsMilitaryTime()
endTime?: string;
@IsOptional()
@@ -101,11 +104,13 @@ export class UpdateScheduleDto {
attendanceAdvanceMinutes?: number;
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string;
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string;
@IsOptional()
@@ -149,21 +154,25 @@ export class QueryScheduleDto {
weekDay?: number;
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string;
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string;
}
export class WeeklyViewQueryDto {
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string;
@IsOptional()
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string;
@IsOptional()

View File

@@ -339,3 +339,41 @@ describe('SchedulesService — remove', () => {
expect(scheduleRepo.remove).not.toHaveBeenCalled();
});
});
describe('SchedulesService — range boundaries', () => {
const makeService = () => {
const scheduleRepo = { create: jest.fn() };
return {
service: new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
),
scheduleRepo,
};
};
const valid = {
classId: 1, classroomId: 2, weekDay: 1,
startTime: '09:00', endTime: '10:00',
startDate: '2026-07-01', endDate: '2026-07-31', subject: '数学',
};
it('rejects zero-duration schedules before repository access', async () => {
const { service, scheduleRepo } = makeService();
await expect(service.create({ ...valid, endTime: '09:00' })).rejects.toThrow(
'上课时间和下课时间不能相同',
);
expect(scheduleRepo.create).not.toHaveBeenCalled();
});
it('rejects reversed date ranges before repository access', async () => {
const { service, scheduleRepo } = makeService();
await expect(
service.create({ ...valid, startDate: '2026-08-01', endDate: '2026-07-31' }),
).rejects.toThrow('排课结束日期不能早于开始日期');
expect(scheduleRepo.create).not.toHaveBeenCalled();
});
});

View File

@@ -179,7 +179,22 @@ export class SchedulesService {
}
}
private assertValidScheduleRange(
startTime: string,
endTime: string,
startDate: string,
endDate: string,
) {
if (startTime === endTime) {
throw new BadRequestException('上课时间和下课时间不能相同');
}
if (startDate > endDate) {
throw new BadRequestException('排课结束日期不能早于开始日期');
}
}
async create(dto: CreateScheduleDto) {
this.assertValidScheduleRange(dto.startTime, dto.endTime, dto.startDate, dto.endDate);
await this.assertClassroomAvailable(dto.classroomId);
await this.normalizeTeacherForSchedule(dto);
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
@@ -211,6 +226,7 @@ export class SchedulesService {
const endTime = dto.endTime ?? existing.endTime;
const startDate = dto.startDate ?? existing.startDate;
const endDate = dto.endDate ?? existing.endDate;
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
const normalized = await this.normalizeTeacherForSchedule({
...dto,