Files
gongxue-base/apps/server/src/sync/sync.service.spec.ts

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') }),
);
});
});