diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3412cdd6..c7c7eb37 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -157,11 +157,6 @@ "count": 2 } }, - "src/components/cards/UserCard.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/components/dialog/AboutDialog.vue": { "@typescript-eslint/no-explicit-any": { "count": 8 @@ -328,11 +323,6 @@ "count": 1 } }, - "src/components/dialog/UserAddEditDialog.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/components/dialog/UserAuthDialog.vue": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/src/api/types.ts b/src/api/types.ts index 7c2dfef3..ffe96560 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1327,8 +1327,8 @@ export interface User { id: number // 用户名称 name: string - // 用户密码 - password: string + // 用户密码仅用于写入,查询响应不返回该字段 + password?: string // 用户邮箱 email: string // 是否激活 diff --git a/src/components/cards/UserCard.vue b/src/components/cards/UserCard.vue index 55077788..8d93e53f 100644 --- a/src/components/cards/UserCard.vue +++ b/src/components/cards/UserCard.vue @@ -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 = 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) } } diff --git a/src/components/cards/__tests__/UserCard.spec.ts b/src/components/cards/__tests__/UserCard.spec.ts new file mode 100644 index 00000000..ce9f5365 --- /dev/null +++ b/src/components/cards/__tests__/UserCard.spec.ts @@ -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[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() + }) +}) diff --git a/src/components/dialog/UserAddEditDialog.vue b/src/components/dialog/UserAddEditDialog.vue index 9d41d071..58df4848 100644 --- a/src/components/dialog/UserAddEditDialog.vue +++ b/src/components/dialog/UserAddEditDialog.vue @@ -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 = 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) + " > { 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 { diff --git a/src/components/dialog/__tests__/UserAddEditDialog.spec.ts b/src/components/dialog/__tests__/UserAddEditDialog.spec.ts new file mode 100644 index 00000000..56caa023 --- /dev/null +++ b/src/components/dialog/__tests__/UserAddEditDialog.spec.ts @@ -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('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' }) + }) +}) diff --git a/src/pages/__tests__/login.spec.ts b/src/pages/__tests__/login.spec.ts index 9a57f87e..2df9a523 100644 --- a/src/pages/__tests__/login.spec.ts +++ b/src/pages/__tests__/login.spec.ts @@ -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() + 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('.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) => { diff --git a/src/pages/login.vue b/src/pages/login.vue index 03f6168c..cca8161f 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -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 diff --git a/src/views/user/UserListView.vue b/src/views/user/UserListView.vue index b2a27557..a1b7b9e3 100644 --- a/src/views/user/UserListView.vue +++ b/src/views/user/UserListView.vue @@ -32,6 +32,9 @@ const isRefreshed = ref(false) // 是否加载中 const loading = ref(false) +// 最近一次用户列表加载是否失败 +const loadFailed = ref(false) + // 所有用户信息 const allUsers = ref([]) @@ -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({
- + + + + + + + + -
+
diff --git a/src/views/user/__tests__/UserListView.spec.ts b/src/views/user/__tests__/UserListView.spec.ts new file mode 100644 index 00000000..a1b03199 --- /dev/null +++ b/src/views/user/__tests__/UserListView.spec.ts @@ -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, required: true }, + users: { type: Array as PropType, 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, 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: + '
{{ errorTitle }} {{ errorDescription }}
', +}) + +const LoadingBannerStub = defineComponent({ + name: 'LoadingBanner', + template: '
加载用户
', +}) + +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: ` + + + + `, +}) + +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) + }) +}) diff --git a/tests/support/factories/user.ts b/tests/support/factories/user.ts new file mode 100644 index 00000000..c732e87f --- /dev/null +++ b/tests/support/factories/user.ts @@ -0,0 +1,18 @@ +import type { User } from '@/api/types' +import { DEFAULT_PERMISSIONS } from '@/utils/permission' + +/** 构造与用户查询接口一致的稳定测试数据。 */ +export function createUser(overrides: Partial = {}): User { + return { + id: 7, + name: 'alice', + email: 'alice@example.com', + is_active: true, + is_superuser: false, + avatar: '', + is_otp: false, + permissions: { ...DEFAULT_PERMISSIONS }, + settings: {}, + ...overrides, + } +} diff --git a/vite.config.ts b/vite.config.ts index f744f2a5..1ec2f0f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -334,11 +334,13 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/composables/useMediaSubscribe.ts', 'src/composables/useTorrentFilter.ts', 'src/components/cards/SubscribeCard.vue', + 'src/components/cards/UserCard.vue', 'src/components/filter/TorrentFilterBar.vue', 'src/components/cards/TorrentCard.vue', 'src/components/cards/TorrentItem.vue', 'src/utils/torrentDownloadCache.ts', 'src/components/dialog/SiteAddEditDialog.vue', + 'src/components/dialog/UserAddEditDialog.vue', 'src/components/dialog/SiteCookieUpdateDialog.vue', 'src/components/dialog/SiteImportDialog.vue', 'src/components/cards/SubscribeShareCard.vue', @@ -384,6 +386,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/mediaStatusCache.ts', 'src/utils/searchStream.ts', 'src/views/site/SiteCardListView.vue', + 'src/views/user/UserListView.vue', 'src/views/reorganize/DownloadingListView.vue', 'src/views/plugin/PluginCardListView.vue', 'src/utils/siteIconCache.ts', @@ -402,6 +405,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/components/cards/UserCard.vue': { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, + 'src/components/dialog/UserAddEditDialog.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/views/user/UserListView.vue': { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, 'src/components/cards/TorrentCard.vue': { branches: 75, functions: 80,