useUserDetail.test.tsx 9.34 KB
// REQ-USR-001 / REQ-USR-002: useUserDetail 单据 hook 状态机单测
// initialLoading/editing/submitting/submitError/submitSuccess/loadError + 员工联动 + 权限回勾
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 其余真实导出
const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: 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 }) }),
  };
});

// 桩 usrApi 单据方法
vi.mock('../../src/api/usrApi', () => ({
  createUser: vi.fn(),
  updateUser: vi.fn(),
  getUserDetail: vi.fn(),
  listEmployees: vi.fn(),
  listPermissions: vi.fn(),
}));

import {
  createUser,
  updateUser,
  getUserDetail,
  listEmployees,
  listPermissions,
} from '../../src/api/usrApi';
import { useUserDetail } from '../../src/pages/usr/UserDetail/useUserDetail';
import { ApiError } from '../../src/api/request';
import {
  CREATE_DEFAULTS,
  ERR_USERNAME_EXISTS,
  ERR_USER_NOT_FOUND,
  ERR_NO_PERMISSION,
  ERR_VALIDATION,
  MSG_ERR_LOAD_PERMISSIONS,
  type UserFormValues,
} from '../../src/pages/usr/UserDetail/constants';
import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types';

const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>;
const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>;
const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>;
const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>;
const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>;

const EMPLOYEES: EmployeeOption[] = [
  { value: 3, label: '张三', sEmployeeNo: 'zs' },
  { value: 4, label: '李四', sEmployeeNo: 'ls' },
];
const PERMISSIONS: PermissionItem[] = [
  { id: 1, name: '默认显示', category: '基础' },
  { id: 2, name: '高级查看', category: '基础' },
];

function makeVo(over: Partial<UserVO> = {}): UserVO {
  return {
    id: 7,
    sUserName: 'zhangsan',
    employeeName: '张三',
    sUserNo: 'zs',
    departmentName: null,
    sUserType: '超级管理员',
    sLanguage: '英文',
    iIsVoid: 0,
    tLastLoginDate: null,
    sCreator: 'admin',
    tCreateDate: '2026-01-01T00:00:00',
    ...over,
  };
}

function makeValues(over: Partial<UserFormValues> = {}): UserFormValues {
  return {
    sUserName: 'zhangsan',
    sUserNo: 'zs',
    iEmployeeId: 3,
    sUserType: '普通用户',
    sLanguage: '中文',
    iCanModifyBill: 0,
    iIsVoid: 0,
    ...over,
  };
}

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

describe('useUserDetail 状态机', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockedEmployees.mockResolvedValue(EMPLOYEES);
    mockedPermissions.mockResolvedValue(PERMISSIONS);
  });

  it('create mode initial load prefetches employees+permissions (initialLoading→editing)', async () => {
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    expect(result.current.loading).toBe(true);
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(mockedEmployees).toHaveBeenCalled();
    expect(mockedPermissions).toHaveBeenCalled();
    expect(result.current.employees).toEqual(EMPLOYEES);
    expect(result.current.permissions).toEqual(PERMISSIONS);
    expect(result.current.formValues.sUserType).toBe(CREATE_DEFAULTS.sUserType);
    expect(result.current.formValues.iCanModifyBill).toBe(0);
    expect(result.current.checkedPermissionIds).toEqual([]);
    expect(mockedDetail).not.toHaveBeenCalled();
  });

  it('edit mode without presetUser sets loadFailed without calling getUserDetail', async () => {
    // FE-04 B1 fix: 路由 :id 为用户主键,无 by-id 读端点(docs/05 REQ-USR-003),
    // 不能按主键查列表端点;缺 presetUser(直接访问 URL / 刷新丢 state)时按 loadError 处理,
    // 由页面给出「返回列表」恢复入口。
    const { result } = renderHook(
      () => useUserDetail({ mode: 'edit', userId: 7 }),
      { wrapper },
    );
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.loadFailed).toBe(true);
    expect(mockedDetail).not.toHaveBeenCalled();
  });

  it('edit mode with presetUser skips getUserDetail', async () => {
    const { result } = renderHook(
      () => useUserDetail({ mode: 'edit', userId: 7, presetUser: makeVo() }),
      { wrapper },
    );
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(mockedDetail).not.toHaveBeenCalled();
    expect(result.current.formValues.sUserName).toBe('zhangsan');
  });

  it('selectEmployee fills userNo/userName from employee (create)', async () => {
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));
    act(() => {
      result.current.selectEmployee(3);
    });
    expect(result.current.formValues.iEmployeeId).toBe(3);
    expect(result.current.formValues.sUserName).toBe('张三');
    expect(result.current.formValues.sUserNo).toBe('zs');
  });

  it('toggle permission and toggleAll update checkedPermissionIds', async () => {
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));
    act(() => {
      result.current.togglePermission(1, true);
    });
    expect(result.current.checkedPermissionIds).toContain(1);
    act(() => {
      result.current.toggleAll(true);
    });
    expect(result.current.checkedPermissionIds.sort()).toEqual([1, 2]);
    act(() => {
      result.current.toggleAll(false);
    });
    expect(result.current.checkedPermissionIds).toEqual([]);
  });

  it('submit create calls createUser and returns {ok,id}', async () => {
    mockedCreate.mockResolvedValue({ id: 9 });
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));
    let ret: { ok: boolean; id?: number } | undefined;
    await act(async () => {
      ret = await result.current.submit(makeValues());
    });
    expect(mockedCreate).toHaveBeenCalledTimes(1);
    expect(ret).toMatchObject({ ok: true, id: 9 });
    expect(result.current.submitting).toBe(false);
  });

  it('submit edit calls updateUser with userId and full permissionIds', async () => {
    mockedDetail.mockResolvedValue(makeVo());
    mockedUpdate.mockResolvedValue({ id: 7 });
    const { result } = renderHook(
      () => useUserDetail({ mode: 'edit', userId: 7 }),
      { wrapper },
    );
    await waitFor(() => expect(result.current.loading).toBe(false));
    act(() => {
      result.current.togglePermission(2, true);
    });
    let ret: { ok: boolean; id?: number } | undefined;
    await act(async () => {
      ret = await result.current.submit(makeValues());
    });
    expect(mockedUpdate).toHaveBeenCalledTimes(1);
    const [id, body] = mockedUpdate.mock.calls[0];
    expect(id).toBe(7);
    expect(body.permissionIds).toContain(2);
    expect(body).not.toHaveProperty('sUserName');
    expect(ret).toMatchObject({ ok: true, id: 7 });
  });

  it('submit 40901 returns fieldError on sUserName', async () => {
    mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup'));
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));
    let ret: { ok: boolean; fieldError?: { field: string; message: string } } | undefined;
    await act(async () => {
      ret = await result.current.submit(makeValues());
    });
    expect(ret?.ok).toBe(false);
    expect(ret?.fieldError?.field).toBe('sUserName');
    expect(result.current.submitting).toBe(false);
  });

  it('submit 40401/40301/40001/network show message and return ok:false', async () => {
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loading).toBe(false));

    for (const code of [ERR_USER_NOT_FOUND, ERR_NO_PERMISSION, ERR_VALIDATION, -1]) {
      messageSpy.error.mockClear();
      mockedCreate.mockRejectedValueOnce(new ApiError(code, 'e'));
      let ret: { ok: boolean } | undefined;
      await act(async () => {
        ret = await result.current.submit(makeValues());
      });
      expect(ret?.ok).toBe(false);
      expect(messageSpy.error).toHaveBeenCalled();
    }
  });

  it('loadError when prefetch fails sets loadFailed and message; reload clears it', async () => {
    mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net'));
    const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
    await waitFor(() => expect(result.current.loadFailed).toBe(true));
    expect(messageSpy.error).toHaveBeenCalledWith(MSG_ERR_LOAD_PERMISSIONS);

    mockedPermissions.mockResolvedValue(PERMISSIONS);
    act(() => {
      result.current.reload();
    });
    await waitFor(() => expect(result.current.loadFailed).toBe(false));
    expect(result.current.permissions).toEqual(PERMISSIONS);
  });
});