forked from wangziqi/gongxue-base
test(sync): add class-marking import test cases (7 new, 5 fixed)
This commit is contained in:
@@ -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> = {}): ManagerMock {
|
||||
@@ -24,6 +27,7 @@ function mockManager(overrides: Partial<ManagerMock> = {}): 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<Pick<Repository<User>, 'create' | 'save'>>;
|
||||
let studentRepo: jest.Mocked<Pick<Repository<Student>, 'create' | 'save'>>;
|
||||
let roleRepo: jest.Mocked<Pick<Repository<Role>, 'findOne'>>;
|
||||
let classRepo: jest.Mocked<Pick<Repository<ClassEntity>, 'find'>>;
|
||||
let dataSourceMock: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
||||
|
||||
let dingTalkService: jest.Mocked<Pick<DingTalkService, 'fetchOrgTreeWithUsers' | 'syncAll' | 'fetchOrgTree'>>;
|
||||
|
||||
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<string, unknown>) => ({
|
||||
id: nextId++,
|
||||
...data,
|
||||
}));
|
||||
mgr.save.mockImplementation((entityOrClass: unknown, data?: Record<string, unknown>) => {
|
||||
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<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user