// 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('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; const mockedUpdate = updateUser as unknown as ReturnType; const mockedDetail = getUserDetail as unknown as ReturnType; const mockedEmployees = listEmployees as unknown as ReturnType; const mockedPermissions = listPermissions as unknown as ReturnType; 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 { 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 { return { sUserName: 'zhangsan', sUserNo: 'zs', iEmployeeId: 3, sUserType: '普通用户', sLanguage: '中文', iCanModifyBill: 0, iIsVoid: 0, ...over, }; } function wrapper({ children }: { children: ReactNode }) { return ( {children} ); } 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); }); });