712 lines
25 KiB
TypeScript
712 lines
25 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { AttendanceService } from './attendance.service';
|
|
import { AttendanceSession } from '../entities/attendance-session.entity';
|
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
|
|
|
const createService = () => {
|
|
const attendanceRepo = {
|
|
create: jest.fn((value: Record<string, unknown>) => value),
|
|
find: jest.fn(),
|
|
findOne: jest.fn(),
|
|
save: jest.fn(async (records: unknown) => records),
|
|
remove: jest.fn(async (record: unknown) => record),
|
|
count: jest.fn(),
|
|
};
|
|
const dingRawRepo = { find: jest.fn() };
|
|
const scheduleRepo = { findOne: jest.fn() };
|
|
const classStudentRepo = { find: jest.fn() };
|
|
const sessionRepo = {
|
|
findOne: jest.fn(),
|
|
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
|
|
save: jest.fn(async (value: unknown) => value),
|
|
};
|
|
const attendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
|
|
const dataSource = {
|
|
transaction: jest.fn(
|
|
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
|
|
const managerGetRepo = jest.fn((entity: { name: string }) => {
|
|
if (entity.name === AttendanceSession.name) return sessionRepo;
|
|
if (entity.name === AttendanceRecord.name) return attendanceRepo;
|
|
return {};
|
|
});
|
|
return cb({ getRepository: managerGetRepo });
|
|
},
|
|
),
|
|
};
|
|
const service = new AttendanceService(
|
|
attendanceRepo as never,
|
|
dingRawRepo as never,
|
|
{} as never,
|
|
{} as never,
|
|
scheduleRepo as never,
|
|
classStudentRepo as never,
|
|
{} as never,
|
|
{} as never,
|
|
sessionRepo as never,
|
|
attendanceDeviceRepo as never,
|
|
{} as never,
|
|
dataSource as unknown as DataSource,
|
|
);
|
|
return {
|
|
service,
|
|
attendanceRepo,
|
|
dingRawRepo,
|
|
scheduleRepo,
|
|
classStudentRepo,
|
|
sessionRepo,
|
|
attendanceDeviceRepo,
|
|
dataSource,
|
|
};
|
|
};
|
|
|
|
const endedSchedule = {
|
|
id: 4,
|
|
classId: 8,
|
|
weekDay: 6,
|
|
startTime: '09:00',
|
|
endTime: '10:00',
|
|
startDate: '2026-07-01',
|
|
endDate: '2026-07-31',
|
|
subject: '\u6570\u5B66',
|
|
status: 'active',
|
|
scheduleType: 'INTERNAL',
|
|
attendanceAdvanceMinutes: 30,
|
|
};
|
|
|
|
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|
it('creates one course session from DingTalk results after the lesson', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
|
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
|
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
|
|
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Normal',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
punchSource: 'ATM',
|
|
punchDeviceName: '东门考勤机',
|
|
punchDeviceId: 'ATM-01',
|
|
},
|
|
{
|
|
matchedStudentId: 2,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Late',
|
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
|
},
|
|
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
|
|
]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(sessionRepo.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
}),
|
|
);
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
studentId: 1,
|
|
status: 'present',
|
|
source: 'dingtalk',
|
|
punchSource: 'ATM',
|
|
punchDeviceName: '东门考勤机',
|
|
punchDeviceId: 'ATM-01',
|
|
punchTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
}),
|
|
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
|
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
|
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
|
]);
|
|
expect(result.records).toHaveLength(4);
|
|
});
|
|
|
|
it('finalizes missing punches as absent and completes the session', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
|
]);
|
|
expect(sessionRepo.save).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ status: 'completed', completedBy: 21 }),
|
|
);
|
|
expect(result.session.status).toBe('completed');
|
|
});
|
|
|
|
it('returns student relations after the first pull', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ student: { id: 1, name: '张三' } }),
|
|
]);
|
|
expect(result.records[0].student.name).toBe('张三');
|
|
});
|
|
|
|
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Late',
|
|
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Normal',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
},
|
|
]);
|
|
|
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, status: 'present' }),
|
|
]);
|
|
});
|
|
|
|
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
{ studentId: 2, student: { id: 2, name: '李四' } },
|
|
{ studentId: 3, student: { id: 3, name: '王五' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OffDuty',
|
|
checkOutTime: new Date('2026-07-11T08:40:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 2,
|
|
attendanceType: 'OffDuty',
|
|
checkOutTime: new Date('2026-07-11T10:00:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 3,
|
|
attendanceType: 'OnDuty',
|
|
checkInTime: new Date('2026-07-11T08:39:59+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 3,
|
|
attendanceType: 'OffDuty',
|
|
checkOutTime: new Date('2026-07-11T10:00:01+08:00'),
|
|
},
|
|
]);
|
|
|
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, status: 'present' }),
|
|
expect.objectContaining({ studentId: 2, status: 'present' }),
|
|
expect.objectContaining({ studentId: 3, status: 'absent' }),
|
|
]);
|
|
});
|
|
|
|
it('expands import dates when the pre-class window crosses midnight', () => {
|
|
const { service } = createService();
|
|
expect(
|
|
service.getLessonAttendanceImportDateRange(
|
|
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
|
'2026-07-11',
|
|
),
|
|
).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
|
|
});
|
|
|
|
it('creates local attendance after the lesson starts', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
const now = new Date();
|
|
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
|
|
scheduleRepo.findOne.mockResolvedValue({
|
|
...endedSchedule,
|
|
weekDay,
|
|
startTime: '00:00',
|
|
endTime: '23:59',
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-12-31',
|
|
});
|
|
sessionRepo.findOne.mockResolvedValue(null);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
const today = [
|
|
now.getFullYear(),
|
|
String(now.getMonth() + 1).padStart(2, '0'),
|
|
String(now.getDate()).padStart(2, '0'),
|
|
].join('-');
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalled();
|
|
expect(result.records).toHaveLength(1);
|
|
});
|
|
|
|
it('refreshes a completed session when DingTalk punches arrive late', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'completed',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
|
]);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
},
|
|
]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, status: 'present', remark: null }),
|
|
]);
|
|
expect(sessionRepo.save).not.toHaveBeenCalled();
|
|
expect(result.session.status).toBe('completed');
|
|
});
|
|
|
|
it('refreshes an in_progress session from latest DingTalk data', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
|
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
|
]);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
|
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Normal',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 2,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Late',
|
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
|
},
|
|
]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(sessionRepo.create).not.toHaveBeenCalled();
|
|
expect(attendanceRepo.save).toHaveBeenCalled();
|
|
|
|
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
|
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
|
expect(savedRecords).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ studentId: 1, status: 'present' }),
|
|
expect.objectContaining({ studentId: 2, status: 'present' }),
|
|
]),
|
|
);
|
|
expect(result.records).toHaveLength(2);
|
|
});
|
|
|
|
it('restores students missing from an existing empty session', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([]);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
{ studentId: 2, student: { id: 2, name: '李四' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
|
|
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
|
|
]);
|
|
expect(result.records).toHaveLength(2);
|
|
});
|
|
it('preserves manually corrected records when refreshing an in_progress session', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
|
|
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
|
]);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
|
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Late',
|
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 2,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Late',
|
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
|
},
|
|
]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
|
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
|
expect(savedRecords).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ studentId: 1, status: 'leave' }),
|
|
expect.objectContaining({ studentId: 2, status: 'present' }),
|
|
]),
|
|
);
|
|
expect(result.records).toHaveLength(2);
|
|
});
|
|
|
|
it('final settlement overrides interim manual status using the final punch result', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
|
|
]);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
|
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
|
|
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
|
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
|
]);
|
|
});
|
|
|
|
it('rejects completion when pending records exist', async () => {
|
|
const { service, sessionRepo, attendanceRepo } = createService();
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.count.mockResolvedValue(1);
|
|
|
|
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it('completes a pulled attendance session after teacher review', async () => {
|
|
const { service, sessionRepo, attendanceRepo } = createService();
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.count.mockResolvedValue(0);
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 1, status: 'present' },
|
|
{ id: 2, status: 'absent' },
|
|
]);
|
|
|
|
const result = await service.completeLessonAttendance(90, 21);
|
|
|
|
expect(sessionRepo.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
|
|
);
|
|
expect(result.session.status).toBe('completed');
|
|
});
|
|
|
|
it('throws when querying a missing schedule attendance session', async () => {
|
|
const { service, scheduleRepo } = createService();
|
|
scheduleRepo.findOne.mockResolvedValue(null);
|
|
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('update() marks record source as manual so refresh preserves the correction', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
// Initial state: dingtalk-sourced record
|
|
attendanceRepo.findOne.mockResolvedValue({
|
|
id: 101,
|
|
studentId: 1,
|
|
attendanceSessionId: 90,
|
|
status: 'present',
|
|
source: 'dingtalk',
|
|
});
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
|
|
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
|
]);
|
|
classStudentRepo.find.mockResolvedValue([
|
|
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
{ studentId: 2, student: { id: 2, name: '李四' } },
|
|
]);
|
|
dingRawRepo.find.mockResolvedValue([
|
|
{
|
|
matchedStudentId: 1,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Normal',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
},
|
|
{
|
|
matchedStudentId: 2,
|
|
attendanceType: 'OnDuty',
|
|
timeResult: 'Normal',
|
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
|
},
|
|
]);
|
|
|
|
// Step 1: update the record to absent via generic update()
|
|
const updated = await service.update(101, { status: 'absent' });
|
|
expect(updated.source).toBe('manual');
|
|
|
|
// Step 2: refresh in_progress session — manual record status must stay absent
|
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
|
|
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
|
|
][0] as Array<{ studentId: number; status: string }>;
|
|
expect(savedRecords).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
|
expect.objectContaining({ studentId: 2, status: 'present' }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('recovers from concurrent unique constraint on first session creation', async () => {
|
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
|
sessionRepo.findOne
|
|
.mockResolvedValueOnce(null) // first check: no existing session
|
|
.mockResolvedValueOnce({
|
|
// recovery: the winning session
|
|
id: 77,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
lessonDate: '2026-07-11',
|
|
status: 'in_progress',
|
|
});
|
|
// Simulate unique constraint on save
|
|
sessionRepo.save.mockRejectedValueOnce(
|
|
Object.assign(new Error('UNIQUE constraint failed'), {
|
|
code: 'SQLITE_CONSTRAINT',
|
|
errno: undefined,
|
|
}),
|
|
);
|
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
attendanceRepo.find.mockResolvedValue([
|
|
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
|
|
]);
|
|
|
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
|
|
|
expect(result.session.id).toBe(77);
|
|
expect(result.records).toHaveLength(1);
|
|
});
|
|
|
|
it('completeLessonAttendance runs inside a transaction', async () => {
|
|
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
scheduleId: 4,
|
|
classId: 8,
|
|
status: 'in_progress',
|
|
});
|
|
attendanceRepo.count.mockResolvedValue(0);
|
|
attendanceRepo.find.mockResolvedValue([{ id: 1, status: 'present' }]);
|
|
|
|
await service.completeLessonAttendance(90, 21);
|
|
|
|
expect(dataSource.transaction).toHaveBeenCalled();
|
|
});
|
|
|
|
it('update() throws when the parent session is completed', async () => {
|
|
const { service, attendanceRepo, sessionRepo } = createService();
|
|
attendanceRepo.findOne.mockResolvedValue({
|
|
id: 101,
|
|
attendanceSessionId: 90,
|
|
status: 'present',
|
|
});
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
status: 'completed',
|
|
});
|
|
|
|
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it('remove() throws when the parent session is completed', async () => {
|
|
const { service, attendanceRepo, sessionRepo } = createService();
|
|
attendanceRepo.findOne.mockResolvedValue({
|
|
id: 101,
|
|
attendanceSessionId: 90,
|
|
});
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
status: 'completed',
|
|
});
|
|
|
|
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('update() allows modification when the parent session is in_progress', async () => {
|
|
const { service, attendanceRepo, sessionRepo } = createService();
|
|
attendanceRepo.findOne.mockResolvedValue({
|
|
id: 101,
|
|
attendanceSessionId: 90,
|
|
status: 'present',
|
|
});
|
|
sessionRepo.findOne.mockResolvedValue({
|
|
id: 90,
|
|
status: 'in_progress',
|
|
});
|
|
|
|
const result = await service.update(101, { status: 'absent' });
|
|
expect(result.source).toBe('manual');
|
|
});
|
|
});
|
|
|
|
describe('AttendanceService — attendance window boundaries', () => {
|
|
it('crosses calendar boundaries only when the window requires it', () => {
|
|
const { service } = createService();
|
|
expect(
|
|
service.getLessonAttendanceImportDateRange(
|
|
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 },
|
|
'2026-07-13',
|
|
),
|
|
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
|
|
expect(
|
|
service.getLessonAttendanceImportDateRange(
|
|
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 },
|
|
'2026-07-13',
|
|
),
|
|
).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
|
|
expect(
|
|
service.getLessonAttendanceImportDateRange(
|
|
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
|
'2026-07-13',
|
|
),
|
|
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
|
|
});
|
|
|
|
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
|
|
const originalTz = process.env.TZ;
|
|
process.env.TZ = 'UTC';
|
|
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
|
|
try {
|
|
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } =
|
|
createService();
|
|
scheduleRepo.findOne.mockResolvedValue({
|
|
...endedSchedule,
|
|
weekDay: 1,
|
|
startTime: '08:30',
|
|
endTime: '10:00',
|
|
startDate: '2026-07-13',
|
|
endDate: '2026-07-13',
|
|
});
|
|
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
|
|
attendanceRepo.find.mockResolvedValue([]);
|
|
dingRawRepo.find.mockResolvedValue([]);
|
|
classStudentRepo.find.mockResolvedValue([]);
|
|
await expect(
|
|
service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21),
|
|
).resolves.toMatchObject({ records: [] });
|
|
} finally {
|
|
jest.useRealTimers();
|
|
process.env.TZ = originalTz;
|
|
}
|
|
});
|
|
});
|