Files
gongxue-base/apps/server/src/attendance/attendance.boundaries.spec.ts
wangziqi 2c0be0f033 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
2026-07-20 15:30:41 +08:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:0015:00) comes first,
// and A (14:0017: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,
});
});
});