import { ErrorCode } from '@lingxi/shared'; import { describe, expect, it, vi } from 'vitest'; import { AppException } from '../../common/errors/app-exception'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { DepartmentsService } from './departments.service'; import { LoginFailureService } from './login-failure.service'; import { MeService } from './me.service'; import { PasswordService } from './password.service'; import { RolesService } from './roles.service'; import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper'; import { UsersService } from './users.service'; function userFixture(overrides: Partial = {}): UserWithRelations { const base: UserWithRelations = { id: 'u1', account: 'chen.mo', email: 'chen.mo@lingxi.cn', name: '陈默', phone: '13866202046', passwordHash: 'hash', departmentId: 'g1', jobTitle: '主管', status: 'ACTIVE', mustChangePassword: false, lastLoginAt: new Date('2026-11-02T01:41:00.000Z'), lastLoginIp: '192.168.12.46', disabledAt: null, remark: null, createdBy: null, createdAt: new Date('2026-09-30T10:40:00.000Z'), updatedAt: new Date('2026-11-01T08:22:00.000Z'), department: { id: 'g1', code: 'content-g1', name: '素材一组', parentId: 'd1', leaderUserId: null, sortOrder: 1, status: 'ACTIVE', createdAt: new Date(), updatedAt: new Date(), parent: { id: 'd1', code: 'content', name: '内容生产中心', parentId: null, leaderUserId: null, sortOrder: 1, status: 'ACTIVE', createdAt: new Date(), updatedAt: new Date(), }, }, roles: [ { userId: 'u1', roleId: 'r1', createdAt: new Date(), role: { id: 'r1', code: 'material_operator', name: '素材运营', description: '素材生产', isSystem: false, status: 'ACTIVE', createdAt: new Date(), updatedAt: new Date(), }, }, { userId: 'u1', roleId: 'r2', createdAt: new Date(), role: { id: 'r2', code: 'account_owner', name: '客户负责人', description: '', isSystem: false, status: 'DISABLED', createdAt: new Date(), updatedAt: new Date(), }, }, ], }; return { ...base, ...overrides }; } function expectRejected(promise: Promise, code: ErrorCode): Promise { return promise.then( () => expect.unreachable('应抛出异常'), (error: unknown) => { expect(error).toBeInstanceOf(AppException); expect((error as AppException).code).toBe(code); }, ); } describe('user.mapper', () => { it('手机号脱敏与部门 / 小组拆分', () => { expect(maskPhone('13866202046')).toBe('138****2046'); const summary = toUserSummary(userFixture()); expect(summary.departmentId).toBe('d1'); expect(summary.departmentName).toBe('内容生产中心'); expect(summary.groupId).toBe('g1'); expect(summary.groupName).toBe('素材一组'); expect(summary.status).toBe('active'); expect(summary.roles).toHaveLength(1); expect(summary.roles[0]?.code).toBe('material_operator'); expect(summary.lastLoginAt).toBe('2026-11-02T01:41:00.000Z'); }); it('一级部门直属:groupId 为空;详情按权限决定是否给全号', () => { const direct = toUserSummary( userFixture({ department: { ...userFixture().department, id: 'd1', name: '平台技术部', parentId: null, parent: null, }, }), ); expect(direct.groupId).toBeNull(); expect(direct.departmentName).toBe('平台技术部'); const withPhone = toUserDetail(userFixture(), true); const withoutPhone = toUserDetail(userFixture(), false); expect(withPhone.phone).toBe('13866202046'); expect(withoutPhone.phone).toBeUndefined(); expect(withPhone.mustChangePassword).toBe(false); }); }); describe('PasswordService', () => { it('哈希后可校验,错误口令与非法哈希返回 false', async () => { const service = new PasswordService(); const hash = await service.hash('lingxi2026'); await expect(service.verify(hash, 'lingxi2026')).resolves.toBe(true); await expect(service.verify(hash, 'wrong-pass1')).resolves.toBe(false); await expect(service.verify('not-a-hash', 'x')).resolves.toBe(false); }); }); describe('LoginFailureService', () => { function fakeRedis(): { service: LoginFailureService; store: Map; del: () => void; } { const store = new Map(); const client = { status: 'ready', connect: vi.fn().mockResolvedValue(undefined), get: vi.fn((key: string) => Promise.resolve(store.has(key) ? String(store.get(key)) : null)), del: vi.fn((key: string) => { store.delete(key); return Promise.resolve(1); }), multi: vi.fn(() => { let key = ''; const api = { incr: (target: string) => { key = target; store.set(key, (store.get(key) ?? 0) + 1); return api; }, expire: () => api, exec: () => Promise.resolve([[null, store.get(key) ?? 0]]), }; return api; }), }; const service = new LoginFailureService( { getClient: () => client } as never, { authMaxLoginFailures: 3, authLockMinutes: 15 } as never, ); return { service, store, del: () => { store.clear(); }, }; } it('累计失败达阈值后锁定,成功登录清零', async () => { const { service, store, del } = fakeRedis(); await expect(service.status('Chen.Mo')).resolves.toEqual({ locked: false, remaining: 3 }); await service.record('chen.mo'); await service.record('chen.mo'); await expect(service.count('chen.mo')).resolves.toBe(2); await expect(service.status('chen.mo')).resolves.toEqual({ locked: false, remaining: 1 }); await service.record('chen.mo'); await expect(service.status('chen.mo')).resolves.toEqual({ locked: true, remaining: 0 }); await service.clear('chen.mo'); await expect(service.count('chen.mo')).resolves.toBe(0); del(); expect(store.size).toBe(0); }); }); describe('UsersService', () => { const passwords = { hash: vi.fn().mockResolvedValue('hashed') }; function serviceOf(prisma: Record): UsersService { return new UsersService( prisma as unknown as PrismaService, passwords as unknown as PasswordService, ); } it('详情:user:manage 才带手机号全号', async () => { const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } }; const service = serviceOf(prisma); const withPhone = await service.detail('u1', ['user:manage']); const withoutPhone = await service.detail('u1', ['user:view']); expect(withPhone.phone).toBe('13866202046'); expect(withoutPhone.phone).toBeUndefined(); }); it('列表:游标分页、状态默认 active、排序白名单', async () => { const rows = [userFixture({ id: 'u1' }), userFixture({ id: 'u2' }), userFixture({ id: 'u3' })]; const findMany = vi.fn().mockResolvedValue(rows); const service = serviceOf({ user: { findMany } }); const result = await service.list({ limit: 2, status: 'active', sort: 'last_login', order: 'desc', }); expect(result.items).toHaveLength(2); expect(result.next_cursor).toBe('u2'); const args = findMany.mock.calls[0]?.[0] as { where: { status: string }; orderBy: unknown[]; take: number; }; expect(args.where.status).toBe('ACTIVE'); expect(args.take).toBe(3); expect(args.orderBy).toEqual([{ lastLoginAt: { sort: 'desc', nulls: 'last' } }, { id: 'asc' }]); }); it('停用:重复停用 40903,停用自己 42201,客户转交占位 42201', async () => { const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } }; const service = serviceOf(prisma); await expectRejected(service.disable('u1', {}, 'u1', []), ErrorCode.BUSINESS_RULE_VIOLATION); const disabled = userFixture({ status: 'DISABLED' }); const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } }); await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED); expect(() => service.transferCustomers()).toThrow(AppException); }); it('停用成功会同步吊销 refresh token;启用恢复状态', async () => { const transaction = vi.fn().mockResolvedValue([]); const findUnique = vi .fn() .mockResolvedValueOnce(userFixture()) .mockResolvedValueOnce(userFixture({ status: 'DISABLED' })); const service = serviceOf({ user: { findUnique, update: vi.fn().mockResolvedValue(userFixture()) }, refreshToken: { updateMany: vi.fn() }, $transaction: transaction, }); const detail = await service.disable('u1', { reason: '离职' }, 'u9', ['user:manage']); expect(detail.status).toBe('disabled'); expect(transaction).toHaveBeenCalledTimes(1); const enableService = serviceOf({ user: { findUnique: vi .fn() .mockResolvedValueOnce(userFixture({ status: 'DISABLED' })) .mockResolvedValueOnce(userFixture()), update: vi.fn().mockResolvedValue(userFixture()), }, }); const enabled = await enableService.enable('u1', ['user:manage']); expect(enabled.status).toBe('active'); }); }); describe('DepartmentsService', () => { it('部门树:两级嵌套且一级成员数含下级', async () => { const departments = [ { id: 'd1', code: 'content', name: '内容生产中心', parentId: null, leaderUserId: null, sortOrder: 1, status: 'ACTIVE', createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date(), leader: { id: 'u1', name: '陈默' }, }, { id: 'g1', code: 'content-g1', name: '素材一组', parentId: 'd1', leaderUserId: null, sortOrder: 1, status: 'ACTIVE', createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date(), leader: null, }, ]; const service = new DepartmentsService({ department: { findMany: vi.fn().mockResolvedValue(departments) }, user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) }, } as unknown as PrismaService); const tree = await service.tree(); expect(tree).toHaveLength(1); expect(tree[0]?.memberCount).toBe(5); expect(tree[0]?.children[0]?.name).toBe('素材一组'); }); it('新建:父级不存在 40001,超过两级 42201', async () => { const service = new DepartmentsService({ department: { findUnique: vi.fn().mockResolvedValueOnce(null), }, } as unknown as PrismaService); await expectRejected( service.create({ name: '新组', parentId: 'missing' }), ErrorCode.PARAM_INVALID, ); const service2 = new DepartmentsService({ department: { findUnique: vi.fn().mockResolvedValue({ id: 'g1', parentId: 'd1', }), }, } as unknown as PrismaService); await expectRejected( service2.create({ name: '三级', parentId: 'g1' }), ErrorCode.BUSINESS_RULE_VIOLATION, ); }); it('重命名不存在的部门返回 40401', async () => { const service = new DepartmentsService({ department: { findUnique: vi.fn().mockResolvedValue(null) }, } as unknown as PrismaService); await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND); }); }); describe('MeService / RolesService', () => { it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => { const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } }; const service = new MeService(prisma as unknown as PrismaService); const me = await service.getMe('u1', ['user:manage']); expect(me.user.phone).toBe('13866202046'); expect(me.permissions).toEqual(['user:manage']); expect(me.roles).toHaveLength(1); const meWithout = await service.getMe('u1', []); expect(meWithout.user.phone).toBeUndefined(); await expectRejected( new MeService({ user: { findUnique: vi.fn().mockResolvedValue(null) }, } as unknown as PrismaService).getMe('u1', []), ErrorCode.UNAUTHENTICATED, ); }); it('角色列表只返回启用角色', async () => { const prisma = { role: { findMany: vi.fn().mockResolvedValue([ { id: 'r1', code: 'super_admin', name: '超级管理员', description: '内置', isSystem: true, status: 'ACTIVE', createdAt: new Date(), updatedAt: new Date(), }, ]), }, }; const roles = await new RolesService(prisma as unknown as PrismaService).list(); expect(roles).toEqual([ { id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' }, ]); }); });