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(), find: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), }; let runner: { connect: jest.Mock; query: jest.Mock; release: jest.Mock; }; const dataSource = { createQueryRunner: jest.fn(() => runner), }; let service: AttendanceImportService; beforeEach(() => { jest.clearAllMocks(); runner = { connect: jest.fn().mockResolvedValue(undefined), query: jest.fn().mockResolvedValue([{ acquired: 1 }]), release: jest.fn().mockResolvedValue(undefined), }; service = new AttendanceImportService( dingRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, attendanceService as unknown as AttendanceService, dataSource as never, ); }); 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('writes checkInTime for OnDuty and checkOutTime for OffDuty only', async () => { const onDuty = 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-on', checkType: 'OnDuty', }); const offDuty = await (service as any).mapToEntity({ userId: 'ding-1', userName: '张三', workDate: '2026-07-01', timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '2026-07-01T18:00:00.000Z', checkId: 'check-off', checkType: 'OffDuty', }); expect(onDuty.checkInTime).toEqual(new Date('2026-07-01T08:00:00.000Z')); expect(onDuty.checkOutTime).toBeUndefined(); expect(offDuty.checkOutTime).toEqual(new Date('2026-07-01T18:00:00.000Z')); expect(offDuty.checkInTime).toBeUndefined(); }); it('does not write checkIn/checkOut times for a non-whitelisted checkType', 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-unknown', checkType: 'OnDuty/OffDuty', }); // 非法考勤类型不写任何时间,避免污染 checkOutTime/checkInTime;记录本身仍保留 expect(entity.attendanceType).toBe('OnDuty/OffDuty'); expect(entity.checkInTime).toBeUndefined(); expect(entity.checkOutTime).toBeUndefined(); }); 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.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]); studentRepo.find.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 }, ); // 批量预取:只查一次映射 + 一次学生,不再逐条 findOne expect(studentDingMappingRepo.find).toHaveBeenCalledTimes(1); expect(studentRepo.find).toHaveBeenCalledTimes(1); expect(studentDingMappingRepo.findOne).not.toHaveBeenCalled(); expect(studentRepo.findOne).not.toHaveBeenCalled(); }); 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(); } }); it('returns success false when a batch save fails', 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-save-fail', checkType: 'OnDuty', }, ]); dingRawRepo.find.mockResolvedValue([]); dingRawRepo.save.mockRejectedValue(new Error('database down')); const result = await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], }); expect(result.success).toBe(false); expect(result.imported).toBe(0); expect(result.errors.some((message) => message.includes('Batch save error'))).toBe(true); expect(service.running).toBe(false); }); it('cleans up isRunning and importingUserId even when runner.release fails', async () => { runner.release.mockRejectedValueOnce(new Error('release boom')); dingTalkService.fetchAttendanceResults.mockResolvedValue([]); const result = await service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], userId: 7, }); expect(result.success).toBe(true); expect(service.running).toBe(false); expect((service as any).importingUserId).toBeUndefined(); expect(runner.release).toHaveBeenCalled(); }); it('releases the query runner when connect fails to avoid pool leaks', async () => { runner.connect.mockRejectedValueOnce(new Error('connect boom')); await expect( service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], }), ).rejects.toThrow('connect boom'); expect(runner.release).toHaveBeenCalledTimes(1); expect(runner.query).not.toHaveBeenCalled(); expect(service.running).toBe(false); }); it('watchdog only warns and never releases the DB lock while the import is running', async () => { jest.useFakeTimers(); const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); try { let releaseImport: (() => void) | undefined; const gate = new Promise((resolve) => { releaseImport = resolve; }); dingTalkService.fetchAttendanceResults.mockImplementation(() => gate.then(() => [])); const importPromise = service.importFromDingTalk({ startDate: '2026-07-01', endDate: '2026-07-01', userIds: ['ding-1'], }); await jest.advanceTimersByTimeAsync(30 * 60 * 1000); // 看门狗只告警,不执行 RELEASE_LOCK(锁仍由导入结束时释放) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('30 分钟')); expect( runner.query.mock.calls.some(([sql]) => String(sql).includes('RELEASE_LOCK')), ).toBe(false); releaseImport!(); await importPromise; } finally { warnSpy.mockRestore(); jest.useRealTimers(); } }); it('fetches user×date batches concurrently with a bounded concurrency of 4', async () => { const userIds = Array.from({ length: 101 }, (_, index) => `user-${index + 1}`); // 3 user batches // 3 user batches × 2 date ranges = 6 requests let inFlight = 0; let maxInFlight = 0; dingTalkService.fetchAttendanceResults.mockImplementation(async (params) => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); await new Promise((resolve) => setTimeout(resolve, 10)); inFlight -= 1; return [{ userId: params.userIds[0], userName: '', workDate: params.startDate, timeResult: 'Normal', locationResult: '', planCheckTime: '', actualCheckTime: '', checkId: `${params.startDate}-${params.userIds[0]}`, checkType: 'OnDuty', }]; }); const results = await (service as any).fetchAllPages({ startDate: '2026-07-01', endDate: '2026-07-10', userIds, }); expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(6); expect(maxInFlight).toBeGreaterThan(1); expect(maxInFlight).toBeLessThanOrEqual(4); expect(results).toHaveLength(6); // 结果顺序仍为(日期范围 × 用户批次)的原始顺序 expect(results[0].checkId).toBe('2026-07-01-user-1'); expect(results[1].checkId).toBe('2026-07-01-user-51'); expect(results[3].checkId).toBe('2026-07-08-user-1'); expect(results[4].checkId).toBe('2026-07-08-user-51'); expect(results[5].checkId).toBe('2026-07-08-user-101'); }); it('chunks dingId dedup lookups into blocks of at most 1000 and merges results', async () => { const checkIds = Array.from({ length: 1001 }, (_, index) => `check-${index + 1}`); // 输入含重复 id:先去重再分块,返回 Map 仍按 dingId 去重 const results = [...checkIds, 'check-1', 'check-500'].map((checkId) => ({ checkId })); const inValues = (value: unknown): string[] => { if (Array.isArray(value)) return value as string[]; if (value && typeof value === 'object' && '_value' in value) { return (value as { _value: unknown })._value as string[]; } return []; }; dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => inValues(where.dingId).map((dingId) => ({ dingId })), ); const map = await (service as any).getExistingRecordsByDingId(results); // 1001 个去重后的 id 被切成 1000 + 1 两块并发查询 expect(dingRawRepo.find).toHaveBeenCalledTimes(2); const firstChunk = inValues( (dingRawRepo.find.mock.calls[0]?.[0] as { where: { dingId: unknown } }).where.dingId, ); const secondChunk = inValues( (dingRawRepo.find.mock.calls[1]?.[0] as { where: { dingId: unknown } }).where.dingId, ); expect(firstChunk).toHaveLength(1000); expect(secondChunk).toHaveLength(1); // 分块结果合并后仍能命中所有 id,且无重复 expect(map.size).toBe(1001); expect(map.get('check-1')?.dingId).toBe('check-1'); expect(map.get('check-1001')?.dingId).toBe('check-1001'); }); it('keeps a single query at the 1000-id chunk boundary', async () => { const checkIds = Array.from({ length: 1000 }, (_, index) => `check-${index + 1}`); const inValues = (value: unknown): string[] => { if (Array.isArray(value)) return value as string[]; if (value && typeof value === 'object' && '_value' in value) { return (value as { _value: unknown })._value as string[]; } return []; }; dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => inValues(where.dingId).map((dingId) => ({ dingId })), ); const map = await (service as any).getExistingRecordsByDingId( checkIds.map((checkId) => ({ checkId })), ); expect(dingRawRepo.find).toHaveBeenCalledTimes(1); expect(map.size).toBe(1000); }); });