import { ScheduleSyncService } from './schedule-sync.service'; import { ClassSchedule } from '../entities'; describe('ScheduleSyncService — absence threshold', () => { it('updates an existing 16:00-17:00 shift so checking in before 17:00 is not absent', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 5, startTime: '16:00', endTime: '17:00', startDate: '2026-07-01', endDate: '2026-07-31', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), }; const classRepo = { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), }; const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]), upsertShift: jest.fn().mockResolvedValue(456), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn(), scheduleUsers: jest.fn().mockResolvedValue(undefined), }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); await service.syncAll('2026-07-10', 1); expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({ id: 456, name: '冲刺班_16:00-17:00', setting: expect.objectContaining({ absenteeism_late_minutes: 60 }), })); }); }); describe('ScheduleSyncService — attendance machine only', () => { it('updates a reused attendance group with machine-only restrictions', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 1, startTime: '09:00', endTime: '16:00', startDate: '2026-07-01', endDate: '2026-07-31', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), }; const classRepo = { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), }; const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_09:00-16:00' }]), upsertShift: jest.fn().mockResolvedValue(456), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn(), scheduleUsers: jest.fn().mockResolvedValue(undefined), }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); await (service.syncAll as unknown as ( dateFrom: string, days: number, opUserId: string, attendanceMachineOnly: boolean, ) => Promise)('2026-07-13', 1, 'manager', true); expect(dingTalkService.updateAttendanceGroup).toHaveBeenCalledWith(expect.objectContaining({ id: 123, name: '排课_冲刺班', owner: 'manager', shift_ids: [456], attendance_machine_only: true, })); expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled(); }); }); describe('ScheduleSyncService — partial batch failure', () => { it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 1, startTime: '09:00', endTime: '11:00', startDate: '2026-07-06', endDate: '2026-07-06', status: 'active', } as ClassSchedule, { id: 2, classId: 20, classroomId: 2, weekDay: 2, startTime: '14:00', endTime: '16:00', startDate: '2026-07-07', endDate: '2026-07-07', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([ { classId: 10, studentId: 20, status: 'active' }, { classId: 20, studentId: 30, status: 'active' }, ]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([ { studentId: 20, dingUserId: 'student-1' }, { studentId: 30, dingUserId: 'student-2' }, ]), }; const classRepo = { find: jest.fn().mockResolvedValue([ { id: 10, name: '冲刺班' }, { id: 20, name: '强化班' }, ]), }; const scheduleUsers = jest .fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)')); const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([ { id: 900, name: '排课_09:00-11:00' }, { id: 901, name: '排课_14:00-16:00' }, ]), upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn().mockResolvedValue(888), scheduleUsers, }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); const result = await service.syncAll('2026-07-06', 2); expect(scheduleUsers).toHaveBeenCalledTimes(2); expect(result.failedBatchCount).toBeGreaterThan(0); expect(result.errors).toBeDefined(); expect(result.errors.length).toBeGreaterThan(0); // syncedItems should only count the successful batch expect(result.syncedItems).toBeGreaterThan(0); }); }); describe('ScheduleSyncService — attendance group failure', () => { it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 1, startTime: '09:00', endTime: '11:00', startDate: '2026-07-06', endDate: '2026-07-06', status: 'active', } as ClassSchedule, { id: 2, classId: 20, classroomId: 2, weekDay: 2, startTime: '14:00', endTime: '16:00', startDate: '2026-07-07', endDate: '2026-07-07', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([ { classId: 10, studentId: 20, status: 'active' }, { classId: 20, studentId: 30, status: 'active' }, ]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([ { studentId: 20, dingUserId: 'student-1' }, { studentId: 30, dingUserId: 'student-2' }, ]), }; const classRepo = { find: jest.fn().mockResolvedValue([ { id: 10, name: '冲刺班' }, { id: 20, name: '强化班' }, ]), }; const createAttendanceGroup = jest .fn() .mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)')); const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([ { id: 900, name: '排课_09:00-11:00' }, { id: 901, name: '排课_14:00-16:00' }, ]), upsertShift: jest.fn().mockResolvedValue(900), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup, scheduleUsers: jest.fn().mockResolvedValue(undefined), }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); const result = await service.syncAll('2026-07-06', 2); // group failure must NOT be counted as skippedNoMapping expect(result.skippedNoMapping).toBe(0); // group failure must increment failure counters expect(result.failedBatchCount).toBeGreaterThan(0); expect(result.failedItems).toBeGreaterThan(0); // error message must contain the group failure detail expect(result.errors).toBeDefined(); expect(result.errors.some((e) => e.includes('考勤组'))).toBe(true); expect(result.errors.some((e) => e.includes('强化班'))).toBe(true); // the successful class should still sync expect(result.syncedItems).toBeGreaterThan(0); expect(result.groupCount).toBe(1); }); }); describe('ScheduleSyncService — dedup', () => { it('does not write duplicate schedule items for same user/date/shift', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 3, startTime: '10:00', endTime: '12:00', startDate: '2026-07-01', endDate: '2026-07-31', status: 'active', } as ClassSchedule, { id: 2, classId: 10, classroomId: 1, weekDay: 3, startTime: '10:00', endTime: '12:00', startDate: '2026-07-08', endDate: '2026-07-08', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), }; const classRepo = { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), }; const scheduleUsers = jest.fn().mockResolvedValue(undefined); const dingTalkService = { queryShifts: jest .fn() .mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]), upsertShift: jest.fn().mockResolvedValue(456), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn(), scheduleUsers, }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); await service.syncAll('2026-07-01', 31); const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>; // 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date. // The dedup should collapse the two identical {userid, work_date, shift_id} items into one. const key = (item: { userid: string; work_date: number; shift_id: number }) => `${item.userid}-${item.work_date}-${item.shift_id}`; const seen = new Set(); for (const item of batchItems) { const k = key(item); expect(seen.has(k)).toBe(false); seen.add(k); } // At least one item exists for 07-08 (proving overlap was handled) // work_date is epoch ms at 00:00:00+08:00; convert back to date string const fmtDate = (epochMs: number) => { const d = new Date(epochMs); return new Date(d.getTime() - d.getTimezoneOffset() * 60000) .toISOString().slice(0, 10); }; const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08'); expect(july8Items.length).toBe(1); }); }); describe('ScheduleSyncService — all shifts fail', () => { it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 1, classId: 10, classroomId: 1, weekDay: 1, startTime: '09:00', endTime: '11:00', startDate: '2026-07-06', endDate: '2026-07-06', status: 'active', } as ClassSchedule, { id: 2, classId: 10, classroomId: 1, weekDay: 2, startTime: '14:00', endTime: '16:00', startDate: '2026-07-07', endDate: '2026-07-07', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([ { classId: 10, studentId: 20, status: 'active' }, ]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), }; const classRepo = { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), }; const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([]), upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')), queryAttendanceGroups: jest.fn().mockResolvedValue([]), updateAttendanceGroup: jest.fn(), createAttendanceGroup: jest.fn(), scheduleUsers: jest.fn(), }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); const result = await service.syncAll('2026-07-06', 2); // All shifts failed → no shifts created expect(result.shiftCount).toBe(0); // No attendance groups created (no usable shifts) expect(result.groupCount).toBe(0); // Nothing synced expect(result.syncedItems).toBe(0); // Must NOT count as skippedNoMapping expect(result.skippedNoMapping).toBe(0); // Failure counters must reflect the failed shifts expect(result.failedBatchCount).toBeGreaterThan(0); expect(result.failedItems).toBeGreaterThan(0); // Errors must contain shift failure messages expect(result.errors).toBeDefined(); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.some((e) => e.includes('班次'))).toBe(true); // No scheduleUsers calls (no group created) expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled(); }); }); describe('ScheduleSyncService — multiple lessons per student per day', () => { it('combines daily lessons into one DingTalk shift with multiple sections', async () => { const scheduleRepo = { find: jest.fn().mockResolvedValue([ { id: 2, classId: 10, classroomId: 1, weekDay: 1, startTime: '22:10', endTime: '23:10', startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', } as ClassSchedule, { id: 1, classId: 10, classroomId: 2, weekDay: 1, startTime: '20:00', endTime: '21:00', startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', } as ClassSchedule, ]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), }; const classRepo = { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]), }; const scheduleUsers = jest.fn().mockResolvedValue(undefined); const upsertShift = jest.fn().mockResolvedValue(2022); const dingTalkService = { queryShifts: jest.fn().mockResolvedValue([]), upsertShift, queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn(), scheduleUsers, }; const service = new ScheduleSyncService( scheduleRepo as never, classStudentRepo as never, mappingRepo as never, classRepo as never, dingTalkService as never, ); const result = await service.syncAll('2026-07-13', 1); expect(upsertShift).toHaveBeenCalledTimes(1); expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({ name: '冲刺一班_20:00-21:00+22:10-23:10', sections: [ expect.objectContaining({ times: expect.arrayContaining([ expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }), expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }), ]), }), expect.objectContaining({ times: expect.arrayContaining([ expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }), expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }), ]), }), ], })); expect(scheduleUsers).toHaveBeenCalledTimes(1); expect(scheduleUsers.mock.calls[0][1]).toEqual([ expect.objectContaining({ userid: 'student-1', shift_id: 2022 }), ]); expect(result.syncedItems).toBe(1); expect(result.failedBatchCount).toBe(0); }); }); describe('ScheduleSyncService — date and overnight boundaries', () => { const createService = (schedule: ClassSchedule) => { const scheduleUsers = jest.fn().mockResolvedValue(undefined); const upsertShift = jest.fn().mockResolvedValue(501); const service = new ScheduleSyncService( { find: jest.fn().mockResolvedValue([schedule]) } as never, { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never, { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never, { find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never, { queryShifts: jest.fn().mockResolvedValue([]), upsertShift, queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 }, ]), updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), createAttendanceGroup: jest.fn(), scheduleUsers, } as never, ); return { service, scheduleUsers, upsertShift }; }; it('syncs exactly the requested number of calendar days', async () => { const { service, scheduleUsers } = createService({ id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00', startDate: '2026-07-13', endDate: '2026-07-20', status: 'active', } as ClassSchedule); await service.syncAll('2026-07-13', 7); expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1); }); it('marks an overnight lesson off-duty time as next-day', async () => { const { service, upsertShift } = createService({ id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00', startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', } as ClassSchedule); await service.syncAll('2026-07-13', 1); expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({ sections: [expect.objectContaining({ times: expect.arrayContaining([ expect.objectContaining({ check_type: 'OnDuty', across: 0 }), expect.objectContaining({ check_type: 'OffDuty', across: 1 }), ]) })], })); }); it('does not shift the requested date when the server timezone is behind China', async () => { const originalTz = process.env.TZ; process.env.TZ = 'America/Los_Angeles'; try { const { service, scheduleUsers } = createService({ id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00', startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', } as ClassSchedule); await service.syncAll('2026-07-13', 1); expect(scheduleUsers).toHaveBeenCalledTimes(1); } finally { process.env.TZ = originalTz; } }); });