308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|