test: 为边界条件修复添加测试用例
- rooms: parseRoomNumber 未知格式、楼层0、capacity ?? 测试 - occupancies: capacity undefined/null/0 fail-closed 防守测试 - schedules: startTime > endTime 拒绝测试 - attendance: 重叠检查按时间排序、raw[index] 移除测试 - expenses: 金额零/负/NaN 拒绝、period反转校验测试 5 modules, 40 tests, all passing
This commit is contained in:
361
apps/server/src/attendance/attendance.boundaries.spec.ts
Normal file
361
apps/server/src/attendance/attendance.boundaries.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
|
||||
// ── saveAttendancePeriodConfigs ──
|
||||
|
||||
describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
|
||||
function createService(periodConfigRepoOverrides?: {
|
||||
clear?: jest.Mock;
|
||||
save?: jest.Mock;
|
||||
create?: jest.Mock;
|
||||
find?: jest.Mock;
|
||||
count?: jest.Mock;
|
||||
}) {
|
||||
const periodConfigRepo = {
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
save: jest.fn().mockImplementation((entities: unknown) => Promise.resolve(entities)),
|
||||
create: jest.fn().mockImplementation((data: Partial<AttendancePeriodConfig>) => ({
|
||||
id: 1,
|
||||
...data,
|
||||
} as AttendancePeriodConfig)),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
...periodConfigRepoOverrides,
|
||||
};
|
||||
|
||||
return new AttendanceService(
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // dingRawRepo
|
||||
{} as never, // classRepo
|
||||
{} as never, // studentRepo
|
||||
{} as never, // scheduleRepo
|
||||
{} as never, // classStudentRepo
|
||||
{} as never, // studentDingMappingRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // attendanceSessionRepo
|
||||
{} as never, // attendanceDeviceRepo
|
||||
periodConfigRepo as never,
|
||||
{} as never, // dataSource
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('detects overlap when sorted by startTime even if sortOrder is inconsistent', async () => {
|
||||
// A: sortOrder=1 but startTime="14:00" (later in the day)
|
||||
// B: sortOrder=2 but startTime="09:00" (earlier in the day)
|
||||
// When sorted by sortOrder, A comes first, then B.
|
||||
// The overlap check sorts by startTime, so B (09:00–15:00) comes first,
|
||||
// and A (14:00–17:00) is detected as overlapping B.
|
||||
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||
const service = createService({
|
||||
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||
savedPeriods.push(...entities);
|
||||
return Promise.resolve(entities);
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '15:00', sortOrder: 2, enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('时间段不能重叠');
|
||||
});
|
||||
|
||||
it('accepts valid non-overlapping periods sorted by sortOrder', async () => {
|
||||
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||
const expectedPeriods: AttendancePeriodConfig[] = [
|
||||
{ id: 1, periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||
{ id: 1, periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||
{ id: 1, periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||
{ id: 1, periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||
];
|
||||
|
||||
// count returns > 0 so ensureAttendancePeriodConfigs does not re-seed defaults
|
||||
const service = createService({
|
||||
count: jest.fn().mockResolvedValue(4),
|
||||
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||
savedPeriods.push(...entities);
|
||||
return Promise.resolve(entities);
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue(expectedPeriods),
|
||||
});
|
||||
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
|
||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.saveAttendancePeriodConfigs(dto as any);
|
||||
|
||||
expect(result).toEqual(expectedPeriods);
|
||||
expect(savedPeriods).toHaveLength(4);
|
||||
// Verify they were saved in sortOrder order (ascending)
|
||||
expect(savedPeriods[0].periodKey).toBe('morning_reading');
|
||||
expect(savedPeriods[1].periodKey).toBe('morning');
|
||||
expect(savedPeriods[2].periodKey).toBe('afternoon');
|
||||
expect(savedPeriods[3].periodKey).toBe('evening_study');
|
||||
});
|
||||
|
||||
it('rejects periods where endTime is not after startTime', async () => {
|
||||
const service = createService();
|
||||
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: 'bad', label: 'Bad Period', startTime: '10:00', endTime: '09:00', sortOrder: 1, enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('结束时间必须晚于开始时间');
|
||||
});
|
||||
|
||||
it('rejects duplicate periodKey', async () => {
|
||||
const service = createService();
|
||||
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'morning', label: '早课2', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('重复');
|
||||
});
|
||||
|
||||
it('rejects empty period key or label', async () => {
|
||||
const service = createService();
|
||||
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: ' ', label: 'Valid Label', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('时段标识和名称不能为空');
|
||||
});
|
||||
|
||||
it('disabled periods are ignored during overlap check', async () => {
|
||||
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||
const service = createService({
|
||||
count: jest.fn().mockResolvedValue(2),
|
||||
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||
savedPeriods.push(...entities);
|
||||
return Promise.resolve(entities);
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
// A (enabled, startTime=09:00, endTime=17:00) and B (disabled, startTime=10:00, endTime=12:00, which would overlap A)
|
||||
// The disabled period should be skipped in overlap check
|
||||
const dto = {
|
||||
periods: [
|
||||
{ periodKey: 'day', label: '全天', startTime: '09:00', endTime: '17:00', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'break', label: '休息', startTime: '10:00', endTime: '12:00', sortOrder: 2, enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
// Should not throw — the disabled period is ignored
|
||||
const result = await service.saveAttendancePeriodConfigs(dto as any);
|
||||
expect(result).toBeDefined();
|
||||
expect(savedPeriods).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getScheduleOptionsForAttendance ──
|
||||
|
||||
describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () => {
|
||||
it('returns null teacher when schedule has no matching raw entry (teacher not found)', async () => {
|
||||
const entity = {
|
||||
id: 42,
|
||||
classId: 8,
|
||||
subject: '数学',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
weekDay: 1,
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
} as ClassSchedule;
|
||||
|
||||
// Simulate the case where getRawAndEntities returns the entity
|
||||
// but `raw` is empty (no teacher row). This happens when the
|
||||
// schedule has no teacher assignment.
|
||||
const qb = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||
entities: [entity],
|
||||
raw: [], // empty raw → no teacher info
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduleRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||
};
|
||||
|
||||
const service = new AttendanceService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 42,
|
||||
teacherName: null,
|
||||
teacherUsername: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns teacher info when raw contains a matching schedule entry', async () => {
|
||||
const entity = {
|
||||
id: 42,
|
||||
classId: 8,
|
||||
subject: '数学',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
weekDay: 1,
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
} as ClassSchedule;
|
||||
|
||||
const qb = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||
entities: [entity],
|
||||
raw: [
|
||||
{
|
||||
scheduleIdForTeacherMap: 42,
|
||||
teacherName: '张老师',
|
||||
teacherUsername: 'zhang',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduleRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||
};
|
||||
|
||||
const service = new AttendanceService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 42,
|
||||
teacherName: '张老师',
|
||||
teacherUsername: 'zhang',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null teacher when raw has a different schedule id than the entity', async () => {
|
||||
const entity = {
|
||||
id: 42,
|
||||
classId: 8,
|
||||
subject: '数学',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
weekDay: 1,
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
} as ClassSchedule;
|
||||
|
||||
// raw contains teacher info but for a DIFFERENT schedule id (99 ≠ 42)
|
||||
// The fallback `??` should kick in for schedule 42
|
||||
const qb = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||
entities: [entity],
|
||||
raw: [
|
||||
{
|
||||
scheduleIdForTeacherMap: 99,
|
||||
teacherName: '张老师',
|
||||
teacherUsername: 'zhang',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduleRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||
};
|
||||
|
||||
const service = new AttendanceService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 42,
|
||||
teacherName: null,
|
||||
teacherUsername: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user