import { BadRequestException } from '@nestjs/common'; import { AttendanceImportService } from './attendance-import.service'; import { DingTalkService } from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; describe('AttendanceImportService', () => { const dingRawRepo = { find: jest.fn(), findOne: jest.fn(), save: jest.fn(), }; const studentRepo = { findOne: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), }; let service: AttendanceImportService; beforeEach(() => { jest.clearAllMocks(); service = new AttendanceImportService( dingRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, attendanceService as unknown as AttendanceService, ); }); it('splits DingTalk requests by at most 50 users and 7 calendar days without offset pagination', async () => { const userIds = Array.from({ length: 51 }, (_, index) => `user-${index + 1}`); dingTalkService.fetchAttendanceResults.mockResolvedValue([]); await (service as any).fetchAllPages({ startDate: '2026-07-01', endDate: '2026-07-10', userIds, }); expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(4); expect(dingTalkService.fetchAttendanceResults.mock.calls.map(([params]) => params)).toEqual([ { startDate: '2026-07-01', endDate: '2026-07-07', userIds: userIds.slice(0, 50), }, { startDate: '2026-07-01', endDate: '2026-07-07', userIds: userIds.slice(50), }, { startDate: '2026-07-08', endDate: '2026-07-10', userIds: userIds.slice(0, 50), }, { startDate: '2026-07-08', endDate: '2026-07-10', userIds: userIds.slice(50), }, ]); }); it('rejects an attendance import without DingTalk user IDs', async () => { await expect( (service as any).fetchAllPages({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: [], }), ).rejects.toBeInstanceOf(BadRequestException); expect(dingTalkService.fetchAttendanceResults).not.toHaveBeenCalled(); }); it('stores the DingTalk user name returned with the attendance record', async () => { const entity = await (service as any).mapToEntity({ userId: 'ding-1', userName: '张三', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T08:00:00.000Z', checkId: 'check-1', checkType: 'OnDuty', }); expect(entity.userName).toBe('张三'); }); it('stores DingTalk punch source and attendance machine metadata', async () => { const entity = await (service as any).mapToEntity({ userId: 'ding-1', userName: '张三', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T08:00:00.000Z', checkId: 'check-1', checkType: 'OnDuty', sourceType: 'ATM', deviceName: '东门考勤机', deviceId: 'ATM-01', }); expect(entity).toEqual( expect.objectContaining({ punchSource: 'ATM', punchDeviceName: '东门考勤机', punchDeviceId: 'ATM-01', }), ); }); it('fills the student name from the DingTalk mapping when saving an imported record', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { userId: 'ding-1', userName: '', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T08:00:00.000Z', checkId: 'check-1', checkType: 'OnDuty', }, ]); dingRawRepo.find.mockResolvedValue([]); studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 }); studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' }); dingRawRepo.save.mockImplementation(async (entities) => entities); await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], autoMatch: false, }); expect(dingRawRepo.save).toHaveBeenCalledWith( [expect.objectContaining({ userName: '张三' })], { chunk: 50 }, ); }); it('auto-matches previously imported duplicate records', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { userId: 'ding-1', userName: '张三', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T08:00:00.000Z', checkId: 'check-1', checkType: 'OnDuty', sourceType: 'ATM', deviceName: '东门考勤机', deviceId: 'ATM-01', }, ]); dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1', punchSource: null, punchDeviceName: null, punchDeviceId: null, rawData: '', }]); dingRawRepo.save.mockImplementation(async (entities) => entities); attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 }); const result = await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], autoMatch: true, }); expect(dingRawRepo.save).toHaveBeenCalledWith( [expect.objectContaining({ dingId: 'check-1', punchSource: 'ATM', punchDeviceName: '东门考勤机', punchDeviceId: 'ATM-01', })], { chunk: 50 }, ); expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled(); expect(result.matched).toBe(1); }); it('preserves existing device metadata when a duplicate response omits it', async () => { const existing = { dingId: 'check-keep-device', punchSource: 'ATM', punchDeviceName: '东门考勤机', punchDeviceId: 'ATM-01', rawData: '{}', }; dingTalkService.fetchAttendanceResults.mockResolvedValue([{ userId: 'ding-1', userName: '张三', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T08:00:00.000Z', checkId: 'check-keep-device', checkType: 'OnDuty', sourceType: '', }]); dingRawRepo.find.mockResolvedValue([existing]); attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 }); await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], autoMatch: true, }); expect(existing).toEqual(expect.objectContaining({ punchSource: 'ATM', punchDeviceName: '东门考勤机', punchDeviceId: 'ATM-01', })); expect(dingRawRepo.save).not.toHaveBeenCalled(); }); it('scopes SSE progress events to the importing user', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { userId: 'ding-1', userName: '李四', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T09:00:00.000Z', checkId: 'check-2', checkType: 'OnDuty', }, ]); const events: Array<{ phase: string; userId?: number }> = []; const sub = service.progress$.subscribe((event) => { events.push({ phase: event.phase, userId: event.userId }); }); await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], userId: 42, }); sub.unsubscribe(); expect(events.length).toBeGreaterThan(0); for (const event of events) { expect(event.userId).toBe(42); } }); it('emits userId undefined when import has no HTTP user', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { userId: 'ding-2', userName: '王五', workDate: '2026-07-02', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-02T10:00:00.000Z', checkId: 'check-3', checkType: 'OnDuty', }, ]); const events: Array<{ phase: string; userId?: number }> = []; const sub = service.progress$.subscribe((event) => { events.push({ phase: event.phase, userId: event.userId }); }); await service.importFromDingTalk({ startDate: '2026-07-02', endDate: '2026-07-02', userIds: ['ding-2'], }); sub.unsubscribe(); expect(events.length).toBeGreaterThan(0); for (const event of events) { expect(event.userId).toBeUndefined(); } }); });