import { AttendanceSettlementService } from './attendance-settlement.service'; const schedule = { id: 2, classId: 8, teacherId: 21, weekDay: 1, startTime: '09:00', endTime: '10:00', startDate: '2026-07-01', endDate: '2026-07-31', scheduleType: 'INTERNAL', status: 'active', }; const createService = () => { const scheduleRepo = { find: jest.fn() }; const sessionRepo = { find: jest.fn(), update: jest.fn().mockResolvedValue({ affected: 1 }), }; const attendanceService = { getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => { if (targetSchedule.endTime > targetSchedule.startTime) { return { startDate: lessonDate, endDate: lessonDate }; } const next = new Date(`${lessonDate}T00:00:00.000Z`); next.setUTCDate(next.getUTCDate() + 1); return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) }; }), getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']), createLessonAttendanceFromDingTalk: jest.fn().mockImplementation( async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({ session: { id: 90, lessonDate, startedBy: userId, status: finalize ? 'completed' : 'in_progress' }, }), ), }; const importService = { importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), }; const service = new AttendanceSettlementService( scheduleRepo as never, sessionRepo as never, attendanceService as never, importService as never, ); return { service, scheduleRepo, sessionRepo, attendanceService, importService }; }; describe('AttendanceSettlementService', () => { it('pulls and finalizes an ended lesson once', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); expect(importService.importFromDingTalk).toHaveBeenCalledWith({ startDate: '2026-07-13', endDate: '2026-07-13', userIds: ['ding-1'], autoMatch: true, userId: 21, }); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 1, 2, '2026-07-13', 21, false, ); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 2, '2026-07-13', 21, true, ); expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ startDate: '2026-07-13', endDate: '2026-07-13', userIds: ['ding-1'], autoMatch: true, }); }); it('finalizes the lesson even when the leave sync fails', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith( 2, '2026-07-13', 21, true, ); }); it('does not settle a lesson before its end time', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); it('does not settle an in-progress lesson before its end time', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([ { id: 90, scheduleId: 2, lessonDate: '2026-07-13', status: 'in_progress', schedule, }, ]); await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00')); expect(importService.importFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); it('settles an in-progress lesson when its end time is reached', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([ { id: 90, scheduleId: 2, lessonDate: '2026-07-13', status: 'in_progress', schedule, }, ]); await service.settleEndedLessons(new Date('2026-07-13T10:00:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledTimes(1); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( 2, '2026-07-13', 21, true, ); }); it('continues with the next lesson when one settlement fails', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]); sessionRepo.find.mockResolvedValue([]); importService.importFromDingTalk .mockRejectedValueOnce(new Error('DingTalk unavailable')) .mockResolvedValueOnce({ success: true, errors: [] }); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 3, '2026-07-13', 21, false, ); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith( 3, '2026-07-13', 21, true, ); }); it('does not finalize when an import reports partial errors', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); importService.importFromDingTalk.mockResolvedValue({ success: true, errors: ['one batch failed'], }); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalledWith( 2, '2026-07-13', 21, true, ); }); it('retries an uncompleted daytime lesson on a later day', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([]); sessionRepo.find.mockResolvedValue([ { scheduleId: 2, lessonDate: '2026-07-13', status: 'in_progress', schedule, }, ]); await service.settleEndedLessons(new Date('2026-07-15T10:01:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( 2, '2026-07-13', 21, true, ); }); it('skips final pull when another worker already claimed the session', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([]); sessionRepo.find.mockResolvedValue([ { id: 90, scheduleId: 2, lessonDate: '2026-07-13', status: 'in_progress', schedule, }, ]); sessionRepo.update.mockResolvedValue({ affected: 0 }); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); expect(importService.importFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); it('does not settle an in-progress overnight lesson before its next-day end time', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); const overnightSchedule = { ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00', }; scheduleRepo.find.mockResolvedValue([]); sessionRepo.find.mockResolvedValue([ { id: 91, scheduleId: 4, lessonDate: '2026-07-12', status: 'in_progress', schedule: overnightSchedule, }, ]); await service.settleEndedLessons(new Date('2026-07-13T00:30:00+08:00')); expect(importService.importFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); it('settles an overnight lesson after its next-day end time', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([ { ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' }, ]); sessionRepo.find.mockResolvedValue([]); await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( 4, '2026-07-12', 21, true, ); }); it('pulls an overnight lesson through its next calendar date', async () => { const { service, scheduleRepo, sessionRepo, importService } = createService(); scheduleRepo.find.mockResolvedValue([ { ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' }, ]); sessionRepo.find.mockResolvedValue([]); await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00')); expect(importService.importFromDingTalk).toHaveBeenCalledWith( expect.objectContaining({ startDate: '2026-07-12', endDate: '2026-07-13' }), ); }); }); describe('attendance settlement timezone', () => { it('uses Asia/Shanghai course time when the server runs in UTC', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); await service.settleEndedLessons(new Date('2026-07-13T02:01:00.000Z')); expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( 2, '2026-07-13', 21, true, ); }); });