forked from wangziqi/gongxue-base
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
import { ConflictException, ServiceUnavailableException } from '@nestjs/common';
|
|
import { SyncLog } from '../entities';
|
|
import { SyncService } from './sync.service';
|
|
|
|
function queryBuilder(affected = 1) {
|
|
const builder = {
|
|
insert: jest.fn(),
|
|
update: jest.fn(),
|
|
values: jest.fn(),
|
|
orIgnore: jest.fn(),
|
|
set: jest.fn(),
|
|
where: jest.fn(),
|
|
andWhere: jest.fn(),
|
|
execute: jest.fn().mockResolvedValue({ affected }),
|
|
};
|
|
for (const method of ['insert', 'update', 'values', 'orIgnore', 'set', 'where', 'andWhere'] as const) {
|
|
builder[method].mockReturnValue(builder);
|
|
}
|
|
return builder;
|
|
}
|
|
|
|
function createService(options?: {
|
|
affected?: number;
|
|
attendanceResult?: { success: boolean; imported: number; errors: string[] };
|
|
}) {
|
|
const builders = [queryBuilder(), queryBuilder(options?.affected), queryBuilder()];
|
|
const syncStateRepo = {
|
|
createQueryBuilder: jest.fn().mockImplementation(() => builders.shift()),
|
|
findOne: jest.fn().mockResolvedValue({ lastSyncAt: null }),
|
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
};
|
|
const syncLogRepo = {
|
|
create: jest.fn().mockImplementation((value: Partial<SyncLog>) => value),
|
|
save: jest.fn().mockImplementation(async (value: SyncLog) => value),
|
|
findOne: jest.fn(),
|
|
find: jest.fn(),
|
|
};
|
|
const dingTalkService = {
|
|
syncAll: jest.fn().mockResolvedValue({ created: 1, updated: 2, conflicts: [] }),
|
|
};
|
|
const attendanceImportService = {
|
|
importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? {
|
|
success: true,
|
|
imported: 3,
|
|
errors: [],
|
|
}),
|
|
};
|
|
const service = new SyncService(
|
|
syncLogRepo as never,
|
|
syncStateRepo as never,
|
|
{ find: jest.fn().mockResolvedValue([{ dingUserId: 'u1' }]) } as never,
|
|
dingTalkService as never,
|
|
{ syncAll: jest.fn().mockResolvedValue({ userCount: 0 }) } as never,
|
|
attendanceImportService as never,
|
|
{} as never,
|
|
);
|
|
return { service, syncStateRepo, syncLogRepo, dingTalkService, attendanceImportService };
|
|
}
|
|
|
|
describe('SyncService — safe DingTalk orchestration', () => {
|
|
it('uses a dedicated student cursor and records a successful student sync', async () => {
|
|
const { service, syncStateRepo, syncLogRepo } = createService();
|
|
|
|
const log = await service.syncDingTalkStudents(9);
|
|
|
|
expect(log).toMatchObject({
|
|
platform: 'dingtalk_students',
|
|
status: 'success',
|
|
recordsCount: 3,
|
|
});
|
|
expect(syncStateRepo.update).toHaveBeenCalledWith(
|
|
{ platform: 'dingtalk_students' },
|
|
expect.objectContaining({ lastSyncAt: expect.any(Date) }),
|
|
);
|
|
expect(syncLogRepo.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a second run when the database lease is held', async () => {
|
|
const { service } = createService({ affected: 0 });
|
|
|
|
await expect(service.syncDingTalkStudents()).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('does not advance attendance cursor when import reports failure', async () => {
|
|
const { service, syncStateRepo, syncLogRepo } = createService({
|
|
attendanceResult: { success: false, imported: 0, errors: ['upstream failed'] },
|
|
});
|
|
|
|
await expect(service.syncDingTalkAttendance()).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
expect(syncStateRepo.update).not.toHaveBeenCalled();
|
|
expect(syncLogRepo.save).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ status: 'failed', errorMessage: expect.stringContaining('upstream failed') }),
|
|
);
|
|
});
|
|
});
|