Files
gongxue-base/apps/server/src/attendance/attendance-settlement.service.spec.ts

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,
);
});
});