useUserList.test.tsx 10.1 KB
// REQ-USR-003: useUserList 列表查询 hook 状态机单测
// 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ReactNode } from 'react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { App as AntdApp, ConfigProvider } from 'antd';

// 桩 message:保留 antd 其余真实导出,仅覆盖 App.useApp 返回的 message
const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() };
vi.mock('antd', async () => {
  const actual = await vi.importActual<typeof import('antd')>('antd');
  return {
    ...actual,
    App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }),
  };
});

// 桩 listUsers
vi.mock('../../src/api/usrApi', () => ({
  listUsers: vi.fn(),
}));

// 桩 CSV 下载(不真正触发 jsdom 下载)
vi.mock('../../src/pages/usr/UserList/exportUtils', async () => {
  const actual = await vi.importActual<
    typeof import('../../src/pages/usr/UserList/exportUtils')
  >('../../src/pages/usr/UserList/exportUtils');
  return { ...actual, downloadCsv: vi.fn() };
});

import { listUsers } from '../../src/api/usrApi';
import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils';
import { useUserList } from '../../src/pages/usr/UserList/useUserList';
import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants';
import { ApiError } from '../../src/api/request';
import type { UserVO } from '../../src/api/types';

const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>;
const mockedDownload = downloadCsv as unknown as ReturnType<typeof vi.fn>;

function makeUser(id: number, name = `u${id}`): UserVO {
  return {
    id,
    sUserName: name,
    employeeName: null,
    sUserNo: null,
    departmentName: null,
    sUserType: '普通用户',
    sLanguage: '中文',
    iIsVoid: 0,
    tLastLoginDate: null,
    sCreator: 'admin',
    tCreateDate: '2024-01-01T00:00:00',
  };
}

function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) {
  return { records, total, pageNum, pageSize };
}

function wrapper({ children }: { children: ReactNode }) {
  return (
    <ConfigProvider>
      <AntdApp>{children}</AntdApp>
    </ConfigProvider>
  );
}

function lastCallQuery() {
  const calls = mockedList.mock.calls;
  return calls[calls.length - 1][0];
}

describe('useUserList 状态机', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('mounts with default query and loads first page (initialLoading→success)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    // 挂载即以默认 query 调 listUsers
    await waitFor(() => expect(mockedList).toHaveBeenCalled());
    expect(mockedList.mock.calls[0][0]).toMatchObject({
      queryField: '用户名',
      matchType: '包含',
      pageNum: 1,
      pageSize: 10,
    });
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.list).toHaveLength(2);
    expect(result.current.total).toBe(2);
    expect(result.current.query.pageNum).toBe(1);
  });

  it('empty records sets empty state without error', async () => {
    mockedList.mockResolvedValue(page([], 0, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.list).toEqual([]);
    expect(result.current.total).toBe(0);
    expect(result.current.error).toBeNull();
    expect(messageSpy.error).not.toHaveBeenCalled();
  });

  it('search resets to page 1 and refetches with current filters (BR7)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.setQueryField('员工名');
      result.current.setQueryValue('李');
    });
    act(() => {
      result.current.search();
    });
    await waitFor(() => {
      const q = lastCallQuery();
      expect(q.queryField).toBe('员工名');
      expect(q.queryValue).toBe('李');
      expect(q.pageNum).toBe(1);
    });
  });

  it('refresh keeps current query and page (BR8)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.changePage(2, 10);
    });
    await waitFor(() => expect(result.current.query.pageNum).toBe(2));
    mockedList.mockClear();

    act(() => {
      result.current.refresh();
    });
    await waitFor(() => expect(mockedList).toHaveBeenCalled());
    expect(lastCallQuery().pageNum).toBe(2);
  });

  it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.setQueryField('部门');
      result.current.setQueryValue('技术');
    });
    act(() => {
      result.current.clear();
    });
    await waitFor(() => {
      expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField);
      expect(result.current.query.queryValue).toBe('');
      expect(result.current.query.pageNum).toBe(1);
    });
    expect(lastCallQuery().queryField).toBe('用户名');
  });

  it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.changePage(3, 10);
    });
    await waitFor(() => expect(lastCallQuery().pageNum).toBe(3));

    // 改 pageSize(10→50)应回第 1 页
    act(() => {
      result.current.changePage(3, 50);
    });
    await waitFor(() => {
      const q = lastCallQuery();
      expect(q.pageSize).toBe(50);
      expect(q.pageNum).toBe(1);
    });
  });

  it('ApiError 40001 keeps filters and shows error, sets error state', async () => {
    // 首次挂载成功,之后搜索失败 40001
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.setQueryValue('张');
    });
    mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败'));
    act(() => {
      result.current.search();
    });
    await waitFor(() => expect(result.current.error).not.toBeNull());
    expect(result.current.error?.code).toBe(40001);
    expect(result.current.query.queryValue).toBe('张'); // 条件保留
    expect(messageSpy.error).toHaveBeenCalled();
    // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2
    expect(mockedList).toHaveBeenCalledTimes(2);
  });

  it('ApiError 42201 warns and refetches at page 1', async () => {
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查
    mockedList.mockClear();
    mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法'));
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
    act(() => {
      result.current.changePage(3, 10);
    });
    await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled());
    await waitFor(() => expect(result.current.query.pageNum).toBe(1));
    // 重查发生
    expect(lastCallQuery().pageNum).toBe(1);
  });

  it('network error (code -1) sets error state', async () => {
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常'));
    act(() => {
      result.current.refresh();
    });
    await waitFor(() => expect(result.current.error).not.toBeNull());
    expect(result.current.error?.code).toBe(-1);
    expect(messageSpy.error).toHaveBeenCalled();
  });

  it('response pageNum echo syncs pagination (BR15)', async () => {
    // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10));
    act(() => {
      result.current.changePage(99, 10);
    });
    await waitFor(() => expect(result.current.query.pageNum).toBe(5));
    expect(result.current.total).toBe(50);
  });

  it('exportExcel toggles exporting and downloads (BR9)', async () => {
    mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    await act(async () => {
      await result.current.exportExcel();
    });
    expect(mockedDownload).toHaveBeenCalledTimes(1);
    expect(result.current.exporting).toBe(false);
    expect(messageSpy.success).toHaveBeenCalledWith('导出成功');
  });

  it('exportExcel failure shows 导出失败', async () => {
    mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
    const { result } = renderHook(() => useUserList(), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常'));
    await act(async () => {
      await result.current.exportExcel();
    });
    expect(messageSpy.error).toHaveBeenCalledWith('导出失败');
    expect(result.current.exporting).toBe(false);
  });
});