feat: settle course attendance automatically
This commit is contained in:
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal file
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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 = {
|
||||
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: [] }),
|
||||
};
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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('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('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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user