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) => 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) => ({ 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) => { 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; } }); });