UserDetailPage.test.tsx
9.62 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
// REQ-USR-001 / REQ-USR-002: UserDetailPage 页面集成 + 路由接线
// create/edit 贯通 + 提交回流 + 错误就近 + 取数失败(BR3/BR12/BR16/BR17/D4/D5)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Routes, Route, useLocation } from 'react-router-dom';
import { renderShell } from './renderShell';
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 }) }),
};
});
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 UserDetailPage from '../../src/pages/usr/UserDetail';
import { ApiError } from '../../src/api/request';
import { ERR_USERNAME_EXISTS } 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' }];
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 LocationProbe() {
const loc = useLocation();
return <div data-testid="loc">{loc.pathname}</div>;
}
// FE-04: edit 单据预填来自 FE-03 经 navigate state 透传的列表行(presetUser)。
// 测试入口可携带 state.user 复刻该数据流(路由 :id 仅为主键,无 by-id 读端点,
// 不能按主键查「用户号」列——详见 useUserDetail / docs/05 REQ-USR-002/003)。
function renderPage(entry: string, presetUser?: UserVO) {
const initialEntry = presetUser
? { pathname: entry, state: { user: presetUser } }
: entry;
return renderShell(
<>
<LocationProbe />
<Routes>
<Route path="/usr/users" element={<div data-testid="list-sentinel">list</div>} />
<Route path="/usr/users/new" element={<UserDetailPage />} />
<Route path="/usr/users/:id" element={<UserDetailPage />} />
</Routes>
</>,
{
initialEntries: [initialEntry],
preloadedAuth: {
token: 't',
user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
},
},
);
}
async function fillValidCreateForm(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByTestId('field-username'), 'zhangsan');
await user.type(screen.getByTestId('field-userno'), 'zs');
// 语言必填
await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!);
await user.click(await screen.findByText('中文'));
}
describe('UserDetailPage 集成', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedEmployees.mockResolvedValue(EMPLOYEES);
mockedPermissions.mockResolvedValue(PERMISSIONS);
});
it('create mode renders empty form with defaults', async () => {
renderPage('/usr/users/new');
await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
expect(await screen.findByText('保存后自动生成')).toBeInTheDocument();
expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument();
});
it('create submit success navigates to /usr/users with success', async () => {
const user = userEvent.setup();
mockedCreate.mockResolvedValue({ id: 9 });
renderPage('/usr/users/new');
await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
await fillValidCreateForm(user);
await user.click(screen.getByTestId('perm-check-1'));
await user.click(screen.getByTestId('btn-save'));
await waitFor(() => expect(mockedCreate).toHaveBeenCalled());
const body = mockedCreate.mock.calls[0][0];
expect(body.sUserName).toBe('zhangsan');
expect(body.permissionIds).toContain(1);
expect(messageSpy.success).toHaveBeenCalledWith('用户创建成功');
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
});
it('create username format invalid blocks submit', async () => {
const user = userEvent.setup();
renderPage('/usr/users/new');
await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
await user.type(screen.getByTestId('field-username'), 'ab');
await user.click(screen.getByTestId('btn-save'));
expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument();
expect(mockedCreate).not.toHaveBeenCalled();
});
it('create 40901 highlights username field', async () => {
const user = userEvent.setup();
mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup'));
renderPage('/usr/users/new');
await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
await fillValidCreateForm(user);
await user.click(screen.getByTestId('btn-save'));
await waitFor(() => expect(mockedCreate).toHaveBeenCalled());
expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument();
});
it('edit mode prefills from navigate state (presetUser) and username disabled', async () => {
// FE-04: edit 预填走 FE-03 经 navigate state 透传的列表行,不再按主键查列表端点
renderPage('/usr/users/7', makeVo());
await waitFor(() =>
expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
);
expect(mockedDetail).not.toHaveBeenCalled();
expect(screen.getByTestId('field-username')).toBeDisabled();
});
it('edit submit success navigates to /usr/users with 保存成功', async () => {
const user = userEvent.setup();
mockedUpdate.mockResolvedValue({ id: 7 });
renderPage('/usr/users/7', makeVo());
await waitFor(() =>
expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
);
await user.click(screen.getByTestId('btn-save'));
await waitFor(() => expect(mockedUpdate).toHaveBeenCalled());
expect(mockedUpdate.mock.calls[0][0]).toBe(7);
expect(messageSpy.success).toHaveBeenCalledWith('保存成功');
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
});
it('cancel with dirty form confirms then navigates', async () => {
const user = userEvent.setup();
renderPage('/usr/users/new');
await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
await user.type(screen.getByTestId('field-username'), 'dirtyuser');
await user.click(screen.getByTestId('btn-cancel'));
// AntD Modal.confirm 弹确认
expect((await screen.findAllByText('放弃未保存的修改?')).length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: /确\s*定|OK/ }));
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
});
it('新增 navigates to /usr/users/new', async () => {
const user = userEvent.setup();
// edit 经 navigate state 预填后,工具栏「新增」跳 /usr/users/new(BR14)
renderPage('/usr/users/7', makeVo());
await waitFor(() =>
expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
);
await user.click(screen.getByTestId('btn-new'));
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'));
});
it('loadError shows retry; retry calls reload', async () => {
mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net'));
renderPage('/usr/users/new');
expect(await screen.findByTestId('userdetail-loaderror')).toBeInTheDocument();
mockedPermissions.mockResolvedValue(PERMISSIONS);
const user = userEvent.setup();
await user.click(within(screen.getByTestId('userdetail-loaderror')).getByText('点击重试'));
await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull());
});
it('edit without navigate state shows loadError offering 点击重试 + 返回列表', async () => {
// FE-04 B1 fix: edit 缺 presetUser(直接访问 URL / 刷新丢 state)→ loadError,
// 整页给「点击重试」与「返回列表」两个入口(spec § 4 loadError)。
renderPage('/usr/users/7');
const loadError = await screen.findByTestId('userdetail-loaderror');
expect(within(loadError).getByText('点击重试')).toBeInTheDocument();
expect(within(loadError).getByText('返回列表')).toBeInTheDocument();
expect(mockedDetail).not.toHaveBeenCalled();
const user = userEvent.setup();
await user.click(within(loadError).getByText('返回列表'));
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
});
});