Browse Source

✅ test(users): 添加用户相关单元测试

- 创建用户API测试文件(get.test.ts),覆盖参数验证、成功响应和错误处理场景
- 添加用户服务测试(user.service.test.ts),测试用户创建、查询和密码验证功能
- 实现测试工具函数(test-utils.ts),提供测试服务器创建和模拟数据生成功能
yourname 2 months ago
parent
commit
dd0912d5e6

+ 152 - 0
src/server/api/users/__tests__/get.test.ts

@@ -0,0 +1,152 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { UserService } from '../../../modules/users/user.service';
+import { DataSource } from 'typeorm';
+import { createTestServer } from '../../../../test/test-utils';
+
+// Mock 用户服务
+jest.mock('../../../modules/users/user.service');
+
+// Mock 数据源
+jest.mock('../../../data-source', () => ({
+  AppDataSource: {
+    getRepository: jest.fn()
+  }
+}));
+
+describe('GET /users API', () => {
+  let app: OpenAPIHono;
+  let mockUserService: jest.Mocked<UserService>;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    // 创建模拟的用户服务
+    mockUserService = {
+      getUsersWithPagination: jest.fn()
+    } as any;
+
+    // Mock UserService 构造函数
+    (UserService as jest.MockedClass<typeof UserService>).mockImplementation(() => mockUserService);
+
+    // 动态导入以避免缓存问题
+    jest.isolateModules(async () => {
+      const module = await import('../get');
+      app = module.default;
+    });
+  });
+
+  describe('参数验证', () => {
+    it('应该验证页码必须为正整数', async () => {
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=0');
+
+      expect(response.status).toBe(400);
+      expect(await response.json()).toMatchObject({
+        code: 400,
+        message: '参数错误'
+      });
+    });
+
+    it('应该验证每页数量必须为正整数', async () => {
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?pageSize=0');
+
+      expect(response.status).toBe(400);
+      expect(await response.json()).toMatchObject({
+        code: 400,
+        message: '参数错误'
+      });
+    });
+
+    it('应该接受有效的分页参数', async () => {
+      const mockUsers = [{ id: 1, username: 'testuser' }];
+      const total = 1;
+      mockUserService.getUsersWithPagination.mockResolvedValue([mockUsers as any, total]);
+
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=1&pageSize=10');
+
+      expect(response.status).toBe(200);
+      expect(mockUserService.getUsersWithPagination).toHaveBeenCalledWith({
+        page: 1,
+        pageSize: 10,
+        keyword: undefined
+      });
+    });
+  });
+
+  describe('成功响应', () => {
+    it('应该返回用户列表和分页信息', async () => {
+      const mockUsers = [
+        { id: 1, username: 'user1', email: 'user1@example.com' },
+        { id: 2, username: 'user2', email: 'user2@example.com' }
+      ];
+      const total = 2;
+      mockUserService.getUsersWithPagination.mockResolvedValue([mockUsers as any, total]);
+
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=1&pageSize=10');
+
+      expect(response.status).toBe(200);
+      expect(await response.json()).toEqual({
+        data: mockUsers,
+        pagination: {
+          total: 2,
+          current: 1,
+          pageSize: 10
+        }
+      });
+    });
+
+    it('应该支持关键词搜索', async () => {
+      const mockUsers = [{ id: 1, username: 'admin' }];
+      const total = 1;
+      mockUserService.getUsersWithPagination.mockResolvedValue([mockUsers as any, total]);
+
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=1&pageSize=10&keyword=admin');
+
+      expect(response.status).toBe(200);
+      expect(mockUserService.getUsersWithPagination).toHaveBeenCalledWith({
+        page: 1,
+        pageSize: 10,
+        keyword: 'admin'
+      });
+    });
+  });
+
+  describe('错误处理', () => {
+    it('应该在服务抛出错误时返回500错误', async () => {
+      mockUserService.getUsersWithPagination.mockRejectedValue(new Error('Database error'));
+
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=1&pageSize=10');
+
+      expect(response.status).toBe(500);
+      expect(await response.json()).toMatchObject({
+        code: 500,
+        message: 'Database error'
+      });
+    });
+
+    it('应该在未知错误时返回通用错误消息', async () => {
+      mockUserService.getUsersWithPagination.mockRejectedValue('Unknown error');
+
+      const server = createTestServer(app);
+
+      const response = await server.get('/users?page=1&pageSize=10');
+
+      expect(response.status).toBe(500);
+      expect(await response.json()).toMatchObject({
+        code: 500,
+        message: '获取用户列表失败'
+      });
+    });
+  });
+});

+ 188 - 0
src/server/modules/users/__tests__/user.service.test.ts

@@ -0,0 +1,188 @@
+import { UserService } from '../user.service';
+import { DataSource, Repository } from 'typeorm';
+import { UserEntity as User } from '../user.entity';
+import { Role } from '../role.entity';
+import * as bcrypt from 'bcrypt';
+
+// Mock TypeORM 数据源和仓库
+jest.mock('typeorm', () => ({
+  DataSource: jest.fn().mockImplementation(() => ({
+    getRepository: jest.fn()
+  })),
+  Repository: jest.fn()
+}));
+
+// Mock bcrypt
+jest.mock('bcrypt', () => ({
+  hash: jest.fn().mockResolvedValue('hashed_password'),
+  compare: jest.fn().mockResolvedValue(true)
+}));
+
+describe('UserService', () => {
+  let userService: UserService;
+  let mockDataSource: jest.Mocked<DataSource>;
+  let mockUserRepository: jest.Mocked<Repository<User>>;
+  let mockRoleRepository: jest.Mocked<Repository<Role>>;
+
+  beforeEach(() => {
+    // 创建模拟的仓库实例
+    mockUserRepository = {
+      create: jest.fn(),
+      save: jest.fn(),
+      findOne: jest.fn(),
+      update: jest.fn(),
+      delete: jest.fn(),
+      createQueryBuilder: jest.fn(),
+      find: jest.fn(),
+      findByIds: jest.fn()
+    } as any;
+
+    mockRoleRepository = {
+      findByIds: jest.fn()
+    } as any;
+
+    // 创建模拟的数据源
+    mockDataSource = {
+      getRepository: jest.fn()
+    } as any;
+
+    // 设置数据源返回模拟的仓库
+    mockDataSource.getRepository
+      .mockReturnValueOnce(mockUserRepository)
+      .mockReturnValueOnce(mockRoleRepository);
+
+    userService = new UserService(mockDataSource);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('createUser', () => {
+    it('应该成功创建用户并哈希密码', async () => {
+      const userData = {
+        username: 'testuser',
+        password: 'password123',
+        email: 'test@example.com'
+      };
+
+      const mockUser = { id: 1, ...userData, password: 'hashed_password' } as User;
+
+      mockUserRepository.create.mockReturnValue(mockUser);
+      mockUserRepository.save.mockResolvedValue(mockUser);
+
+      const result = await userService.createUser(userData);
+
+      expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
+      expect(mockUserRepository.create).toHaveBeenCalledWith({
+        ...userData,
+        password: 'hashed_password'
+      });
+      expect(mockUserRepository.save).toHaveBeenCalledWith(mockUser);
+      expect(result).toEqual(mockUser);
+    });
+
+    it('应该在创建用户失败时抛出错误', async () => {
+      const userData = { username: 'testuser', password: 'password123' };
+      const error = new Error('Database error');
+
+      mockUserRepository.create.mockImplementation(() => {
+        throw error;
+      });
+
+      await expect(userService.createUser(userData)).rejects.toThrow('Failed to create user');
+    });
+  });
+
+  describe('getUserById', () => {
+    it('应该通过ID成功获取用户', async () => {
+      const mockUser = { id: 1, username: 'testuser' } as User;
+      mockUserRepository.findOne.mockResolvedValue(mockUser);
+
+      const result = await userService.getUserById(1);
+
+      expect(mockUserRepository.findOne).toHaveBeenCalledWith({
+        where: { id: 1 },
+        relations: ['roles']
+      });
+      expect(result).toEqual(mockUser);
+    });
+
+    it('应该在用户不存在时返回null', async () => {
+      mockUserRepository.findOne.mockResolvedValue(null);
+
+      const result = await userService.getUserById(999);
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('getUsersWithPagination', () => {
+    it('应该成功获取分页用户列表', async () => {
+      const mockUsers = [{ id: 1 }, { id: 2 }] as User[];
+      const total = 2;
+
+      const mockQueryBuilder = {
+        leftJoinAndSelect: jest.fn().mockReturnThis(),
+        skip: jest.fn().mockReturnThis(),
+        take: jest.fn().mockReturnThis(),
+        where: jest.fn().mockReturnThis(),
+        getManyAndCount: jest.fn().mockResolvedValue([mockUsers, total])
+      };
+
+      mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
+
+      const result = await userService.getUsersWithPagination({
+        page: 1,
+        pageSize: 10
+      });
+
+      expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
+      expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
+      expect(result).toEqual([mockUsers, total]);
+    });
+
+    it('应该支持关键词搜索', async () => {
+      const mockQueryBuilder = {
+        leftJoinAndSelect: jest.fn().mockReturnThis(),
+        skip: jest.fn().mockReturnThis(),
+        take: jest.fn().mockReturnThis(),
+        where: jest.fn().mockReturnThis(),
+        getManyAndCount: jest.fn().mockResolvedValue([[], 0])
+      };
+
+      mockUserRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
+
+      await userService.getUsersWithPagination({
+        page: 1,
+        pageSize: 10,
+        keyword: 'test'
+      });
+
+      expect(mockQueryBuilder.where).toHaveBeenCalledWith(
+        'user.username LIKE :keyword OR user.nickname LIKE :keyword OR user.phone LIKE :keyword',
+        { keyword: '%test%' }
+      );
+    });
+  });
+
+  describe('verifyPassword', () => {
+    it('应该验证密码正确', async () => {
+      const user = { password: 'hashed_password' } as User;
+
+      const result = await userService.verifyPassword(user, 'password123');
+
+      expect(bcrypt.compare).toHaveBeenCalledWith('password123', 'hashed_password');
+      expect(result).toBe(true);
+    });
+
+    it('应该验证密码错误', async () => {
+      (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false);
+      const user = { password: 'hashed_password' } as User;
+
+      const result = await userService.verifyPassword(user, 'wrong_password');
+
+      expect(result).toBe(false);
+    });
+  });
+});

+ 88 - 0
src/test/test-utils.ts

@@ -0,0 +1,88 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { Hono } from 'hono';
+
+/**
+ * 创建测试服务器实例
+ */
+export function createTestServer(app: OpenAPIHono | Hono) {
+  const server = app as any;
+
+  return {
+    get: (path: string) => makeRequest('GET', path),
+    post: (path: string, body?: any) => makeRequest('POST', path, body),
+    put: (path: string, body?: any) => makeRequest('PUT', path, body),
+    delete: (path: string) => makeRequest('DELETE', path),
+    patch: (path: string, body?: any) => makeRequest('PATCH', path, body)
+  };
+
+  async function makeRequest(method: string, path: string, body?: any) {
+    const url = new URL(path, 'http://localhost:3000');
+
+    const request = new Request(url.toString(), {
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: body ? JSON.stringify(body) : undefined,
+    });
+
+    try {
+      const response = await server.fetch(request);
+      return {
+        status: response.status,
+        headers: response.headers,
+        json: async () => response.json(),
+        text: async () => response.text()
+      };
+    } catch (error) {
+      throw new Error(`Request failed: ${error}`);
+    }
+  }
+}
+
+/**
+ * 创建模拟的认证上下文
+ */
+export function createMockAuthContext() {
+  return {
+    req: {
+      header: (name: string) => {
+        if (name === 'authorization') return 'Bearer mock-token';
+        return null;
+      }
+    },
+    set: jest.fn(),
+    json: jest.fn().mockImplementation((data, status = 200) => ({
+      status,
+      body: data
+    })),
+    env: {},
+    var: {}
+  };
+}
+
+/**
+ * 创建模拟的用户实体
+ */
+export function createMockUser(overrides: Partial<any> = {}) {
+  return {
+    id: 1,
+    username: 'testuser',
+    email: 'test@example.com',
+    password: 'hashed_password',
+    phone: '13800138000',
+    nickname: 'Test User',
+    status: 1,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    roles: [],
+    ...overrides
+  };
+}
+
+/**
+ * 等待指定时间
+ */
+export function wait(ms: number) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}