useUserDetail.test.tsx
9.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// 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);
});
});