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