test(user): cover user management workflows (#664)

This commit is contained in:
InfinityPacer
2026-08-11 15:29:48 +08:00
committed by GitHub
parent 6e76de293f
commit 3631cd1f22
12 changed files with 946 additions and 29 deletions
+2 -1
View File
@@ -96,7 +96,7 @@ async function removeUser() {
content: t('user.confirmDeleteUser', { username: props.user?.name }),
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
const result: Record<string, unknown> = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success(t('user.deleteSuccess'))
emit('remove')
@@ -104,6 +104,7 @@ async function removeUser() {
$toast.error(t('user.deleteFailed'))
}
} catch (error) {
$toast.error(t('user.deleteFailed'))
console.log(error)
}
}
@@ -0,0 +1,194 @@
import UserCard from '@/components/cards/UserCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createUser } from '@tests/support/factories/user'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
confirm: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
delete: (...args: unknown[]) => mocks.apiDelete(...args),
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => mocks.confirm }))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const ButtonStub = defineComponent({
name: 'VBtn',
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
})
const IconStub = defineComponent({
name: 'VIcon',
props: { icon: String },
setup(props) {
return () => h('span', props.icon)
},
})
async function renderCard(
options: { currentUserId?: number; superUser?: boolean; user?: Parameters<typeof createUser>[0] } = {},
) {
const user = createUser({ id: 7, name: 'alice', settings: { nickname: 'Alice' }, ...options.user })
const events = { remove: vi.fn(), save: vi.fn() }
const result = await renderWithProviders(UserCard, {
props: {
user,
users: [user, createUser({ id: 8, name: 'bob' })],
onRemove: events.remove,
onSave: events.save,
},
initialState: {
global: { globalSettings: { GLOBAL_IMAGE_CACHE: false } },
user: { superUser: options.superUser ?? true, userID: options.currentUserId ?? 99 },
},
global: { stubs: { VBtn: ButtonStub, VIcon: IconStub } },
})
return { ...result, events, user }
}
describe('UserCard', () => {
beforeEach(() => {
mocks.apiDelete.mockReset()
mocks.apiGet.mockReset()
mocks.confirm.mockReset()
mocks.openSharedDialog.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiGet.mockResolvedValue([])
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('shows movie and TV subscription counts', async () => {
mocks.apiGet.mockResolvedValue([
{ id: 1, type: '电影' },
{ id: 2, type: '电影' },
{ id: 3, type: '电视剧' },
])
await renderCard()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('subscribe/user/alice'))
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('renders administrator, inactive, OTP, and fallback profile details', async () => {
mocks.apiGet.mockResolvedValue(null)
await renderCard({
user: {
email: '',
is_active: false,
is_otp: true,
is_superuser: true,
nickname: '',
settings: {},
},
})
expect(screen.getByText('alice')).toBeInTheDocument()
expect(screen.getByText('管理员')).toBeInTheDocument()
expect(screen.getByText('已停用')).toBeInTheDocument()
expect(screen.getByText('2FA')).toBeInTheDocument()
expect(screen.getByText('未设置邮箱')).toBeInTheDocument()
})
it('prefers the direct nickname and hides absent permission chips', async () => {
await renderCard({ user: { nickname: 'Direct Nickname', permissions: {} } })
expect(screen.getByText('Direct Nickname')).toBeInTheDocument()
expect(screen.queryByText('发现')).not.toBeInTheDocument()
})
it('keeps management actions available when subscription loading fails', async () => {
mocks.apiGet.mockRejectedValue(new Error('network'))
const { container } = await renderCard()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
await fireEvent.click(container.querySelector('.user-card')!)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
})
it('opens editing with all usernames and relays save', async () => {
const { container, events } = await renderCard()
await fireEvent.click(container.querySelector('.user-card')!)
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({
oper: 'edit',
username: 'alice',
usernames: ['alice', 'bob'],
})
mocks.openSharedDialog.mock.calls[0][2].save()
expect(events.save).toHaveBeenCalledOnce()
})
it('does not offer deleting the current user', async () => {
await renderCard({ currentUserId: 7 })
expect(screen.queryByRole('button', { name: 'mdi-delete' })).not.toBeInTheDocument()
})
it('does not offer deleting users to non-administrators', async () => {
await renderCard({ superUser: false })
expect(screen.queryByRole('button', { name: 'mdi-delete' })).not.toBeInTheDocument()
})
it('does not request deletion when confirmation is canceled', async () => {
mocks.confirm.mockResolvedValue(false)
await renderCard()
await fireEvent.click(screen.getByRole('button', { name: 'mdi-delete' }))
expect(mocks.apiDelete).not.toHaveBeenCalled()
})
it('emits remove only after a successful deletion', async () => {
mocks.apiDelete.mockResolvedValue({ success: true })
const { events } = await renderCard()
await fireEvent.click(screen.getByRole('button', { name: 'mdi-delete' }))
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('user/id/7'))
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户删除成功')
expect(events.remove).toHaveBeenCalledOnce()
})
it('shows business failure without emitting remove', async () => {
mocks.apiDelete.mockResolvedValue({ success: false })
const { events } = await renderCard()
await fireEvent.click(screen.getByRole('button', { name: 'mdi-delete' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户删除失败!'))
expect(events.remove).not.toHaveBeenCalled()
})
it('shows HTTP failure without emitting remove', async () => {
mocks.apiDelete.mockRejectedValue(new Error('network'))
const { events } = await renderCard()
await fireEvent.click(screen.getByRole('button', { name: 'mdi-delete' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户删除失败!'))
expect(events.remove).not.toHaveBeenCalled()
})
})
+18 -11
View File
@@ -150,8 +150,8 @@ const permissionFeatureOptions = computed(() =>
})),
)
const activePermissionOption = computed(() =>
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
const activePermissionOption = computed(
() => permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
)
const activePermissionFeatures = computed(() =>
@@ -332,6 +332,7 @@ async function addUser() {
userForm.value.name = ''
}
} catch (error) {
$toast.error(t('dialog.userAddEdit.userCreateFailed', { message: t('common.serverConnectionFailed') }))
console.error(error)
}
doneNProgress()
@@ -374,7 +375,7 @@ async function updateUser() {
// 确保权限数据正确传递
userData.permissions = userPermissions.value
const result: { [key: string]: any } = await api.put('user/', userData)
const result: Record<string, unknown> = await api.put('user/', userData)
if (result.success) {
if (oldUserName !== currentUserName.value) {
@@ -403,18 +404,19 @@ async function updateUser() {
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))
}
}
//失败缓存值还原
} catch (error) {
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: '' }))
console.error('更新失败:', error)
} finally {
// 表单中的已保存值用于恢复操作,待提交值只保留在对应的编辑状态中。
currentUserName.value = userForm.value.name
userForm.value.name = oldUserName
currentAvatar.value = userForm.value.avatar
userForm.value.avatar = oldAvatar
userForm.value.password = ''
} catch (error) {
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: '' }))
console.error('更新失败:', error)
doneNProgress()
isUpdating.value = false
}
doneNProgress()
isUpdating.value = false
}
// 用户状态转换,true/false转换为1/0
@@ -771,7 +773,9 @@ onMounted(() => {
}"
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.space.prevent="
userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)
"
>
<VCheckboxBtn
:model-value="isFeatureEnabled(feature.key)"
@@ -871,7 +875,10 @@ onMounted(() => {
background: var(--permission-editor-panel-bg);
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
opacity 0.18s ease;
}
.permission-category-option {
@@ -0,0 +1,353 @@
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useUserStore } from '@/stores'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createUser } from '@tests/support/factories/user'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
done: vi.fn(),
start: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
const DialogCloseBtn = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { 'aria-label': '关闭', onClick: () => emit('click'), type: 'button' })
},
})
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
put: (...args: unknown[]) => mocks.apiPut(...args),
},
}))
vi.mock('@/api/nprogress', () => ({
doneNProgress: () => mocks.done(),
startNProgress: () => mocks.start(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
async function renderDialog(
oper: 'add' | 'edit',
username?: string,
initialPermissions = { discovery: true, search: true, subscribe: true, manage: false, features: {} },
) {
const events = { close: vi.fn(), save: vi.fn() }
const result = await renderWithProviders(UserAddEditDialog, {
props: {
modelValue: true,
oper,
username,
usernames: ['alice', 'existing'],
onClose: events.close,
onSave: events.save,
},
initialState: {
user: {
avatar: 'old-avatar.png',
permissions: initialPermissions,
userName: username === 'alice' ? 'alice' : 'admin',
},
},
stubActions: false,
global: { stubs: { VDialogCloseBtn: DialogCloseBtn } },
})
return { ...result, events }
}
async function fillAddForm(name = 'new-user', password = 'secret') {
await fireEvent.update(screen.getByLabelText('用户名'), name)
await fireEvent.update(screen.getByLabelText('密码'), password)
await fireEvent.update(screen.getByLabelText('确认密码'), password)
}
describe('UserAddEditDialog', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiPut.mockReset()
mocks.done.mockReset()
mocks.start.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('validates username, duplicates, and password confirmation before adding', async () => {
await renderDialog('add')
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
expect(mocks.toastError).toHaveBeenCalledWith('用户名不能为空')
await fillAddForm('existing')
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
expect(mocks.toastError).toHaveBeenCalledWith('用户名已存在')
await fireEvent.update(screen.getByLabelText('用户名'), 'new-user')
await fireEvent.update(screen.getByLabelText('确认密码'), 'different')
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
expect(mocks.toastError).toHaveBeenCalledWith('两次输入的密码不一致')
expect(mocks.apiPost).not.toHaveBeenCalled()
})
it('creates a user with the edited fields and normalized permissions', async () => {
mocks.apiPost.mockResolvedValue({ success: true })
const { events } = await renderDialog('add')
await fillAddForm()
await fireEvent.update(screen.getByLabelText('邮箱'), 'new@example.com')
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
expect(mocks.apiPost).toHaveBeenCalledWith(
'user/',
expect.objectContaining({
email: 'new@example.com',
name: 'new-user',
password: 'secret',
permissions: expect.objectContaining({ discovery: true, manage: false, search: true, subscribe: true }),
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户【new-user】创建成功')
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.done).toHaveBeenCalledOnce()
})
it('includes notification identities in the creation payload', async () => {
mocks.apiPost.mockResolvedValue({ success: true })
await renderDialog('add')
await fillAddForm('notification-user')
await fireEvent.update(screen.getByLabelText('企业微信ID'), 'wx-user')
await fireEvent.update(screen.getByLabelText('Telegram ID'), 'tg-user')
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
expect(mocks.apiPost.mock.calls[0][1]).toMatchObject({
settings: { telegram_userid: 'tg-user', wechat_userid: 'wx-user' },
})
})
it('sends changed permission categories and features when adding', async () => {
mocks.apiPost.mockResolvedValue({ success: true })
await renderDialog('add')
await fillAddForm('permission-user')
const searchCategory = screen.getByRole('button', { name: /^搜索 / })
await fireEvent.click(searchCategory)
await fireEvent.keyDown(searchCategory, { key: 'Enter' })
await fireEvent.keyDown(searchCategory, { key: ' ' })
expect(screen.getByText('搜索功能')).toBeInTheDocument()
const searchFeature = screen.getAllByRole('checkbox').find(element => element.textContent?.includes('资源搜索'))
expect(searchFeature).toBeDefined()
expect(searchFeature).toHaveAttribute('aria-checked', 'true')
await fireEvent.click(searchFeature!)
expect(searchFeature).toHaveAttribute('aria-checked', 'false')
await fireEvent.keyDown(searchFeature!, { key: ' ' })
expect(searchFeature).toHaveAttribute('aria-checked', 'true')
await fireEvent.click(searchFeature!.querySelector('input[type="checkbox"]')!)
expect(searchFeature).toHaveAttribute('aria-checked', 'false')
await fireEvent.click(screen.getByRole('button', { name: '清空' }))
expect(searchFeature).toHaveAttribute('aria-checked', 'false')
await fireEvent.click(screen.getByRole('button', { name: '默认' }))
expect(searchFeature).toHaveAttribute('aria-checked', 'true')
await fireEvent.click(screen.getByRole('button', { name: '清空' }))
await fireEvent.click(screen.getByRole('button', { name: '全选' }))
expect(searchFeature).toHaveAttribute('aria-checked', 'true')
await fireEvent.keyDown(searchFeature!, { key: 'Enter' })
expect(searchFeature).toHaveAttribute('aria-checked', 'false')
await fireEvent.click(searchCategory.querySelector('.permission-category-option__toggle')!)
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
expect(mocks.apiPost.mock.calls[0][1].permissions).toMatchObject({
search: false,
features: { 'search.resource': false },
})
})
it('toggles password visibility through the form controls', async () => {
await renderDialog('add')
const password = screen.getByLabelText('密码')
const confirmation = screen.getByLabelText('确认密码')
await fireEvent.click(password.closest('.v-input')!.querySelector('.v-field__append-inner .v-icon')!)
await fireEvent.click(confirmation.closest('.v-input')!.querySelector('.v-field__append-inner .v-icon')!)
expect(password).toHaveAttribute('type', 'text')
expect(confirmation).toHaveAttribute('type', 'text')
})
it('validates avatar type and size, then accepts and resets a valid image', async () => {
await renderDialog('add')
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!
const inputClick = vi.spyOn(input, 'click')
await fireEvent.click(input.previousElementSibling!)
expect(inputClick).toHaveBeenCalledOnce()
Object.defineProperty(input, 'files', {
configurable: true,
value: [new File(['text'], 'avatar.txt', { type: 'text/plain' })],
})
await fireEvent.input(input)
expect(mocks.toastError).toHaveBeenCalledWith('上传的文件不符合要求,请重新选择头像')
Object.defineProperty(input, 'files', {
configurable: true,
value: [new File([new Uint8Array(800 * 1024 + 1)], 'large.png', { type: 'image/png' })],
})
await fireEvent.input(input)
expect(mocks.toastError).toHaveBeenCalledWith('文件大小不得大于800KB')
Object.defineProperty(input, 'files', {
configurable: true,
value: [new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })],
})
await fireEvent.input(input)
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('新头像上传成功,待保存后生效!'))
await fireEvent.click(screen.getByRole('button', { name: /重置默认头像/ }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('已重置为默认头像,待保存后生效!')
})
it('restores the saved avatar and emits close while editing', async () => {
mocks.apiGet.mockResolvedValue(createUser({ avatar: 'saved-avatar.png', name: 'alice' }))
const { events } = await renderDialog('edit', 'alice')
await screen.findByDisplayValue('alice')
await fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('已还原当前使用头像!')
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(events.close).toHaveBeenCalledOnce()
})
it('keeps the dialog open after a business creation failure', async () => {
mocks.apiPost.mockResolvedValue({ message: '不允许创建', success: false })
const { events } = await renderDialog('add')
await fillAddForm()
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('创建用户失败:不允许创建'))
expect(events.save).not.toHaveBeenCalled()
})
it('shows HTTP creation failure and restores progress for retry', async () => {
mocks.apiPost.mockRejectedValue(new Error('network'))
const { events } = await renderDialog('add')
await fillAddForm()
await fireEvent.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('创建用户失败:服务器连接失败'))
expect(events.save).not.toHaveBeenCalled()
expect(mocks.done).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '添加' })).toBeEnabled()
})
it('loads an existing user and sends controlled edit fields', async () => {
mocks.apiGet.mockResolvedValue(
createUser({
avatar: 'saved-avatar.png',
email: 'alice@example.com',
name: 'alice',
permissions: { discovery: true, search: false, subscribe: true, manage: false, features: {} },
settings: { nickname: 'Alice' },
}),
)
mocks.apiPut.mockResolvedValue({ success: true })
const { events } = await renderDialog('edit', 'alice')
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
await fireEvent.update(screen.getByLabelText('昵称'), 'Alice Updated')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
expect(mocks.apiPut).toHaveBeenCalledWith(
'user/',
expect.objectContaining({
avatar: 'saved-avatar.png',
name: 'alice',
permissions: expect.objectContaining({ search: false }),
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
}),
)
expect(events.save).toHaveBeenCalledOnce()
expect(useUserStore().avatar).toBe('old-avatar.png')
})
it('keeps current-user permissions on failure and updates them only after success', async () => {
mocks.apiGet.mockResolvedValue(createUser({ avatar: 'saved-avatar.png', name: 'alice' }))
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
await renderDialog('edit', 'alice', {
discovery: false,
search: false,
subscribe: false,
manage: false,
features: {},
})
await screen.findByDisplayValue('alice')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
expect(useUserStore().permissions).toMatchObject({ discovery: false, search: false, subscribe: false })
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(2))
expect(mocks.apiPut.mock.calls[1][1].permissions).toMatchObject({
discovery: true,
search: true,
subscribe: true,
})
await waitFor(() =>
expect(useUserStore().permissions).toMatchObject({ discovery: true, search: true, subscribe: true }),
)
})
it.each([
['business', { message: '不允许更新', success: false }, false],
['HTTP', new Error('network'), true],
])('keeps editing available after %s update failure', async (_case, result, rejects) => {
mocks.apiGet.mockResolvedValue(createUser({ name: 'alice' }))
if (rejects) mocks.apiPut.mockRejectedValue(result)
else mocks.apiPut.mockResolvedValue(result)
const { events } = await renderDialog('edit', 'alice')
await screen.findByDisplayValue('alice')
await userEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('更新用户失败')))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
})
it('preserves the saved avatar baseline for restore after an HTTP update failure', async () => {
mocks.apiGet.mockResolvedValue(createUser({ avatar: 'saved-avatar.png', name: 'alice' }))
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
await renderDialog('edit', 'alice')
await screen.findByDisplayValue('alice')
await fireEvent.click(screen.getByRole('button', { name: /重置默认头像/ }))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
await fireEvent.click(screen.getByRole('button', { name: '取消' }))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(2))
expect(mocks.apiPut.mock.calls[1][1]).toMatchObject({ avatar: 'saved-avatar.png' })
})
})