Ver Fonte

所有7个UsersPage组件测试现在都通过

yourname há 2 meses atrás
pai
commit
b01defcc03

+ 3 - 1
.claude/settings.local.json

@@ -16,7 +16,9 @@
       "Bash(npm run lint:*)",
       "Bash(pnpm run test:*)",
       "Bash(npm test:*)",
-      "Bash(npm run typecheck)"
+      "Bash(npm run typecheck)",
+      "Bash(npx playwright test:*)",
+      "Bash(npm view:*)"
     ],
     "deny": [],
     "ask": []

+ 70 - 70
src/client/admin/pages/Users.tsx

@@ -269,33 +269,23 @@ export const UsersPage = () => {
     }
   };
 
-  // 渲染加载骨架
-  if (isLoading) {
-    return (
-      <div className="space-y-4">
-        <div className="flex justify-between items-center">
-          <h1 className="text-2xl font-bold">用户管理</h1>
-          <Button disabled>
-            <Plus className="mr-2 h-4 w-4" />
-            创建用户
-          </Button>
+  // 渲染表格部分的骨架屏
+  const renderTableSkeleton = () => (
+    <div className="space-y-2">
+      {Array.from({ length: 5 }).map((_, index) => (
+        <div key={index} className="flex space-x-4">
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 w-16" />
         </div>
-        
-        <Card>
-          <CardHeader>
-            <Skeleton className="h-6 w-1/4" />
-          </CardHeader>
-          <CardContent>
-            <div className="space-y-2">
-              <Skeleton className="h-4 w-full" />
-              <Skeleton className="h-4 w-full" />
-              <Skeleton className="h-4 w-full" />
-            </div>
-          </CardContent>
-        </Card>
-      </div>
-    );
-  }
+      ))}
+    </div>
+  );
 
   return (
     <div className="space-y-4">
@@ -365,10 +355,10 @@ export const UsersPage = () => {
                 <div className="space-y-2">
                   <label className="text-sm font-medium">用户状态</label>
                   <Select
-                    value={filters.isDisabled?.toString() || ''}
+                    value={filters.isDisabled === undefined ? 'all' : filters.isDisabled.toString()}
                     onValueChange={(value) =>
                       handleFilterChange({
-                        isDisabled: value === '' ? undefined : parseInt(value)
+                        isDisabled: value === 'all' ? undefined : parseInt(value)
                       })
                     }
                   >
@@ -376,7 +366,7 @@ export const UsersPage = () => {
                       <SelectValue placeholder="选择状态" />
                     </SelectTrigger>
                     <SelectContent>
-                      <SelectItem value="">全部状态</SelectItem>
+                      <SelectItem value="all">全部状态</SelectItem>
                       <SelectItem value="0">启用</SelectItem>
                       <SelectItem value="1">禁用</SelectItem>
                     </SelectContent>
@@ -518,50 +508,60 @@ export const UsersPage = () => {
                 </TableRow>
               </TableHeader>
               <TableBody>
-                {users.map((user) => (
-                  <TableRow key={user.id}>
-                    <TableCell className="font-medium">{user.username}</TableCell>
-                    <TableCell>{user.nickname || '-'}</TableCell>
-                    <TableCell>{user.email || '-'}</TableCell>
-                    <TableCell>{user.name || '-'}</TableCell>
-                    <TableCell>
-                      <Badge
-                        variant={user.roles?.some((role) => role.name === 'admin') ? 'destructive' : 'default'}
-                        className="capitalize"
-                      >
-                        {user.roles?.some((role) => role.name === 'admin') ? '管理员' : '普通用户'}
-                      </Badge>
+                {isLoading ? (
+                  // 显示表格骨架屏
+                  <TableRow>
+                    <TableCell colSpan={8} className="p-4">
+                      {renderTableSkeleton()}
                     </TableCell>
-                    <TableCell>
-                      <Badge
-                        variant={user.isDisabled === 1 ? 'secondary' : 'default'}
-                      >
-                        {user.isDisabled === 1 ? '禁用' : '启用'}
-                      </Badge>
-                    </TableCell>
-                    <TableCell>
-                      {format(new Date(user.createdAt), 'yyyy-MM-dd HH:mm')}
-                    </TableCell>
-                    <TableCell className="text-right">
-                      <div className="flex justify-end gap-2">
-                        <Button
-                          variant="ghost"
-                          size="icon"
-                          onClick={() => handleEditUser(user)}
+                  </TableRow>
+                ) : (
+                  // 显示实际用户数据
+                  users.map((user) => (
+                    <TableRow key={user.id}>
+                      <TableCell className="font-medium">{user.username}</TableCell>
+                      <TableCell>{user.nickname || '-'}</TableCell>
+                      <TableCell>{user.email || '-'}</TableCell>
+                      <TableCell>{user.name || '-'}</TableCell>
+                      <TableCell>
+                        <Badge
+                          variant={user.roles?.some((role) => role.name === 'admin') ? 'destructive' : 'default'}
+                          className="capitalize"
                         >
-                          <Edit className="h-4 w-4" />
-                        </Button>
-                        <Button
-                          variant="ghost"
-                          size="icon"
-                          onClick={() => handleDeleteUser(user.id)}
+                          {user.roles?.some((role) => role.name === 'admin') ? '管理员' : '普通用户'}
+                        </Badge>
+                      </TableCell>
+                      <TableCell>
+                        <Badge
+                          variant={user.isDisabled === 1 ? 'secondary' : 'default'}
                         >
-                          <Trash2 className="h-4 w-4" />
-                        </Button>
-                      </div>
-                    </TableCell>
-                  </TableRow>
-                ))}
+                          {user.isDisabled === 1 ? '禁用' : '启用'}
+                        </Badge>
+                      </TableCell>
+                      <TableCell>
+                        {format(new Date(user.createdAt), 'yyyy-MM-dd HH:mm')}
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-2">
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleEditUser(user)}
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleDeleteUser(user.id)}
+                          >
+                            <Trash2 className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
               </TableBody>
             </Table>
           </div>

+ 311 - 0
src/client/admin/pages/__tests__/Users.test.tsx

@@ -0,0 +1,311 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TestWrapper } from '@/client/__test_utils__/test-render';
+import { UsersPage } from '../Users';
+import { userClient } from '@/client/api';
+
+// Mock the API client
+vi.mock('@/client/api', () => ({
+  userClient: {
+    $get: vi.fn(),
+    $post: vi.fn(),
+    ':id': {
+      $put: vi.fn(),
+      $delete: vi.fn()
+    }
+  }
+}));
+
+// Mock the toast notification
+vi.mock('sonner', () => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn()
+  }
+}));
+
+describe('UsersPage Component', () => {
+  const mockUsers = [
+    {
+      id: 1,
+      username: 'admin',
+      nickname: '管理员',
+      email: 'admin@example.com',
+      phone: '13800138000',
+      name: '系统管理员',
+      isDisabled: 0,
+      createdAt: '2024-01-01T00:00:00.000Z',
+      roles: [{ id: 1, name: 'admin' }]
+    },
+    {
+      id: 2,
+      username: 'user1',
+      nickname: '用户1',
+      email: 'user1@example.com',
+      phone: '13900139000',
+      name: '张三',
+      isDisabled: 0,
+      createdAt: '2024-01-02T00:00:00.000Z',
+      roles: [{ id: 2, name: 'user' }]
+    }
+  ];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    // Mock successful API response - return a proper Response object
+    (userClient.$get as any).mockImplementation(async (params: any) => {
+      console.log('API called with params:', params);
+      return {
+        status: 200,
+        ok: true,
+        headers: new Headers({ 'content-type': 'application/json' }),
+        json: async () => ({
+          data: mockUsers,
+          pagination: {
+            total: 2,
+            current: 1,
+            pageSize: 10
+          }
+        })
+      };
+    });
+
+    console.log('Mock setup complete');
+  });
+
+  it('应该渲染用户列表页面', async () => {
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 检查页面标题
+    expect(screen.getByText('用户管理')).toBeInTheDocument();
+
+    // 检查创建用户按钮
+    expect(screen.getByText('创建用户')).toBeInTheDocument();
+
+    // 等待数据加载完成
+    await waitFor(() => {
+      expect(screen.getByText('admin')).toBeInTheDocument();
+      expect(screen.getByText('user1')).toBeInTheDocument();
+    }, { timeout: 10000 });
+
+    // 检查API是否被调用
+    expect(userClient.$get).toHaveBeenCalled();
+
+    // 检查用户总数显示
+    expect(screen.getByText(/共 2 位用户/)).toBeInTheDocument();
+  });
+
+  it('应该显示搜索框和过滤按钮', async () => {
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 等待数据加载完成
+    await waitFor(() => {
+      expect(screen.getByText('admin')).toBeInTheDocument();
+    }, { timeout: 10000 });
+
+    // 检查搜索框
+    expect(screen.getByPlaceholderText('搜索用户名、昵称或邮箱...')).toBeInTheDocument();
+
+    // 检查搜索按钮
+    expect(screen.getByText('搜索')).toBeInTheDocument();
+
+    // 检查高级筛选按钮
+    expect(screen.getByText('高级筛选')).toBeInTheDocument();
+  });
+
+  it('应该支持关键词搜索', async () => {
+    const user = userEvent.setup();
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 等待数据加载完成
+    await waitFor(() => {
+      expect(screen.getByText('admin')).toBeInTheDocument();
+    }, { timeout: 10000 });
+
+    // 在搜索框中输入关键词 - 使用paste来避免防抖中间状态
+    const searchInput = screen.getByPlaceholderText('搜索用户名、昵称或邮箱...');
+    await user.clear(searchInput);
+    await user.click(searchInput);
+    await user.paste('admin');
+
+    // 等待防抖完成(300ms + 缓冲时间)
+    await new Promise(resolve => setTimeout(resolve, 400));
+
+    // 点击搜索按钮
+    const searchButton = screen.getByText('搜索');
+    await user.click(searchButton);
+
+    // 验证API被调用正确的参数
+    const calls = (userClient.$get as any).mock.calls;
+    const lastCall = calls[calls.length - 1];
+
+    // 检查搜索参数
+    const queryParams = lastCall[0].query;
+    expect(queryParams.page).toBe(1);
+    expect(queryParams.pageSize).toBe(10);
+    expect(queryParams.keyword).toBe('admin');
+  });
+
+  it('应该显示高级筛选面板', async () => {
+    const user = userEvent.setup();
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 等待数据加载完成
+    await waitFor(() => {
+      expect(screen.getByText('admin')).toBeInTheDocument();
+    }, { timeout: 10000 });
+
+    // 点击高级筛选按钮
+    const filterButton = screen.getByText('高级筛选');
+    await user.click(filterButton);
+
+    // 检查筛选面板是否显示
+    expect(screen.getByText('用户状态')).toBeInTheDocument();
+    expect(screen.getByText('用户角色')).toBeInTheDocument();
+    // 使用更具体的查询来避免与表格标题冲突
+    expect(screen.getAllByText('创建时间')[0]).toBeInTheDocument();
+  });
+
+  it('应该显示加载骨架屏', async () => {
+    // 清除之前的mock
+    vi.clearAllMocks();
+
+    // 模拟延迟响应
+    (userClient.$get as any).mockImplementation(() =>
+      new Promise(resolve => setTimeout(() => resolve({
+        status: 200,
+        ok: true,
+        json: async () => ({
+          data: mockUsers,
+          pagination: { total: 2, current: 1, pageSize: 10 }
+        })
+      }), 100))
+    );
+
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 检查骨架屏是否显示
+    expect(screen.getByText('用户管理')).toBeInTheDocument();
+    expect(screen.getByText('创建用户')).toBeInTheDocument();
+
+    // 检查骨架屏元素
+    // 先检查所有元素来调试角色问题
+    const allElements = screen.getAllByRole('generic');
+    console.log('All elements with generic role:', allElements.length);
+
+    // 尝试查找骨架屏元素
+    const skeletons = screen.queryAllByRole('status');
+    console.log('Elements with status role:', skeletons.length);
+
+    // 如果找不到status角色,尝试通过data-slot查找
+    if (skeletons.length === 0) {
+      const skeletonElements = screen.queryAllByTestId('skeleton');
+      if (skeletonElements.length === 0) {
+        // 使用data-slot属性查找
+        const slotSkeletons = document.querySelectorAll('[data-slot="skeleton"]');
+        expect(slotSkeletons.length).toBeGreaterThan(0);
+      } else {
+        expect(skeletonElements.length).toBeGreaterThan(0);
+      }
+    } else {
+      expect(skeletons.length).toBeGreaterThan(0);
+    }
+
+    // 等待数据加载完成
+    await waitFor(() => {
+      expect(screen.getByText('admin')).toBeInTheDocument();
+    });
+
+    // 检查骨架屏已消失
+    const remainingSkeletons = screen.queryAllByRole('status');
+    if (remainingSkeletons.length === 0) {
+      // 也检查通过testid查找的骨架屏
+      const remainingTestidSkeletons = screen.queryAllByTestId('skeleton');
+      if (remainingTestidSkeletons.length === 0) {
+        // 检查通过data-slot查找的骨架屏
+        const remainingSlotSkeletons = document.querySelectorAll('[data-slot="skeleton"]');
+        expect(remainingSlotSkeletons).toHaveLength(0);
+      } else {
+        expect(remainingTestidSkeletons).toHaveLength(0);
+      }
+    } else {
+      expect(remainingSkeletons).toHaveLength(0);
+    }
+  });
+
+  it('应该处理API错误', async () => {
+    // 模拟API错误
+    (userClient.$get as any).mockResolvedValue({
+      status: 500,
+      ok: false,
+      json: async () => ({ error: 'Internal server error' })
+    });
+
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // 检查页面仍然渲染
+    expect(screen.getByText('用户管理')).toBeInTheDocument();
+    expect(screen.getByText('创建用户')).toBeInTheDocument();
+
+    // 等待加载完成(应该没有数据)
+    await waitFor(() => {
+      expect(screen.queryByText('admin')).not.toBeInTheDocument();
+      expect(screen.queryByText('user1')).not.toBeInTheDocument();
+    });
+  });
+
+  it('应该显示分页控件', async () => {
+    // 模拟多页数据
+    (userClient.$get as any).mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: mockUsers,
+        pagination: {
+          total: 25,
+          current: 1,
+          pageSize: 10
+        }
+      })
+    });
+
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    await waitFor(() => {
+      // 检查分页控件
+      expect(screen.getByText('1')).toBeInTheDocument();
+      expect(screen.getByText('2')).toBeInTheDocument();
+      expect(screen.getByText('3')).toBeInTheDocument();
+    });
+  });
+});

+ 61 - 0
src/client/admin/pages/__tests__/debug.test.tsx

@@ -0,0 +1,61 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { TestWrapper } from '@/client/__test_utils__/test-render';
+import { UsersPage } from '../Users';
+import { userClient } from '@/client/api';
+
+// Mock the API client
+vi.mock('@/client/api', () => ({
+  userClient: {
+    $get: vi.fn(),
+    $post: vi.fn(),
+    ':id': {
+      $put: vi.fn(),
+      $delete: vi.fn()
+    }
+  }
+}));
+
+describe('Debug Test', () => {
+  it('should mock the API client', async () => {
+    // Mock the API call
+    (userClient.$get as any).mockResolvedValue({
+      status: 200,
+      ok: true,
+      json: async () => ({
+        data: [
+          {
+            id: 1,
+            username: 'testuser',
+            nickname: '测试用户',
+            email: 'test@example.com',
+            phone: '13800138000',
+            name: '测试用户',
+            isDisabled: 0,
+            createdAt: '2024-01-01T00:00:00.000Z',
+            roles: [{ id: 1, name: 'admin' }]
+          }
+        ],
+        pagination: {
+          total: 1,
+          current: 1,
+          pageSize: 10
+        }
+      })
+    });
+
+    render(
+      <TestWrapper>
+        <UsersPage />
+      </TestWrapper>
+    );
+
+    // Check if the mock was called
+    expect(userClient.$get).toHaveBeenCalled();
+
+    // Wait for data to load
+    await screen.findByText('testuser');
+
+    expect(screen.getByText('testuser')).toBeInTheDocument();
+  });
+});

+ 4 - 3
src/server/__test_utils__/test-db.ts

@@ -5,15 +5,16 @@ import { vi, beforeEach, afterEach } from 'vitest';
  * 创建模拟的数据源
  */
 export function createMockDataSource() {
+  const manager = createMockEntityManager();
   const mockDataSource = {
     initialize: vi.fn().mockResolvedValue(undefined),
     destroy: vi.fn().mockResolvedValue(undefined),
     isInitialized: true,
-    manager: createMockEntityManager(),
+    manager,
     getRepository: vi.fn().mockImplementation(() => createMockRepository()),
     createQueryBuilder: vi.fn().mockReturnValue(createMockQueryBuilder()),
     transaction: vi.fn().mockImplementation(async (callback) => {
-      return callback(mockDataSource.manager);
+      return callback(manager);
     }),
     synchronize: vi.fn().mockResolvedValue(undefined),
     dropDatabase: vi.fn().mockResolvedValue(undefined)
@@ -34,7 +35,7 @@ export function createMockEntityManager(): EntityManager {
     delete: vi.fn().mockResolvedValue({ affected: 1 }),
     createQueryBuilder: vi.fn().mockReturnValue(createMockQueryBuilder()),
     transaction: vi.fn().mockImplementation(async (callback) => {
-      return callback(mockDataSource.manager);
+      return callback(createMockEntityManager());
     }),
     getRepository: vi.fn().mockImplementation(() => createMockRepository())
   } as any;

+ 1 - 2
src/server/api/__integration_tests__/users.integration.test.ts

@@ -40,7 +40,6 @@ vi.mock('../../../utils/generic-crud.service', () => ({
 describe('Users API Integration Tests', () => {
   let app: OpenAPIHono;
   let apiClient: ApiClient;
-  let mockDataSource: any;
 
   beforeEach(async () => {
     vi.clearAllMocks();
@@ -192,7 +191,7 @@ describe('Users API Integration Tests', () => {
     it('应该在无效令牌时返回401', async () => {
       // 模拟认证中间件验证失败
       const authMiddleware = require('../../middleware/auth.middleware').authMiddleware;
-      authMiddleware.mockImplementation((c, next) => {
+      authMiddleware.mockImplementation((c: any) => {
         return c.json({ error: 'Invalid token' }, 401);
       });
 

+ 7 - 1
tests/e2e/specs/users/user-crud.spec.ts

@@ -1,5 +1,11 @@
 import { test, expect } from '../../utils/test-setup';
-import testUsers from '../../fixtures/test-users.json' with { type: 'json' };
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const testUsers = JSON.parse(readFileSync(join(__dirname, '../../fixtures/test-users.json'), 'utf-8'));
 
 test.describe('用户管理CRUD操作', () => {
   test.beforeEach(async ({ loginPage, userManagementPage }) => {

+ 3 - 1
vitest.config.components.ts

@@ -9,7 +9,9 @@ export default defineConfig({
     // 测试文件匹配模式
     include: [
       'src/client/__integration_tests__/**/*.test.{js,ts,jsx,tsx}',
-      'src/client/__tests__/**/*.test.{js,ts,jsx,tsx}'
+      'src/client/__tests__/**/*.test.{js,ts,jsx,tsx}',
+      'src/client/**/__tests__/**/*.test.{js,ts,jsx,tsx}',
+      'src/client/**/*.test.{js,ts,jsx,tsx}'
     ],
 
     // 排除模式