import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SyncService, ImportUserDto } from './sync.service'; import { SyncLog, SyncState, UserDingMapping } from '../entities'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; import { Role } from '../entities/role.entity'; import { DingTalkService } from '../integration/dingtalk.service'; import { WeComService } from '../integration/wecom.service'; import { AttendanceImportService } from '../attendance/attendance-import.service'; import { ScheduleSyncService } from './schedule-sync.service'; describe('SyncService — new methods', () => { let service: SyncService; let mappingRepo: jest.Mocked< Pick, 'findOne' | 'create' | 'save' | 'find'> >; let userRepo: jest.Mocked, 'create' | 'save'>>; let studentRepo: jest.Mocked, 'create' | 'save'>>; let roleRepo: jest.Mocked, 'findOne'>>; let dingTalkService: jest.Mocked>; beforeEach(async () => { mappingRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn(), find: jest.fn().mockResolvedValue([]), }; userRepo = { create: jest.fn(), save: jest.fn(), }; studentRepo = { create: jest.fn(), save: jest.fn(), }; roleRepo = { findOne: jest.fn(), }; dingTalkService = { fetchOrgTreeWithUsers: jest.fn(), syncAll: jest.fn(), fetchOrgTree: jest.fn(), }; const mockWeComService = { syncAll: jest.fn() }; const mockAttendanceImportService = { importFromDingTalk: jest.fn() }; const mockScheduleSyncService = { syncAll: jest.fn(), getStatus: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ SyncService, { provide: getRepositoryToken(SyncLog), useValue: { create: jest.fn(), save: jest.fn(), find: jest.fn(), findOne: jest.fn() } }, { provide: getRepositoryToken(SyncState), useValue: { findOne: jest.fn(), upsert: jest.fn() } }, { provide: getRepositoryToken(UserDingMapping), useValue: mappingRepo }, { provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(Student), useValue: studentRepo }, { provide: getRepositoryToken(Role), useValue: roleRepo }, { provide: DingTalkService, useValue: dingTalkService }, { provide: WeComService, useValue: mockWeComService }, { provide: AttendanceImportService, useValue: mockAttendanceImportService }, { provide: ScheduleSyncService, useValue: mockScheduleSyncService }, ], }).compile(); service = module.get(SyncService); }); // ── getDingTalkOrgTreeWithUsers ── it('getDingTalkOrgTreeWithUsers delegates to dingTalkService.fetchOrgTreeWithUsers', async () => { const mockTree = [{ id: 1, name: 'root', parentId: 0, children: [], users: [] }]; dingTalkService.fetchOrgTreeWithUsers.mockResolvedValue(mockTree); const result = await service.getDingTalkOrgTreeWithUsers(1); expect(dingTalkService.fetchOrgTreeWithUsers).toHaveBeenCalledWith(1); expect(result).toBe(mockTree); }); // ── importDingTalkUsers ── it('imports teacher when roleId is a number (role found)', async () => { const mockRole = { id: 5, name: 'Teacher' } as Role; roleRepo.findOne.mockResolvedValue(mockRole); const mockUser = { id: 10 } as User; userRepo.create.mockReturnValue(mockUser); userRepo.save.mockResolvedValue(mockUser); mappingRepo.findOne.mockResolvedValue(null); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); const users: ImportUserDto[] = [ { dingUserId: 'user1', name: 'Zhang San', mobile: '13800001111', roleId: 5 }, ]; const result = await service.importDingTalkUsers(users); expect(result.teacherCount).toBe(1); expect(result.studentCount).toBe(0); expect(result.skipped).toBe(0); expect(roleRepo.findOne).toHaveBeenCalledWith({ where: { id: 5 } }); expect(userRepo.save).toHaveBeenCalledWith( expect.objectContaining({ roles: [mockRole] }), ); }); it('imports teacher when roleId is a number but role not found (logs warning, no roles assigned)', async () => { roleRepo.findOne.mockResolvedValue(null); const mockUser = { id: 11 } as User; userRepo.create.mockReturnValue(mockUser); userRepo.save.mockResolvedValue(mockUser); mappingRepo.findOne.mockResolvedValue(null); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); const users: ImportUserDto[] = [ { dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 }, ]; const result = await service.importDingTalkUsers(users); expect(result.teacherCount).toBe(1); expect(result.studentCount).toBe(0); expect(result.skipped).toBe(0); // User should be saved once (without roles assignment the second time) expect(userRepo.save).toHaveBeenCalledTimes(1); }); it('imports student when roleId is null', async () => { const mockUser = { id: 20 } as User; userRepo.create.mockReturnValue(mockUser); userRepo.save.mockResolvedValue(mockUser); const mockStudent = { id: 30 } as Student; studentRepo.create.mockReturnValue(mockStudent); studentRepo.save.mockResolvedValue(mockStudent); mappingRepo.findOne.mockResolvedValue(null); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); const users: ImportUserDto[] = [ { dingUserId: 'user3', name: 'Wang Wu', mobile: '', roleId: null }, ]; const result = await service.importDingTalkUsers(users); expect(result.studentCount).toBe(1); expect(result.teacherCount).toBe(0); expect(result.skipped).toBe(0); expect(studentRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'Wang Wu', userId: 20, status: 'active', }), ); expect(studentRepo.save).toHaveBeenCalled(); }); it('skips user when mapping already exists', async () => { mappingRepo.findOne.mockResolvedValue({ id: 1 } as UserDingMapping); const users: ImportUserDto[] = [ { dingUserId: 'existing', name: 'Zhao Liu', mobile: '13800003333', roleId: null }, ]; const result = await service.importDingTalkUsers(users); expect(result.skipped).toBe(1); expect(result.teacherCount).toBe(0); expect(result.studentCount).toBe(0); expect(userRepo.create).not.toHaveBeenCalled(); }); it('handles per-user errors gracefully — one failure does not block others', async () => { // First user fails, second succeeds mappingRepo.findOne.mockResolvedValue(null); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); userRepo.create .mockReturnValueOnce(new Error('DB error') as unknown as User) .mockReturnValueOnce({ id: 40 } as User); userRepo.save .mockRejectedValueOnce(new Error('DB error')) .mockResolvedValueOnce({ id: 40 } as User); studentRepo.create.mockReturnValue({} as Student); studentRepo.save.mockResolvedValue({} as Student); const users: ImportUserDto[] = [ { dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null }, { dingUserId: 'ok', name: 'Ok User', mobile: '', roleId: null }, ]; const result = await service.importDingTalkUsers(users); expect(result.studentCount).toBe(1); expect(result.skipped).toBe(0); // The first user's mapping should not be saved, but the second's should expect(mappingRepo.save).toHaveBeenCalledTimes(1); }); it('counts mixed teacher/student/skipped correctly', async () => { const mockRole = { id: 1, name: 'Teacher Role' } as Role; roleRepo.findOne.mockResolvedValue(mockRole); const mockUser = { id: 50 } as User; userRepo.create.mockReturnValue(mockUser); userRepo.save.mockResolvedValue(mockUser); studentRepo.create.mockReturnValue({} as Student); studentRepo.save.mockResolvedValue({} as Student); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); // First: skip (existing), second: teacher, third: student mappingRepo.findOne .mockResolvedValueOnce({ id: 99 } as UserDingMapping) // skip .mockResolvedValueOnce(null) // teacher .mockResolvedValueOnce(null); // student const users: ImportUserDto[] = [ { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null }, { dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1 }, { dingUserId: 'student', name: 'Student', mobile: '', roleId: null }, ]; const result = await service.importDingTalkUsers(users); expect(result.teacherCount).toBe(1); expect(result.studentCount).toBe(1); expect(result.skipped).toBe(1); }); });