465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
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<unknown>)('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<string>();
|
|
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();
|
|
});
|
|
});
|
|
|