Files
gongxue-base/apps/server/src/attendance/attendance-import.service.spec.ts
wangziqi f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

566 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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