mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
test(user): cover user management workflows (#664)
This commit is contained in:
+2
-2
@@ -1327,8 +1327,8 @@ export interface User {
|
||||
id: number
|
||||
// 用户名称
|
||||
name: string
|
||||
// 用户密码
|
||||
password: string
|
||||
// 用户密码仅用于写入,查询响应不返回该字段
|
||||
password?: string
|
||||
// 用户邮箱
|
||||
email: string
|
||||
// 是否激活
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -955,6 +955,65 @@ describe('login page orchestration', () => {
|
||||
expect(mocks.api.post).toHaveBeenCalledWith('/login/access-token', expect.any(FormData), expect.any(Object))
|
||||
})
|
||||
|
||||
it('does not leave Conditional UI loading when an aborted credential request resolves late', async () => {
|
||||
const credential = deferred<Credential>()
|
||||
let capturedSignal: AbortSignal | undefined
|
||||
const credentialGet = vi.fn((options: CredentialRequestOptions) => {
|
||||
capturedSignal = options.signal
|
||||
return credential.promise
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'PublicKeyCredential',
|
||||
class PublicKeyCredentialStub {
|
||||
static isConditionalMediationAvailable = vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('navigator', { credentials: { get: credentialGet } })
|
||||
mocks.api.get.mockResolvedValue([
|
||||
{
|
||||
id: 'system:passkey',
|
||||
type: 'system',
|
||||
method: 'passkey',
|
||||
name: '通行密钥',
|
||||
enabled: true,
|
||||
},
|
||||
])
|
||||
mocks.api.post.mockImplementation((url: string) => {
|
||||
if (url === '/mfa/passkey/authenticate/start') {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
options: JSON.stringify({ challenge: 'AQI' }),
|
||||
transaction_token: 'conditional-transaction',
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const { container } = await renderLogin()
|
||||
await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce())
|
||||
const passkeyButton = await waitFor(() => container.querySelector<HTMLElement>('.passkey-btn')!)
|
||||
|
||||
await submitPassword(container)
|
||||
expect(capturedSignal?.aborted).toBe(true)
|
||||
credential.resolve({
|
||||
id: 'late-credential',
|
||||
rawId: new Uint8Array([1]).buffer,
|
||||
response: {
|
||||
authenticatorData: new Uint8Array([2]).buffer,
|
||||
clientDataJSON: new Uint8Array([3]).buffer,
|
||||
signature: new Uint8Array([4]).buffer,
|
||||
userHandle: null,
|
||||
},
|
||||
type: 'public-key',
|
||||
} as unknown as Credential)
|
||||
await credential.promise
|
||||
await nextTick()
|
||||
|
||||
expect(passkeyButton).not.toHaveClass('v-btn--loading')
|
||||
expect(mocks.api.post).not.toHaveBeenCalledWith('/mfa/passkey/authenticate/finish', expect.any(Object))
|
||||
})
|
||||
|
||||
it('aborts manual Passkey before password login can take ownership', async () => {
|
||||
let capturedSignal: AbortSignal | undefined
|
||||
const credentialGet = vi.fn((options: CredentialRequestOptions) => {
|
||||
|
||||
+2
-1
@@ -412,6 +412,8 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
|
||||
|
||||
const credential = await navigator.credentials.get(credentialRequestOptions)
|
||||
|
||||
if (signal?.aborted) throw new DOMException('PassKey authentication aborted', 'AbortError')
|
||||
|
||||
// Conditional UI 模式下,用户选择通行密钥后才显示 loading
|
||||
if (isConditional) {
|
||||
passkeyLoading.value = true
|
||||
@@ -420,7 +422,6 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
|
||||
if (!credential) {
|
||||
throw new Error('No credential selected')
|
||||
}
|
||||
if (signal?.aborted) throw new DOMException('PassKey authentication aborted', 'AbortError')
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const publicKeyCredential = credential as PublicKeyCredential
|
||||
|
||||
@@ -32,6 +32,9 @@ const isRefreshed = ref(false)
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 最近一次用户列表加载是否失败
|
||||
const loadFailed = ref(false)
|
||||
|
||||
// 所有用户信息
|
||||
const allUsers = ref<User[]>([])
|
||||
|
||||
@@ -41,10 +44,13 @@ async function loadAllUsers() {
|
||||
loading.value = true
|
||||
const result: User[] = await api.get('/user/')
|
||||
allUsers.value = result
|
||||
loadFailed.value = false
|
||||
} catch (error) {
|
||||
loadFailed.value = true
|
||||
console.log(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +66,7 @@ const openAddUserDialog = () => {
|
||||
{
|
||||
oper: 'add',
|
||||
maxWidth: '45rem',
|
||||
usernames: allUsers.value.map(user => user.name),
|
||||
},
|
||||
{
|
||||
save: onUserAdd,
|
||||
@@ -96,7 +103,32 @@ useDynamicButton({
|
||||
</div>
|
||||
<div class="card-list-container">
|
||||
<!-- 加载中提示 -->
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<LoadingBanner v-if="loading && !isRefreshed" class="mt-12" />
|
||||
<!-- 无可展示用户且加载失败时提供同页重试,不把合法空列表当作错误。 -->
|
||||
<NoDataFound
|
||||
v-else-if="loadFailed && allUsers.length === 0"
|
||||
error-code="500"
|
||||
:error-title="t('common.serverConnectionFailed')"
|
||||
>
|
||||
<template #button>
|
||||
<VBtn color="primary" variant="tonal" :loading="loading" @click="loadAllUsers">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<VAlert
|
||||
v-else-if="loadFailed"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
:title="t('common.serverConnectionFailed')"
|
||||
class="mx-2 mb-4"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn color="error" variant="text" :loading="loading" @click="loadAllUsers">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
<!-- 用户卡片网格 -->
|
||||
<ProgressiveCardGrid
|
||||
v-if="allUsers.length > 0 && isRefreshed"
|
||||
@@ -113,7 +145,7 @@ useDynamicButton({
|
||||
</ProgressiveCardGrid>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-if="allUsers.length === 0 && isRefreshed">
|
||||
<div v-if="allUsers.length === 0 && isRefreshed && !loadFailed">
|
||||
<NoDataFound error-code="404" :error-title="t('user.noUsers')" :error-description="t('user.clickToAddUser')" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { User } from '@/api/types'
|
||||
import UserListView from '@/views/user/UserListView.vue'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createUser } from '@tests/support/factories/user'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h, ref, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
appMode: false,
|
||||
openSharedDialog: vi.fn(),
|
||||
useDynamicButton: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: (...args: unknown[]) => mocks.apiGet(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDynamicButton', () => ({
|
||||
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePWA', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return { usePWA: () => ({ appMode: computed(() => mocks.appMode) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
const UserCardStub = defineComponent({
|
||||
name: 'UserCard',
|
||||
props: {
|
||||
user: { type: Object as PropType<User>, required: true },
|
||||
users: { type: Array as PropType<User[]>, required: true },
|
||||
},
|
||||
emits: ['remove', 'save'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('article', { 'data-testid': `user-${props.user.id}` }, [
|
||||
h('span', props.user.name),
|
||||
h('button', { onClick: () => emit('remove'), type: 'button' }, `remove-${props.user.id}`),
|
||||
h('button', { onClick: () => emit('save'), type: 'button' }, `save-${props.user.id}`),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const ProgressiveCardGridStub = defineComponent({
|
||||
name: 'ProgressiveCardGrid',
|
||||
props: {
|
||||
getItemKey: { type: Function as PropType<(user: User) => number>, required: true },
|
||||
items: { type: Array as PropType<User[]>, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'section',
|
||||
props.items.flatMap(item => h('div', { key: props.getItemKey(item) }, slots.default?.({ item }) ?? [])),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const NoDataFoundStub = defineComponent({
|
||||
name: 'NoDataFound',
|
||||
props: { errorDescription: String, errorTitle: String },
|
||||
template:
|
||||
'<section role="region" aria-label="用户状态">{{ errorTitle }} {{ errorDescription }}<slot name="button" /></section>',
|
||||
})
|
||||
|
||||
const LoadingBannerStub = defineComponent({
|
||||
name: 'LoadingBanner',
|
||||
template: '<div role="status">加载用户</div>',
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'VBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const KeepAliveHost = defineComponent({
|
||||
components: { UserListView },
|
||||
setup() {
|
||||
return { active: ref(true) }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用用户页</button>
|
||||
<button type="button" @click="active = true">启用用户页</button>
|
||||
<KeepAlive><UserListView v-if="active" /></KeepAlive>
|
||||
`,
|
||||
})
|
||||
|
||||
interface RenderListOptions {
|
||||
appMode?: boolean
|
||||
initialRoute?: string
|
||||
superUser?: boolean
|
||||
useKeepAlive?: boolean
|
||||
}
|
||||
|
||||
async function renderList(options: RenderListOptions = {}) {
|
||||
mocks.appMode = options.appMode ?? false
|
||||
return renderWithProviders(options.useKeepAlive ? KeepAliveHost : UserListView, {
|
||||
initialRoute: options.initialRoute ?? '/user',
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: DEFAULT_PERMISSIONS,
|
||||
superUser: options.superUser ?? true,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingBanner: LoadingBannerStub,
|
||||
NoDataFound: NoDataFoundStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
UserCard: UserCardStub,
|
||||
VFab: ButtonStub,
|
||||
VPageContentTitle: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('UserListView', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.useDynamicButton.mockReset()
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('loads users once during the initial activated mount', async () => {
|
||||
const user = createUser()
|
||||
mocks.apiGet.mockResolvedValue([user])
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByText(user.name)).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('/user/')
|
||||
})
|
||||
|
||||
it('shows a normal empty state for a successful empty response', async () => {
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByRole('region', { name: '用户状态' })).toHaveTextContent('没有用户')
|
||||
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retryable failure instead of remaining in loading state', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce([createUser({ name: 'recovered' })])
|
||||
|
||||
await renderList()
|
||||
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
await fireEvent.click(retry)
|
||||
expect(await screen.findByText('recovered')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshes after child remove and save events', async () => {
|
||||
const user = createUser()
|
||||
mocks.apiGet.mockResolvedValue([user])
|
||||
await renderList()
|
||||
expect(await screen.findByText(user.name)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: `remove-${user.id}` }))
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
await fireEvent.click(screen.getByRole('button', { name: `save-${user.id}` }))
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(3))
|
||||
})
|
||||
|
||||
it('refreshes when a kept-alive view is activated again', async () => {
|
||||
mocks.apiGet.mockResolvedValue([createUser()])
|
||||
await renderList({ useKeepAlive: true })
|
||||
await screen.findByText('alice')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用用户页' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用用户页' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('keeps stale users visible and offers retry when reactivation refresh fails', async () => {
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce([createUser({ name: 'stale-user' })])
|
||||
.mockRejectedValueOnce(new Error('network'))
|
||||
.mockResolvedValueOnce([createUser({ name: 'recovered-user' })])
|
||||
await renderList({ useKeepAlive: true })
|
||||
await screen.findByText('stale-user')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用用户页' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用用户页' }))
|
||||
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
expect(screen.getByText('stale-user')).toBeInTheDocument()
|
||||
await fireEvent.click(retry)
|
||||
expect(await screen.findByText('recovered-user')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PWA 模式', { appMode: true }],
|
||||
['非用户路由', { initialRoute: '/apps' }],
|
||||
['无管理权限', { superUser: false }],
|
||||
])('hides the page add button in %s', async (_case, options) => {
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
await renderList(options)
|
||||
await screen.findByRole('region', { name: '用户状态' })
|
||||
|
||||
expect(document.querySelector('.compact-fab')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the add dialog from page and dynamic actions with admin permission', async () => {
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
await renderList()
|
||||
await screen.findByRole('region', { name: '用户状态' })
|
||||
|
||||
expect(mocks.useDynamicButton).toHaveBeenCalledWith(expect.objectContaining({ permission: 'admin' }))
|
||||
await fireEvent.click(document.querySelector('.compact-fab')!)
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
maxWidth: '45rem',
|
||||
oper: 'add',
|
||||
usernames: [],
|
||||
})
|
||||
mocks.openSharedDialog.mock.calls[0][2].save()
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
|
||||
mocks.useDynamicButton.mock.calls[0][0].onClick()
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user