303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|