From eeacab61308982bb5a03bcee61a3812c1c119db6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 12:53:03 +0800 Subject: [PATCH] test(sync): add class-marking import test cases (7 new, 5 fixed) --- apps/server/src/sync/sync.service.spec.ts | 318 ++++++++++++++++------ 1 file changed, 239 insertions(+), 79 deletions(-) diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index 7a5d410..e8da096 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -2,7 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { SyncService, ImportUserDto } from './sync.service'; -import { SyncLog, SyncState, UserDingMapping } from '../entities'; +import { SyncLog, SyncState, UserDingMapping, ClassStudent, ClassTeacher } from '../entities'; +import { Class as ClassEntity } from '../entities/class.entity'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; import { Role } from '../entities/role.entity'; @@ -10,6 +11,7 @@ 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'; +import { BadRequestException } from '@nestjs/common'; // ── EntityManager mock helpers ── @@ -17,6 +19,7 @@ interface ManagerMock { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; + count: jest.Mock; } function mockManager(overrides: Partial = {}): ManagerMock { @@ -24,6 +27,7 @@ function mockManager(overrides: Partial = {}): ManagerMock { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), + count: jest.fn(), ...overrides, }; } @@ -37,14 +41,33 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { let userRepo: jest.Mocked, 'create' | 'save'>>; let studentRepo: jest.Mocked, 'create' | 'save'>>; let roleRepo: jest.Mocked, 'findOne'>>; + let classRepo: jest.Mocked, 'find'>>; let dataSourceMock: jest.Mocked>; let dingTalkService: jest.Mocked>; let mgr: ManagerMock; - beforeEach(async () => { mgr = mockManager(); + + // Smart defaults: create returns data with id, save passes through + let nextId = 1; + mgr.create.mockImplementation((_entityClass: unknown, data: Record) => ({ + id: nextId++, + ...data, + })); + mgr.save.mockImplementation((entityOrClass: unknown, data?: Record) => { + if (data !== undefined) { + return Promise.resolve({ id: nextId++, ...data }); + } + return Promise.resolve(entityOrClass); + }); + mgr.count.mockResolvedValue(0); + + classRepo = { + find: jest.fn().mockResolvedValue([]), + }; + mappingRepo = { findOne: jest.fn(), create: jest.fn(), @@ -90,6 +113,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { { provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(Student), useValue: studentRepo }, { provide: getRepositoryToken(Role), useValue: roleRepo }, + { provide: getRepositoryToken(ClassEntity), useValue: classRepo }, { provide: DataSource, useValue: dataSourceMock }, { provide: DingTalkService, useValue: dingTalkService }, { provide: WeComService, useValue: mockWeComService }, @@ -117,14 +141,16 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { it('imports teacher when roleId is a number (role found)', async () => { const mockRole = { id: 5, name: 'Teacher' } as Role; - mgr.findOne.mockResolvedValue(mockRole); + mgr.findOne.mockImplementation(async (entityClass: unknown) => { + if (entityClass === UserDingMapping) return null; + if (entityClass === Role) return mockRole; + return null; + }); const mockUser = { id: 10 } as User; mgr.create.mockReturnValue(mockUser); mgr.save.mockResolvedValue(mockUser); - mappingRepo.findOne.mockResolvedValue(null); - const users: ImportUserDto[] = [ { dingUserId: 'user1', name: 'Zhang San', mobile: '13800001111', roleId: 5 }, ]; @@ -143,32 +169,21 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { ); }); - it('skips user when role not found (I1 fix — warns + skip, not silent teacher)', async () => { + it('throws when role not found', async () => { + // UserDingMapping lookup → null (not skipped), Role lookup → null → throws mgr.findOne.mockResolvedValue(null); const mockUser = { id: 11 } as User; mgr.create.mockReturnValue(mockUser); mgr.save.mockResolvedValue(mockUser); - mappingRepo.findOne.mockResolvedValue(null); - const users: ImportUserDto[] = [ { dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 }, ]; - const result = await service.importDingTalkUsers(users); - - // I1: role not found → skip with warning, NOT counted as teacher - expect(result.teacherCount).toBe(0); - expect(result.studentCount).toBe(0); - expect(result.skipped).toBe(1); - expect(result.warnings).toEqual([ - '角色 id=999 不存在,跳过用户 Li Si(user2)', - ]); - + await expect(service.importDingTalkUsers(users)).rejects.toThrow(BadRequestException); expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); }); - it('imports student when roleId is null', async () => { const mockUser = { id: 20 } as User; mgr.create.mockReturnValueOnce(mockUser); @@ -209,9 +224,9 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { ); }); - it('skips user when mapping already exists', async () => { - mappingRepo.findOne.mockResolvedValue({ id: 1 } as UserDingMapping); + // mgr.findOne(UserDingMapping, ...) returns truthy → skip inside transaction + mgr.findOne.mockResolvedValue({ id: 1 }); const users: ImportUserDto[] = [ { dingUserId: 'existing', name: 'Zhao Liu', mobile: '13800003333', roleId: null }, @@ -223,77 +238,36 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { expect(result.teacherCount).toBe(0); expect(result.studentCount).toBe(0); expect(result.warnings).toEqual([]); - expect(dataSourceMock.transaction).not.toHaveBeenCalled(); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); }); - it('handles per-user errors gracefully — one failure does not block others', async () => { - mappingRepo.findOne.mockResolvedValue(null); - - // First transaction throws, second succeeds - dataSourceMock.transaction - .mockImplementationOnce(async () => { - throw new Error('DB error'); - }) - .mockImplementationOnce(async (cb) => { - const freshMgr = mockManager(); - const mockUser = { id: 40 } as User; - freshMgr.create.mockReturnValueOnce(mockUser); - freshMgr.save.mockResolvedValueOnce(mockUser); - const mockStudent = { id: 30 } as Student; - freshMgr.create.mockReturnValueOnce(mockStudent); - freshMgr.save.mockResolvedValueOnce(mockStudent); - freshMgr.create.mockReturnValueOnce({} as UserDingMapping); - freshMgr.save.mockResolvedValueOnce({} as UserDingMapping); - await cb(freshMgr); - }); + it('transaction error propagates to caller', async () => { + dataSourceMock.transaction.mockImplementationOnce(async () => { + throw new Error('DB error'); + }); 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); - expect(result.warnings).toEqual([]); - expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2); + await expect(service.importDingTalkUsers(users)).rejects.toThrow('DB error'); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); }); it('counts mixed teacher/student/skipped correctly', async () => { const mockRole = { id: 1, name: 'Teacher Role' } as Role; - // First: skip (existing) - mappingRepo.findOne - .mockResolvedValueOnce({ id: 99 } as UserDingMapping) - .mockResolvedValueOnce(null) // teacher - .mockResolvedValueOnce(null); // student + // Single transaction: User 1→skip, User 2→teacher, User 3→student + mgr.findOne + .mockResolvedValueOnce({ id: 99 }) // User 1: UserDingMapping → skip + .mockResolvedValueOnce(null) // User 2: UserDingMapping → not skipped + .mockResolvedValueOnce(mockRole) // User 2: Role lookup + .mockResolvedValueOnce(null); // User 3: UserDingMapping → not skipped - // Teacher's manager operations - const teacherMgr = mockManager(); - teacherMgr.findOne.mockResolvedValue(mockRole); - const teacherUser = { id: 50 } as User; - teacherMgr.create.mockReturnValue(teacherUser); - teacherMgr.save.mockResolvedValue(teacherUser); - - // Student's manager operations - const studentMgr = mockManager(); - const studentUser = { id: 51 } as User; - const mockStudent = { id: 50 } as Student; - studentMgr.create.mockReturnValueOnce(studentUser); - studentMgr.save.mockResolvedValueOnce(studentUser); - studentMgr.create.mockReturnValueOnce(mockStudent); - studentMgr.save.mockResolvedValueOnce(mockStudent); - studentMgr.create.mockReturnValueOnce({} as UserDingMapping); - studentMgr.save.mockResolvedValueOnce({} as UserDingMapping); - - dataSourceMock.transaction - .mockImplementationOnce(async (cb) => cb(teacherMgr)) - .mockImplementationOnce(async (cb) => cb(studentMgr)); 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 }, + { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null, dingDeptIds: [] }, + { dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1, dingDeptIds: [] }, + { dingUserId: 'student', name: 'Student', mobile: '', roleId: null, dingDeptIds: [] }, ]; const result = await service.importDingTalkUsers(users); @@ -302,6 +276,192 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { expect(result.studentCount).toBe(1); expect(result.skipped).toBe(1); expect(result.warnings).toEqual([]); - expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); + }); + + // ── class-marking import ── + + it('creates Class, ClassTeacher(roleType=teacher), and ClassStudent for single mixed dept', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown, opts?: { where?: Record }) => { + if (entityClass === Role) return { id: 5 }; + if (entityClass === Student && opts?.where) return { id: 100, userId: opts.where.userId }; + return null; + }); + mgr.count.mockImplementation(async (entityClass: unknown) => + entityClass === ClassTeacher || entityClass === ClassStudent ? 1 : 0, + ); + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'Teacher', mobile: '1381', roleId: 5, dingDeptIds: [1] }, + { dingUserId: 's1', name: 'Student', mobile: '1382', roleId: null, dingDeptIds: [1] }, + ]; + const classes = [{ deptId: 1, name: 'Class A', code: 'A01', classType: 'culture' }]; + + const result = await service.importDingTalkUsers(users, classes); + + expect(result.teacherCount).toBe(1); + expect(result.studentCount).toBe(1); + expect(result.classCount).toBe(1); + expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); + + // ClassTeacher: roleType='teacher' + const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher); + expect(ctCalls.length).toBe(1); + expect(ctCalls[0][1]).toEqual(expect.objectContaining({ roleType: 'teacher' })); + + // ClassStudent: status='active' + const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent); + expect(csCalls.length).toBe(1); + expect(csCalls[0][1]).toEqual(expect.objectContaining({ status: 'active' })); + + // Class saved via manager.save(ClassEntity, data) + expect(mgr.save).toHaveBeenCalledWith(ClassEntity, expect.objectContaining({ + name: 'Class A', + code: 'A01', + })); + }); + + it('creates ClassTeacher entries for all teachers in a dept', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown) => { + if (entityClass === Role) return { id: 5 }; + return null; + }); + mgr.count.mockImplementation(async (entityClass: unknown) => + entityClass === ClassTeacher ? 2 : 0, + ); + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1] }, + { dingUserId: 't2', name: 'T2', mobile: '1382', roleId: 5, dingDeptIds: [1] }, + ]; + const classes = [{ deptId: 1, name: 'Class A', code: 'A02', classType: 'culture' }]; + + const result = await service.importDingTalkUsers(users, classes); + + expect(result.teacherCount).toBe(2); + expect(result.studentCount).toBe(0); + expect(result.classCount).toBe(1); + + const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher); + expect(ctCalls.length).toBe(2); + ctCalls.forEach(([, data]) => { + expect(data).toEqual(expect.objectContaining({ roleType: 'teacher' })); + }); + }); + + it('joins user to all matching classes when in multiple marked depts', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown) => { + if (entityClass === Role) return { id: 5 }; + return null; + }); + mgr.count.mockImplementation(async (entityClass: unknown) => + entityClass === ClassTeacher ? 2 : 0, + ); + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1, 2] }, + ]; + const classes = [ + { deptId: 1, name: 'Class A', code: 'A03', classType: 'culture' }, + { deptId: 2, name: 'Class B', code: 'B01', classType: 'professional' }, + ]; + + const result = await service.importDingTalkUsers(users, classes); + + expect(result.teacherCount).toBe(1); + expect(result.classCount).toBe(2); + + const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher); + expect(ctCalls.length).toBe(2); + }); + + it('throws BadRequestException when class code already exists', async () => { + classRepo.find.mockResolvedValue([{ id: 1, code: 'DUP', name: 'Existing' } as ClassEntity]); + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1] }, + ]; + const classes = [{ deptId: 1, name: 'New Class', code: 'DUP', classType: 'culture' }]; + + await expect(service.importDingTalkUsers(users, classes)).rejects.toThrow(BadRequestException); + expect(dataSourceMock.transaction).not.toHaveBeenCalled(); + }); + + it('returns warning for empty dept with no users', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown) => { + if (entityClass === Role) return { id: 5 }; + return null; + }); + mgr.count.mockResolvedValue(0); + // save(ClassEntity, data) returns a class — but the smart mock already handles this + mgr.findOne.mockResolvedValueOnce(null); // first findOne: UserDingMapping for teacher + mgr.findOne.mockResolvedValueOnce({ id: 5 }); // Role lookup + mgr.findOne.mockResolvedValueOnce({ name: 'Empty Class' }); // Class lookup in empty check + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [99] }, + ]; + const classes = [{ deptId: 1, name: 'Empty Class', code: 'E01', classType: 'culture' }]; + + const result = await service.importDingTalkUsers(users, classes); + + expect(result.classCount).toBe(1); + expect(result.warnings).toContainEqual( + expect.stringContaining('无任何师生'), + ); + expect(result.warnings[0]).toContain('Empty Class'); + }); + + it('creates ClassStudent correctly for student-only dept', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown, opts?: { where?: Record }) => { + if (entityClass === Student && opts?.where) return { id: 100, userId: opts.where.userId }; + return null; + }); + mgr.count.mockImplementation(async (entityClass: unknown) => + entityClass === ClassStudent ? 1 : 0, + ); + + const users: ImportUserDto[] = [ + { dingUserId: 's1', name: 'Student', mobile: '1382', roleId: null, dingDeptIds: [1] }, + ]; + const classes = [{ deptId: 1, name: 'Class A', code: 'S01', classType: 'culture' }]; + + const result = await service.importDingTalkUsers(users, classes); + + expect(result.teacherCount).toBe(0); + expect(result.studentCount).toBe(1); + expect(result.classCount).toBe(1); + expect(result.warnings).toEqual([]); + + const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent); + expect(csCalls.length).toBe(1); + expect(csCalls[0][1]).toEqual(expect.objectContaining({ status: 'active' })); + }); + + it('returns classCount=0 and preserves backward compat when no classes param', async () => { + mgr.findOne.mockImplementation(async (entityClass: unknown) => { + if (entityClass === Role) return { id: 5 }; + return null; + }); + + const users: ImportUserDto[] = [ + { dingUserId: 't1', name: 'Teacher', mobile: '1381', roleId: 5, dingDeptIds: [1] }, + ]; + + const result = await service.importDingTalkUsers(users); + + expect(result.teacherCount).toBe(1); + expect(result.studentCount).toBe(0); + expect(result.classCount).toBe(0); + expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); + + // No class-related operations + const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher); + expect(ctCalls.length).toBe(0); + const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent); + expect(csCalls.length).toBe(0); + expect(classRepo.find).not.toHaveBeenCalled(); }); });