import { BaseController, CoolCommException } from '@cool-midway/core'; import { BaseSysDepartmentEntity } from '../../../src/modules/base/entity/sys/department'; import { BaseSysUserEntity } from '../../../src/modules/base/entity/sys/user'; import { CardInfoEntity } from '../../../src/modules/card/entity/info'; import { AdminTeamMembersController, adminTeamMembersCurdOption, } from '../../../src/modules/team/controller/admin/members'; import { TeamMembersService } from '../../../src/modules/team/service/members'; describe('Point 17: 后台组织架构、部门树与成员名片分配 (Team Members)', () => { // ========================================== // 1. 控制器路由与装饰器配置约定 // ========================================== describe('1. 控制器装饰器与路由约定', () => { it('控制器应继承 BaseController 并配置 CRUD', () => { expect(AdminTeamMembersController.prototype).toBeInstanceOf(BaseController); expect(adminTeamMembersCurdOption.entity).toBe(BaseSysUserEntity); expect(adminTeamMembersCurdOption.service).toBe(TeamMembersService); }); it('serviceApis 必须包含关键服务方法暴露', () => { const methods = (adminTeamMembersCurdOption.serviceApis || []).map( (api: { method: string }) => api.method ); expect(methods).toContain('getDepartmentTree'); expect(methods).toContain('assignCard'); expect(methods).toContain('moveMembers'); expect(methods).toContain('getMemberPage'); expect(methods).toContain('addDepartment'); expect(methods).toContain('updateDepartment'); expect(methods).toContain('deleteDepartment'); }); }); // ========================================== // 2. 部门树层级构建与 CRUD // ========================================== describe('2. 部门树层级构建与部门管理 (TeamMembersService)', () => { let service: TeamMembersService; let departments: BaseSysDepartmentEntity[]; let users: BaseSysUserEntity[]; let cards: CardInfoEntity[]; beforeEach(() => { departments = []; users = []; cards = []; // 预置部门数据:总部 (1) -> 研发中心 (2) -> 前端组 (3); 总部 (1) -> 国际业务拓展部 (4) const d1 = new BaseSysDepartmentEntity(); d1.id = 1; d1.name = '全球商务总部'; d1.parentId = null as unknown as number; d1.orderNum = 1; const d2 = new BaseSysDepartmentEntity(); d2.id = 2; d2.name = '国际研发中心'; d2.parentId = 1; d2.orderNum = 10; const d3 = new BaseSysDepartmentEntity(); d3.id = 3; d3.name = '前端终端架构组'; d3.parentId = 2; d3.orderNum = 5; const d4 = new BaseSysDepartmentEntity(); d4.id = 4; d4.name = '国际业务拓展部'; d4.parentId = 1; d4.orderNum = 20; departments.push(d1, d2, d3, d4); // 预置成员数据 const u1 = new BaseSysUserEntity(); u1.id = 101; u1.name = '张总监'; u1.username = 'alex_zhang'; u1.departmentId = 1; u1.status = 1; const u2 = new BaseSysUserEntity(); u2.id = 102; u2.name = '李架构'; u2.username = 'leo_li'; u2.departmentId = 3; u2.status = 1; const u3 = new BaseSysUserEntity(); u3.id = 103; u3.name = '王商务'; u3.username = 'sarah_wang'; u3.departmentId = 4; u3.status = 1; users.push(u1, u2, u3); // 预置名片数据 const c1 = new CardInfoEntity(); c1.id = 801; c1.name = 'Alex Zhang'; c1.title = 'Managing Director'; c1.userId = 101; c1.slug = 'alex-zhang'; c1.status = 'published'; const c2 = new CardInfoEntity(); c2.id = 802; c2.name = 'Sarah Wang'; c2.title = 'Global BD Lead'; c2.userId = null as unknown as number; c2.slug = 'sarah-wang'; c2.status = 'published'; cards.push(c1, c2); // Mock Repositories const mockDeptRepo = { find: jest.fn(async () => [...departments]), findOneBy: jest.fn(async (query: { id: number }) => { return departments.find(d => d.id === Number(query.id)) || null; }), save: jest.fn(async (entity: BaseSysDepartmentEntity) => { if (!entity.id) { entity.id = departments.length ? Math.max(...departments.map(d => d.id)) + 1 : 1; departments.push(entity); return entity; } const idx = departments.findIndex(d => d.id === entity.id); if (idx >= 0) { Object.assign(departments[idx], entity); return departments[idx]; } departments.push(entity); return entity; }), delete: jest.fn(async (id: number) => { const idx = departments.findIndex(d => d.id === Number(id)); if (idx >= 0) { departments.splice(idx, 1); } return { affected: 1 }; }), }; const mockUserRepo = { find: jest.fn(async () => [...users]), findOneBy: jest.fn(async (query: { id: number }) => { return users.find(u => u.id === Number(query.id)) || null; }), findAndCount: jest.fn(async () => [users, users.length]), update: jest.fn(async (criteria: { id: unknown } | unknown, values: Partial) => { let targetIds: number[] = []; if (criteria && typeof criteria === 'object' && '_type' in criteria) { // In(...) TypeORM operator targetIds = (criteria as unknown as { _value: number[] })._value; } else if (typeof criteria === 'number') { targetIds = [criteria]; } for (const u of users) { if (targetIds.includes(u.id)) { Object.assign(u, values); } } return { affected: targetIds.length }; }), }; const mockCardRepo = { find: jest.fn(async () => [...cards]), findOneBy: jest.fn(async (query: { id: number }) => { return cards.find(c => c.id === Number(query.id)) || null; }), save: jest.fn(async (entity: CardInfoEntity) => { const idx = cards.findIndex(c => c.id === entity.id); if (idx >= 0) { Object.assign(cards[idx], entity); return cards[idx]; } cards.push(entity); return entity; }), }; service = new TeamMembersService(); service.baseSysDepartmentEntity = mockDeptRepo as unknown as TeamMembersService['baseSysDepartmentEntity']; service.baseSysUserEntity = mockUserRepo as unknown as TeamMembersService['baseSysUserEntity']; service.cardInfoEntity = mockCardRepo as unknown as TeamMembersService['cardInfoEntity']; }); it('2.1 getDepartmentTree 正确构建多级部门树,按 orderNum 升序并统计人数', async () => { const tree = await service.getDepartmentTree(); expect(Array.isArray(tree)).toBe(true); expect(tree.length).toBe(1); // 根部门:全球商务总部 (1) const root = tree[0]; expect(root.id).toBe(1); expect(root.title).toBe('全球商务总部'); expect(root.children?.length).toBe(2); // children 排序应为 国际研发中心 (orderNum: 10) 和 国际业务拓展部 (orderNum: 20) expect(root.children?.[0].id).toBe(2); expect(root.children?.[0].children?.length).toBe(1); // 前端组 (3) expect(root.children?.[0].children?.[0].id).toBe(3); expect(root.children?.[1].id).toBe(4); // 人数统计验证 expect(root.memberCount).toBe(1); // u1 在总部 expect(root.children?.[0].children?.[0].memberCount).toBe(1); // u2 在前端组 expect(root.children?.[1].memberCount).toBe(1); // u3 在国际业务部 }); it('2.2 addDepartment 校验部门名称必填并成功入库', async () => { await expect(service.addDepartment({ name: '' })).rejects.toThrow(CoolCommException); const added = await service.addDepartment({ name: '中东迪拜办公室', parentId: 1, orderNum: 30, }); expect(added.id).toBeDefined(); expect(added.name).toBe('中东迪拜办公室'); expect(added.parentId).toBe(1); const found = departments.find(d => d.name === '中东迪拜办公室'); expect(found).toBeDefined(); }); it('2.3 updateDepartment 更新指定部门信息', async () => { const updated = await service.updateDepartment(3, { name: '全栈与跨端技术组', orderNum: 1, }); expect(updated.name).toBe('全栈与跨端技术组'); expect(updated.orderNum).toBe(1); }); it('2.4 deleteDepartment 删除部门,部门不存在时抛出异常', async () => { await expect(service.deleteDepartment(9999)).rejects.toThrow(CoolCommException); const res = await service.deleteDepartment(4); expect(res.success).toBe(true); expect(departments.some(d => d.id === 4)).toBe(false); }); }); // ========================================== // 3. 成员列表查询与名片关联 // ========================================== describe('3. 成员分页列表与名片关联 (getMemberPage)', () => { let service: TeamMembersService; let users: BaseSysUserEntity[]; let cards: CardInfoEntity[]; let departments: BaseSysDepartmentEntity[]; beforeEach(() => { users = [ Object.assign(new BaseSysUserEntity(), { id: 1, name: 'Alex Zhang', username: 'alex', phone: '+86 13800000001', departmentId: 10, status: 1, }), Object.assign(new BaseSysUserEntity(), { id: 2, name: 'Sarah Wang', username: 'sarah', phone: '+86 13800000002', departmentId: 20, status: 1, }), ]; departments = [ Object.assign(new BaseSysDepartmentEntity(), { id: 10, name: '商务部' }), Object.assign(new BaseSysDepartmentEntity(), { id: 20, name: '技术部' }), ]; cards = [ Object.assign(new CardInfoEntity(), { id: 501, userId: 1, name: 'Alex Zhang', title: 'Managing Director', slug: 'alex', status: 'published', }), ]; service = new TeamMembersService(); service.baseSysDepartmentEntity = { find: jest.fn(async () => departments), } as unknown as TeamMembersService['baseSysDepartmentEntity']; service.baseSysUserEntity = { find: jest.fn(async () => users), } as unknown as TeamMembersService['baseSysUserEntity']; service.cardInfoEntity = { find: jest.fn(async () => cards), } as unknown as TeamMembersService['cardInfoEntity']; }); it('3.1 正确返回成员列表并聚合已分配的名片信息与部门名称', async () => { const result = await service.getMemberPage({ page: 1, size: 20 }); expect(result.pagination.total).toBe(2); expect(result.list.length).toBe(2); const member1 = result.list.find(m => m.id === 1); expect(member1?.name).toBe('Alex Zhang'); expect(member1?.departmentName).toBe('商务部'); expect(member1?.card).toBeDefined(); expect(member1?.card?.id).toBe(501); expect(member1?.card?.title).toBe('Managing Director'); const member2 = result.list.find(m => m.id === 2); expect(member2?.name).toBe('Sarah Wang'); expect(member2?.departmentName).toBe('技术部'); expect(member2?.card).toBeNull(); }); it('3.2 按部门 ID 筛选成员', async () => { const result = await service.getMemberPage({ departmentId: 10 }); expect(result.list.length).toBe(1); expect(result.list[0].id).toBe(1); }); it('3.3 按关键字模糊检索成员', async () => { const result = await service.getMemberPage({ keyword: 'sarah' }); expect(result.list.length).toBe(1); expect(result.list[0].username).toBe('sarah'); }); }); // ========================================== // 4. 名片分配与批量调部 // ========================================== describe('4. 名片分配 (assignCard) 与 批量调整部门 (moveMembers)', () => { let service: TeamMembersService; let users: BaseSysUserEntity[]; let cards: CardInfoEntity[]; let departments: BaseSysDepartmentEntity[]; beforeEach(() => { users = [ Object.assign(new BaseSysUserEntity(), { id: 11, name: 'User 11', departmentId: 1, }), Object.assign(new BaseSysUserEntity(), { id: 12, name: 'User 12', departmentId: 1, }), ]; departments = [ Object.assign(new BaseSysDepartmentEntity(), { id: 1, name: '原有部门' }), Object.assign(new BaseSysDepartmentEntity(), { id: 2, name: '目标部门' }), ]; cards = [ Object.assign(new CardInfoEntity(), { id: 901, name: 'VIP 名片', userId: null as unknown as number, }), ]; service = new TeamMembersService(); service.baseSysDepartmentEntity = { findOneBy: jest.fn(async (q: { id: number }) => departments.find(d => d.id === Number(q.id)) || null ), } as unknown as TeamMembersService['baseSysDepartmentEntity']; service.baseSysUserEntity = { findOneBy: jest.fn(async (q: { id: number }) => users.find(u => u.id === Number(q.id)) || null ), find: jest.fn(async () => users), update: jest.fn(async () => ({ affected: 2 })), } as unknown as TeamMembersService['baseSysUserEntity']; service.cardInfoEntity = { findOneBy: jest.fn(async (q: { id: number }) => cards.find(c => c.id === Number(q.id)) || null ), save: jest.fn(async (c: CardInfoEntity) => { const idx = cards.findIndex(item => item.id === c.id); if (idx >= 0) { cards[idx] = c; } return c; }), } as unknown as TeamMembersService['cardInfoEntity']; }); it('4.1 assignCard 将指定名片分配给指定成员', async () => { const res = await service.assignCard(11, 901); expect(res.success).toBe(true); expect(res.memberId).toBe(11); expect(res.cardId).toBe(901); const targetCard = cards.find(c => c.id === 901); expect(targetCard?.userId).toBe(11); }); it('4.2 assignCard 兼容参数对象形式调用', async () => { const res = await service.assignCard({ memberId: 12, cardId: 901 }); expect(res.success).toBe(true); expect(res.memberId).toBe(12); expect(res.cardId).toBe(901); }); it('4.3 assignCard 成员不存在或名片不存在时抛出异常', async () => { await expect(service.assignCard(999, 901)).rejects.toThrow('成员不存在'); await expect(service.assignCard(11, 9999)).rejects.toThrow('名片不存在'); }); it('4.4 moveMembers 批量更新成员所属部门', async () => { const res = await service.moveMembers([11, 12], 2); expect(res.success).toBe(true); expect(res.departmentId).toBe(2); expect(res.memberIds).toEqual([11, 12]); }); it('4.5 moveMembers 校验成员列表非空且目标部门必须存在', async () => { await expect(service.moveMembers([], 2)).rejects.toThrow('未指定成员列表'); await expect(service.moveMembers([11], 9999)).rejects.toThrow('目标部门不存在'); }); }); // ========================================== // 5. 控制器方法暴露与响应结构 // ========================================== describe('5. 控制器路由调用测试 (AdminTeamMembersController)', () => { let controller: AdminTeamMembersController; let mockService: TeamMembersService; beforeEach(() => { mockService = new TeamMembersService(); mockService.getDepartmentTree = jest.fn(async () => [ { id: 1, name: '总部', children: [] }, ]); mockService.assignCard = jest.fn(async (mId, cId) => ({ success: true, memberId: typeof mId === 'object' ? Number(mId.memberId) : Number(mId), cardId: typeof mId === 'object' ? Number(mId.cardId) : Number(cId), card: new CardInfoEntity(), })); mockService.moveMembers = jest.fn(async (ids, dId) => ({ success: true, updatedCount: Array.isArray(ids) ? ids.length : (ids.memberIds?.length || 0), memberIds: Array.isArray(ids) ? ids : (ids.memberIds || []), departmentId: Array.isArray(ids) ? Number(dId) : Number(ids.departmentId), })); controller = new AdminTeamMembersController(); controller.teamMembersService = mockService; controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data, }); }); it('postDepartmentTree 返回 code 1000', async () => { const res = await controller.postDepartmentTree(); expect(res.code).toBe(1000); expect(mockService.getDepartmentTree).toHaveBeenCalled(); }); it('postAssignCard 返回 code 1000', async () => { const res = await controller.postAssignCard({ memberId: 10, cardId: 20 }); expect(res.code).toBe(1000); expect(mockService.assignCard).toHaveBeenCalledWith(10, 20); }); it('postMoveMembers 返回 code 1000', async () => { const res = await controller.postMoveMembers({ memberIds: [10, 11], departmentId: 5, }); expect(res.code).toBe(1000); expect(mockService.moveMembers).toHaveBeenCalledWith([10, 11], 5); }); }); });