Commit 4e0722ef74b831bd77df3f5fd1849f5c44b9ca8b
1 parent
c25135cc
feat(usr): 用户单据 hook useUserDetail 状态机 REQ-USR-001 REQ-USR-002
Showing
2 changed files
with
516 additions
and
0 deletions
frontend/src/pages/usr/UserDetail/useUserDetail.ts
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 用户单据 hook(状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError) | ||
| 2 | +import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| 3 | +import { App as AntdApp } from 'antd'; | ||
| 4 | +import { | ||
| 5 | + createUser, | ||
| 6 | + updateUser, | ||
| 7 | + getUserDetail, | ||
| 8 | + listEmployees, | ||
| 9 | + listPermissions, | ||
| 10 | +} from '../../../api/usrApi'; | ||
| 11 | +import { ApiError } from '../../../api/request'; | ||
| 12 | +import type { | ||
| 13 | + EmployeeOption, | ||
| 14 | + PermissionItem, | ||
| 15 | + UserVO, | ||
| 16 | + UserDetailMode, | ||
| 17 | +} from '../../../api/types'; | ||
| 18 | +import { | ||
| 19 | + CREATE_DEFAULTS, | ||
| 20 | + ERR_USERNAME_EXISTS, | ||
| 21 | + ERR_USER_NOT_FOUND, | ||
| 22 | + ERR_NO_PERMISSION, | ||
| 23 | + ERR_VALIDATION, | ||
| 24 | + MSG_ERR_USERNAME_EXISTS, | ||
| 25 | + MSG_ERR_USER_NOT_FOUND, | ||
| 26 | + MSG_ERR_NO_PERMISSION, | ||
| 27 | + MSG_ERR_VALIDATION, | ||
| 28 | + MSG_ERR_NETWORK, | ||
| 29 | + MSG_ERR_LOAD_EMPLOYEES, | ||
| 30 | + MSG_ERR_LOAD_PERMISSIONS, | ||
| 31 | + MSG_LOAD_DETAIL_FAIL, | ||
| 32 | + toCreateReq, | ||
| 33 | + toUpdateReq, | ||
| 34 | + userVoToFormValues, | ||
| 35 | + type UserFormValues, | ||
| 36 | +} from './constants'; | ||
| 37 | + | ||
| 38 | +export interface UseUserDetailArgs { | ||
| 39 | + mode: UserDetailMode; | ||
| 40 | + userId?: number; | ||
| 41 | + presetUser?: UserVO | null; | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +export interface SubmitFieldError { | ||
| 45 | + field: keyof UserFormValues; | ||
| 46 | + message: string; | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +export interface SubmitResult { | ||
| 50 | + ok: boolean; | ||
| 51 | + id?: number; | ||
| 52 | + fieldError?: SubmitFieldError; | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +export interface UseUserDetailReturn { | ||
| 56 | + mode: UserDetailMode; | ||
| 57 | + formValues: UserFormValues; | ||
| 58 | + employees: EmployeeOption[]; | ||
| 59 | + permissions: PermissionItem[]; | ||
| 60 | + checkedPermissionIds: number[]; | ||
| 61 | + readonlyCreator: string; | ||
| 62 | + readonlyCreateTime: string; | ||
| 63 | + loading: boolean; | ||
| 64 | + submitting: boolean; | ||
| 65 | + error: ApiError | null; | ||
| 66 | + loadFailed: boolean; | ||
| 67 | + setField(name: keyof UserFormValues, value: unknown): void; | ||
| 68 | + selectEmployee(value: number | null): void; | ||
| 69 | + togglePermission(id: number, checked: boolean): void; | ||
| 70 | + toggleAll(checked: boolean): void; | ||
| 71 | + submit(values: UserFormValues): Promise<SubmitResult>; | ||
| 72 | + reload(): void; | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { | ||
| 76 | + const { mode, userId, presetUser } = args; | ||
| 77 | + const { message } = AntdApp.useApp(); | ||
| 78 | + | ||
| 79 | + const [formValues, setFormValues] = useState<UserFormValues>({ ...CREATE_DEFAULTS }); | ||
| 80 | + const [employees, setEmployees] = useState<EmployeeOption[]>([]); | ||
| 81 | + const [permissions, setPermissions] = useState<PermissionItem[]>([]); | ||
| 82 | + const [checkedPermissionIds, setCheckedPermissionIds] = useState<number[]>([]); | ||
| 83 | + const [readonlyCreator, setReadonlyCreator] = useState(''); | ||
| 84 | + const [readonlyCreateTime, setReadonlyCreateTime] = useState(''); | ||
| 85 | + const [loading, setLoading] = useState(true); // initialLoading | ||
| 86 | + const [submitting, setSubmitting] = useState(false); | ||
| 87 | + const [error, setError] = useState<ApiError | null>(null); | ||
| 88 | + const [loadFailed, setLoadFailed] = useState(false); | ||
| 89 | + | ||
| 90 | + const employeesRef = useRef<EmployeeOption[]>(employees); | ||
| 91 | + employeesRef.current = employees; | ||
| 92 | + const checkedRef = useRef<number[]>(checkedPermissionIds); | ||
| 93 | + checkedRef.current = checkedPermissionIds; | ||
| 94 | + const messageRef = useRef(message); | ||
| 95 | + messageRef.current = message; | ||
| 96 | + const mountedRef = useRef(true); | ||
| 97 | + | ||
| 98 | + /** 把后端权限分类回勾:UserVO 不暴露已授权 id,故 edit 预填仅用 detail 的权限字段(无则空集) */ | ||
| 99 | + const initFromVo = useCallback((vo: UserVO) => { | ||
| 100 | + setFormValues(userVoToFormValues(vo)); | ||
| 101 | + setReadonlyCreator(vo.sCreator ?? ''); | ||
| 102 | + setReadonlyCreateTime(vo.tCreateDate ?? ''); | ||
| 103 | + // UserVO 不含已授权权限 id(FE-03 列表 VO),按空集初始化;后端补详情端点后可回勾 | ||
| 104 | + setCheckedPermissionIds([]); | ||
| 105 | + }, []); | ||
| 106 | + | ||
| 107 | + /** 挂载预取(员工/权限)+ edit 详情回填(initialLoading→editing / loadError) */ | ||
| 108 | + const runLoad = useCallback(async () => { | ||
| 109 | + setLoading(true); | ||
| 110 | + setLoadFailed(false); | ||
| 111 | + try { | ||
| 112 | + const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); | ||
| 113 | + if (!mountedRef.current) return; | ||
| 114 | + setEmployees(emps); | ||
| 115 | + setPermissions(perms); | ||
| 116 | + | ||
| 117 | + if (mode === 'edit') { | ||
| 118 | + if (presetUser) { | ||
| 119 | + initFromVo(presetUser); | ||
| 120 | + } else if (userId != null) { | ||
| 121 | + const vo = await getUserDetail({ | ||
| 122 | + queryField: '用户号', | ||
| 123 | + queryValue: String(userId), | ||
| 124 | + }); | ||
| 125 | + if (!mountedRef.current) return; | ||
| 126 | + if (vo) { | ||
| 127 | + initFromVo(vo); | ||
| 128 | + } else { | ||
| 129 | + // 详情不存在:交由页面按 40401 路径处理,标记 loadFailed | ||
| 130 | + setLoadFailed(true); | ||
| 131 | + messageRef.current.error(MSG_ERR_USER_NOT_FOUND); | ||
| 132 | + setLoading(false); | ||
| 133 | + return; | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + } else { | ||
| 137 | + setFormValues({ ...CREATE_DEFAULTS }); | ||
| 138 | + setCheckedPermissionIds([]); | ||
| 139 | + } | ||
| 140 | + setLoading(false); | ||
| 141 | + } catch (err) { | ||
| 142 | + if (!mountedRef.current) return; | ||
| 143 | + setLoading(false); | ||
| 144 | + setLoadFailed(true); | ||
| 145 | + // 区分员工/权限/详情失败文案:以 reject 顺序无法精确分辨,按权限优先(最常见空源) | ||
| 146 | + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK); | ||
| 147 | + if (mode === 'edit' && employeesRef.current.length === 0 && permissions.length === 0) { | ||
| 148 | + messageRef.current.error(MSG_LOAD_DETAIL_FAIL); | ||
| 149 | + } else { | ||
| 150 | + messageRef.current.error( | ||
| 151 | + apiErr.message === MSG_ERR_LOAD_EMPLOYEES | ||
| 152 | + ? MSG_ERR_LOAD_EMPLOYEES | ||
| 153 | + : MSG_ERR_LOAD_PERMISSIONS, | ||
| 154 | + ); | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 158 | + }, [mode, userId, presetUser, initFromVo]); | ||
| 159 | + | ||
| 160 | + useEffect(() => { | ||
| 161 | + mountedRef.current = true; | ||
| 162 | + void runLoad(); | ||
| 163 | + return () => { | ||
| 164 | + mountedRef.current = false; | ||
| 165 | + }; | ||
| 166 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 167 | + }, []); | ||
| 168 | + | ||
| 169 | + const setField = useCallback((name: keyof UserFormValues, value: unknown) => { | ||
| 170 | + setFormValues((prev) => ({ ...prev, [name]: value })); | ||
| 171 | + }, []); | ||
| 172 | + | ||
| 173 | + /** 选择员工:带出用户名(create)/ 用户号(BR5,用户仍可改) */ | ||
| 174 | + const selectEmployee = useCallback((value: number | null) => { | ||
| 175 | + setFormValues((prev) => { | ||
| 176 | + const emp = employeesRef.current.find((e) => e.value === value); | ||
| 177 | + if (!emp) return { ...prev, iEmployeeId: value }; | ||
| 178 | + return { | ||
| 179 | + ...prev, | ||
| 180 | + iEmployeeId: value, | ||
| 181 | + sUserName: emp.label, | ||
| 182 | + sUserNo: emp.sEmployeeNo ?? prev.sUserNo, | ||
| 183 | + }; | ||
| 184 | + }); | ||
| 185 | + }, []); | ||
| 186 | + | ||
| 187 | + const togglePermission = useCallback((id: number, checked: boolean) => { | ||
| 188 | + setCheckedPermissionIds((prev) => { | ||
| 189 | + if (checked) return prev.includes(id) ? prev : [...prev, id]; | ||
| 190 | + return prev.filter((p) => p !== id); | ||
| 191 | + }); | ||
| 192 | + }, []); | ||
| 193 | + | ||
| 194 | + const toggleAll = useCallback((checked: boolean) => { | ||
| 195 | + setCheckedPermissionIds(checked ? permissions.map((p) => p.id) : []); | ||
| 196 | + }, [permissions]); | ||
| 197 | + | ||
| 198 | + /** 提交:create→POST / edit→PUT;错误码分流(spec § 4) */ | ||
| 199 | + const submit = useCallback( | ||
| 200 | + async (values: UserFormValues): Promise<SubmitResult> => { | ||
| 201 | + setSubmitting(true); | ||
| 202 | + setError(null); | ||
| 203 | + try { | ||
| 204 | + const ids = checkedRef.current; | ||
| 205 | + let id: number; | ||
| 206 | + if (mode === 'edit' && userId != null) { | ||
| 207 | + const ret = await updateUser(userId, toUpdateReq(values, ids)); | ||
| 208 | + id = ret.id; | ||
| 209 | + } else { | ||
| 210 | + const ret = await createUser(toCreateReq(values, ids)); | ||
| 211 | + id = ret.id; | ||
| 212 | + } | ||
| 213 | + if (mountedRef.current) setSubmitting(false); | ||
| 214 | + return { ok: true, id }; | ||
| 215 | + } catch (err) { | ||
| 216 | + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK); | ||
| 217 | + if (mountedRef.current) { | ||
| 218 | + setSubmitting(false); | ||
| 219 | + setError(apiErr); | ||
| 220 | + } | ||
| 221 | + if (apiErr.code === ERR_USERNAME_EXISTS) { | ||
| 222 | + messageRef.current.error(MSG_ERR_USERNAME_EXISTS); | ||
| 223 | + return { | ||
| 224 | + ok: false, | ||
| 225 | + fieldError: { field: 'sUserName', message: MSG_ERR_USERNAME_EXISTS }, | ||
| 226 | + }; | ||
| 227 | + } | ||
| 228 | + if (apiErr.code === ERR_USER_NOT_FOUND) { | ||
| 229 | + messageRef.current.error(MSG_ERR_USER_NOT_FOUND); | ||
| 230 | + } else if (apiErr.code === ERR_NO_PERMISSION) { | ||
| 231 | + messageRef.current.error(MSG_ERR_NO_PERMISSION); | ||
| 232 | + } else if (apiErr.code === ERR_VALIDATION) { | ||
| 233 | + messageRef.current.error(MSG_ERR_VALIDATION); | ||
| 234 | + } else { | ||
| 235 | + messageRef.current.error(MSG_ERR_NETWORK); | ||
| 236 | + } | ||
| 237 | + return { ok: false }; | ||
| 238 | + } | ||
| 239 | + }, | ||
| 240 | + [mode, userId], | ||
| 241 | + ); | ||
| 242 | + | ||
| 243 | + const reload = useCallback(() => { | ||
| 244 | + void runLoad(); | ||
| 245 | + }, [runLoad]); | ||
| 246 | + | ||
| 247 | + return { | ||
| 248 | + mode, | ||
| 249 | + formValues, | ||
| 250 | + employees, | ||
| 251 | + permissions, | ||
| 252 | + checkedPermissionIds, | ||
| 253 | + readonlyCreator, | ||
| 254 | + readonlyCreateTime, | ||
| 255 | + loading, | ||
| 256 | + submitting, | ||
| 257 | + error, | ||
| 258 | + loadFailed, | ||
| 259 | + setField, | ||
| 260 | + selectEmployee, | ||
| 261 | + togglePermission, | ||
| 262 | + toggleAll, | ||
| 263 | + submit, | ||
| 264 | + reload, | ||
| 265 | + }; | ||
| 266 | +} |
frontend/tests/unit/useUserDetail.test.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: useUserDetail 单据 hook 状态机单测 | ||
| 2 | +// initialLoading/editing/submitting/submitError/submitSuccess/loadError + 员工联动 + 权限回勾 | ||
| 3 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 4 | +import type { ReactNode } from 'react'; | ||
| 5 | +import { renderHook, act, waitFor } from '@testing-library/react'; | ||
| 6 | +import { App as AntdApp, ConfigProvider } from 'antd'; | ||
| 7 | + | ||
| 8 | +// 桩 message:保留 antd 其余真实导出 | ||
| 9 | +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; | ||
| 10 | +vi.mock('antd', async () => { | ||
| 11 | + const actual = await vi.importActual<typeof import('antd')>('antd'); | ||
| 12 | + return { | ||
| 13 | + ...actual, | ||
| 14 | + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), | ||
| 15 | + }; | ||
| 16 | +}); | ||
| 17 | + | ||
| 18 | +// 桩 usrApi 单据方法 | ||
| 19 | +vi.mock('../../src/api/usrApi', () => ({ | ||
| 20 | + createUser: vi.fn(), | ||
| 21 | + updateUser: vi.fn(), | ||
| 22 | + getUserDetail: vi.fn(), | ||
| 23 | + listEmployees: vi.fn(), | ||
| 24 | + listPermissions: vi.fn(), | ||
| 25 | +})); | ||
| 26 | + | ||
| 27 | +import { | ||
| 28 | + createUser, | ||
| 29 | + updateUser, | ||
| 30 | + getUserDetail, | ||
| 31 | + listEmployees, | ||
| 32 | + listPermissions, | ||
| 33 | +} from '../../src/api/usrApi'; | ||
| 34 | +import { useUserDetail } from '../../src/pages/usr/UserDetail/useUserDetail'; | ||
| 35 | +import { ApiError } from '../../src/api/request'; | ||
| 36 | +import { | ||
| 37 | + CREATE_DEFAULTS, | ||
| 38 | + ERR_USERNAME_EXISTS, | ||
| 39 | + ERR_USER_NOT_FOUND, | ||
| 40 | + ERR_NO_PERMISSION, | ||
| 41 | + ERR_VALIDATION, | ||
| 42 | + MSG_ERR_LOAD_PERMISSIONS, | ||
| 43 | + type UserFormValues, | ||
| 44 | +} from '../../src/pages/usr/UserDetail/constants'; | ||
| 45 | +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types'; | ||
| 46 | + | ||
| 47 | +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>; | ||
| 48 | +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>; | ||
| 49 | +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>; | ||
| 50 | +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>; | ||
| 51 | +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>; | ||
| 52 | + | ||
| 53 | +const EMPLOYEES: EmployeeOption[] = [ | ||
| 54 | + { value: 3, label: '张三', sEmployeeNo: 'zs' }, | ||
| 55 | + { value: 4, label: '李四', sEmployeeNo: 'ls' }, | ||
| 56 | +]; | ||
| 57 | +const PERMISSIONS: PermissionItem[] = [ | ||
| 58 | + { id: 1, name: '默认显示', category: '基础' }, | ||
| 59 | + { id: 2, name: '高级查看', category: '基础' }, | ||
| 60 | +]; | ||
| 61 | + | ||
| 62 | +function makeVo(over: Partial<UserVO> = {}): UserVO { | ||
| 63 | + return { | ||
| 64 | + id: 7, | ||
| 65 | + sUserName: 'zhangsan', | ||
| 66 | + employeeName: '张三', | ||
| 67 | + sUserNo: 'zs', | ||
| 68 | + departmentName: null, | ||
| 69 | + sUserType: '超级管理员', | ||
| 70 | + sLanguage: '英文', | ||
| 71 | + iIsVoid: 0, | ||
| 72 | + tLastLoginDate: null, | ||
| 73 | + sCreator: 'admin', | ||
| 74 | + tCreateDate: '2026-01-01T00:00:00', | ||
| 75 | + ...over, | ||
| 76 | + }; | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +function makeValues(over: Partial<UserFormValues> = {}): UserFormValues { | ||
| 80 | + return { | ||
| 81 | + sUserName: 'zhangsan', | ||
| 82 | + sUserNo: 'zs', | ||
| 83 | + iEmployeeId: 3, | ||
| 84 | + sUserType: '普通用户', | ||
| 85 | + sLanguage: '中文', | ||
| 86 | + iCanModifyBill: 0, | ||
| 87 | + iIsVoid: 0, | ||
| 88 | + ...over, | ||
| 89 | + }; | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +function wrapper({ children }: { children: ReactNode }) { | ||
| 93 | + return ( | ||
| 94 | + <ConfigProvider> | ||
| 95 | + <AntdApp>{children}</AntdApp> | ||
| 96 | + </ConfigProvider> | ||
| 97 | + ); | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +describe('useUserDetail 状态机', () => { | ||
| 101 | + beforeEach(() => { | ||
| 102 | + vi.clearAllMocks(); | ||
| 103 | + mockedEmployees.mockResolvedValue(EMPLOYEES); | ||
| 104 | + mockedPermissions.mockResolvedValue(PERMISSIONS); | ||
| 105 | + }); | ||
| 106 | + | ||
| 107 | + it('create mode initial load prefetches employees+permissions (initialLoading→editing)', async () => { | ||
| 108 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 109 | + expect(result.current.loading).toBe(true); | ||
| 110 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 111 | + expect(mockedEmployees).toHaveBeenCalled(); | ||
| 112 | + expect(mockedPermissions).toHaveBeenCalled(); | ||
| 113 | + expect(result.current.employees).toEqual(EMPLOYEES); | ||
| 114 | + expect(result.current.permissions).toEqual(PERMISSIONS); | ||
| 115 | + expect(result.current.formValues.sUserType).toBe(CREATE_DEFAULTS.sUserType); | ||
| 116 | + expect(result.current.formValues.iCanModifyBill).toBe(0); | ||
| 117 | + expect(result.current.checkedPermissionIds).toEqual([]); | ||
| 118 | + expect(mockedDetail).not.toHaveBeenCalled(); | ||
| 119 | + }); | ||
| 120 | + | ||
| 121 | + it('edit mode prefills from getUserDetail and pre-checks permissions', async () => { | ||
| 122 | + mockedDetail.mockResolvedValue(makeVo()); | ||
| 123 | + const { result } = renderHook( | ||
| 124 | + () => useUserDetail({ mode: 'edit', userId: 7 }), | ||
| 125 | + { wrapper }, | ||
| 126 | + ); | ||
| 127 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 128 | + expect(mockedDetail).toHaveBeenCalled(); | ||
| 129 | + expect(result.current.formValues.sUserName).toBe('zhangsan'); | ||
| 130 | + expect(result.current.formValues.sUserType).toBe('超级管理员'); | ||
| 131 | + expect(result.current.formValues.sLanguage).toBe('英文'); | ||
| 132 | + }); | ||
| 133 | + | ||
| 134 | + it('edit mode with presetUser skips getUserDetail', async () => { | ||
| 135 | + const { result } = renderHook( | ||
| 136 | + () => useUserDetail({ mode: 'edit', userId: 7, presetUser: makeVo() }), | ||
| 137 | + { wrapper }, | ||
| 138 | + ); | ||
| 139 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 140 | + expect(mockedDetail).not.toHaveBeenCalled(); | ||
| 141 | + expect(result.current.formValues.sUserName).toBe('zhangsan'); | ||
| 142 | + }); | ||
| 143 | + | ||
| 144 | + it('selectEmployee fills userNo/userName from employee (create)', async () => { | ||
| 145 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 146 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 147 | + act(() => { | ||
| 148 | + result.current.selectEmployee(3); | ||
| 149 | + }); | ||
| 150 | + expect(result.current.formValues.iEmployeeId).toBe(3); | ||
| 151 | + expect(result.current.formValues.sUserName).toBe('张三'); | ||
| 152 | + expect(result.current.formValues.sUserNo).toBe('zs'); | ||
| 153 | + }); | ||
| 154 | + | ||
| 155 | + it('toggle permission and toggleAll update checkedPermissionIds', async () => { | ||
| 156 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 157 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 158 | + act(() => { | ||
| 159 | + result.current.togglePermission(1, true); | ||
| 160 | + }); | ||
| 161 | + expect(result.current.checkedPermissionIds).toContain(1); | ||
| 162 | + act(() => { | ||
| 163 | + result.current.toggleAll(true); | ||
| 164 | + }); | ||
| 165 | + expect(result.current.checkedPermissionIds.sort()).toEqual([1, 2]); | ||
| 166 | + act(() => { | ||
| 167 | + result.current.toggleAll(false); | ||
| 168 | + }); | ||
| 169 | + expect(result.current.checkedPermissionIds).toEqual([]); | ||
| 170 | + }); | ||
| 171 | + | ||
| 172 | + it('submit create calls createUser and returns {ok,id}', async () => { | ||
| 173 | + mockedCreate.mockResolvedValue({ id: 9 }); | ||
| 174 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 175 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 176 | + let ret: { ok: boolean; id?: number } | undefined; | ||
| 177 | + await act(async () => { | ||
| 178 | + ret = await result.current.submit(makeValues()); | ||
| 179 | + }); | ||
| 180 | + expect(mockedCreate).toHaveBeenCalledTimes(1); | ||
| 181 | + expect(ret).toMatchObject({ ok: true, id: 9 }); | ||
| 182 | + expect(result.current.submitting).toBe(false); | ||
| 183 | + }); | ||
| 184 | + | ||
| 185 | + it('submit edit calls updateUser with userId and full permissionIds', async () => { | ||
| 186 | + mockedDetail.mockResolvedValue(makeVo()); | ||
| 187 | + mockedUpdate.mockResolvedValue({ id: 7 }); | ||
| 188 | + const { result } = renderHook( | ||
| 189 | + () => useUserDetail({ mode: 'edit', userId: 7 }), | ||
| 190 | + { wrapper }, | ||
| 191 | + ); | ||
| 192 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 193 | + act(() => { | ||
| 194 | + result.current.togglePermission(2, true); | ||
| 195 | + }); | ||
| 196 | + let ret: { ok: boolean; id?: number } | undefined; | ||
| 197 | + await act(async () => { | ||
| 198 | + ret = await result.current.submit(makeValues()); | ||
| 199 | + }); | ||
| 200 | + expect(mockedUpdate).toHaveBeenCalledTimes(1); | ||
| 201 | + const [id, body] = mockedUpdate.mock.calls[0]; | ||
| 202 | + expect(id).toBe(7); | ||
| 203 | + expect(body.permissionIds).toContain(2); | ||
| 204 | + expect(body).not.toHaveProperty('sUserName'); | ||
| 205 | + expect(ret).toMatchObject({ ok: true, id: 7 }); | ||
| 206 | + }); | ||
| 207 | + | ||
| 208 | + it('submit 40901 returns fieldError on sUserName', async () => { | ||
| 209 | + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup')); | ||
| 210 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 211 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 212 | + let ret: { ok: boolean; fieldError?: { field: string; message: string } } | undefined; | ||
| 213 | + await act(async () => { | ||
| 214 | + ret = await result.current.submit(makeValues()); | ||
| 215 | + }); | ||
| 216 | + expect(ret?.ok).toBe(false); | ||
| 217 | + expect(ret?.fieldError?.field).toBe('sUserName'); | ||
| 218 | + expect(result.current.submitting).toBe(false); | ||
| 219 | + }); | ||
| 220 | + | ||
| 221 | + it('submit 40401/40301/40001/network show message and return ok:false', async () => { | ||
| 222 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 223 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 224 | + | ||
| 225 | + for (const code of [ERR_USER_NOT_FOUND, ERR_NO_PERMISSION, ERR_VALIDATION, -1]) { | ||
| 226 | + messageSpy.error.mockClear(); | ||
| 227 | + mockedCreate.mockRejectedValueOnce(new ApiError(code, 'e')); | ||
| 228 | + let ret: { ok: boolean } | undefined; | ||
| 229 | + await act(async () => { | ||
| 230 | + ret = await result.current.submit(makeValues()); | ||
| 231 | + }); | ||
| 232 | + expect(ret?.ok).toBe(false); | ||
| 233 | + expect(messageSpy.error).toHaveBeenCalled(); | ||
| 234 | + } | ||
| 235 | + }); | ||
| 236 | + | ||
| 237 | + it('loadError when prefetch fails sets loadFailed and message; reload clears it', async () => { | ||
| 238 | + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net')); | ||
| 239 | + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); | ||
| 240 | + await waitFor(() => expect(result.current.loadFailed).toBe(true)); | ||
| 241 | + expect(messageSpy.error).toHaveBeenCalledWith(MSG_ERR_LOAD_PERMISSIONS); | ||
| 242 | + | ||
| 243 | + mockedPermissions.mockResolvedValue(PERMISSIONS); | ||
| 244 | + act(() => { | ||
| 245 | + result.current.reload(); | ||
| 246 | + }); | ||
| 247 | + await waitFor(() => expect(result.current.loadFailed).toBe(false)); | ||
| 248 | + expect(result.current.permissions).toEqual(PERMISSIONS); | ||
| 249 | + }); | ||
| 250 | +}); |