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) => ({ 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, }); }); });