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 -2
View File
@@ -1327,8 +1327,8 @@ export interface User {
id: number
// 用户名称
name: string
// 用户密码
password: string
// 用户密码仅用于写入,查询响应不返回该字段
password?: string
// 用户邮箱
email: string
// 是否激活
+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' })
})
})
+59
View File
@@ -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
View File
@@ -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
+36 -4
View File
@@ -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)
})
})