mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
fix(auth): align v3 profile and auth errors
按 AUTH-001 配对交付门禁合并:已完成与后端认证契约的真实联调,前端 format、lint 和单测全部通过;PR Agent 配额失败按约定忽略。
This commit is contained in:
@@ -6,7 +6,7 @@ import axios, {
|
|||||||
type AxiosResponse,
|
type AxiosResponse,
|
||||||
type InternalAxiosRequestConfig,
|
type InternalAxiosRequestConfig,
|
||||||
} from 'axios'
|
} from 'axios'
|
||||||
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
|
import { ApiRequestError, createApiClients, getApiErrorMessage, type ApiFeedbackNotifier } from '@/api/client'
|
||||||
import type { ApiResponse } from '@/api/types'
|
import type { ApiResponse } from '@/api/types'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -314,6 +314,23 @@ describe('MoviePilot API client', () => {
|
|||||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('从业务或 HTTP 错误提取 message/detail,网络错误交给连接 fallback', () => {
|
||||||
|
const businessError = new ApiRequestError('Cannot save', {
|
||||||
|
businessFailure: true,
|
||||||
|
payload: { message: '资料保存失败' },
|
||||||
|
})
|
||||||
|
const forbiddenPayload = { detail: '当前用户无权修改资料' }
|
||||||
|
const forbiddenError = new ApiRequestError('Forbidden', {
|
||||||
|
payload: forbiddenPayload,
|
||||||
|
response: createResponse({} as InternalAxiosRequestConfig, forbiddenPayload, 403),
|
||||||
|
})
|
||||||
|
const networkError = new ApiRequestError('Network Error')
|
||||||
|
|
||||||
|
expect(getApiErrorMessage(businessError)).toBe('资料保存失败')
|
||||||
|
expect(getApiErrorMessage(forbiddenError)).toBe('当前用户无权修改资料')
|
||||||
|
expect(getApiErrorMessage(networkError)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||||
const reportConnectionFailure = vi.fn()
|
const reportConnectionFailure = vi.fn()
|
||||||
const adapter: AxiosAdapter = async () => {
|
const adapter: AxiosAdapter = async () => {
|
||||||
|
|||||||
@@ -118,14 +118,15 @@ describe('API application wiring', () => {
|
|||||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('token 校验失败的 403 完成签退后不弹技术错误', async () => {
|
it('403 保留当前会话并通过请求反馈展示授权错误', async () => {
|
||||||
mocks.authState.token = 'invalid-token'
|
mocks.authState.token = 'invalid-token'
|
||||||
const module = await installFailingAdapter(403, { detail: 'token校验不通过' })
|
const module = await installFailingAdapter(403, { detail: 'token校验不通过' })
|
||||||
|
|
||||||
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
|
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
|
||||||
|
|
||||||
expect(mocks.logout).toHaveBeenCalledOnce()
|
expect(mocks.logout).not.toHaveBeenCalled()
|
||||||
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
|
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
expect(mocks.authState.token).toBe('invalid-token')
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('token校验不通过')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-4
@@ -143,13 +143,23 @@ export class ApiRequestError<T = unknown> extends AxiosError<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回 API 错误载荷中的业务消息,网络错误无响应时返回 undefined 供调用方使用连接 fallback。 */
|
||||||
|
export function getApiErrorMessage(error: unknown): string | undefined {
|
||||||
|
if (!(error instanceof ApiRequestError)) return undefined
|
||||||
|
const payload = error.payload ?? error.response?.data
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined
|
||||||
|
const record = payload as Record<string, unknown>
|
||||||
|
for (const key of ['message', 'detail']) {
|
||||||
|
const message = record[key]
|
||||||
|
if (typeof message === 'string' && message.trim()) return message
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
|
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
|
||||||
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
||||||
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
||||||
const payload = error.payload
|
return getApiErrorMessage(error)
|
||||||
if (!payload || typeof payload !== 'object') return undefined
|
|
||||||
const message = (payload as { message?: unknown }).message
|
|
||||||
return typeof message === 'string' && message.trim() ? message : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
||||||
|
|||||||
+5
-4
@@ -8,6 +8,7 @@ import i18n, { getCurrentLocale } from '@/plugins/i18n'
|
|||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
createApiClients,
|
createApiClients,
|
||||||
|
getApiErrorMessage,
|
||||||
getApiBusinessErrorMessage,
|
getApiBusinessErrorMessage,
|
||||||
isApiBusinessFailure,
|
isApiBusinessFailure,
|
||||||
isApiResponse,
|
isApiResponse,
|
||||||
@@ -33,8 +34,8 @@ const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
|||||||
timeout: 'common.requestTimeout',
|
timeout: 'common.requestTimeout',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 认证失效只负责代码签退;原始异常继续交给发起请求的业务界面处理。 */
|
/** 只有认证失效(401)才清理会话并回到登录页;授权拒绝(403)保留会话交给业务处理。 */
|
||||||
function handleAuthenticationFailure(): true {
|
function handleUnauthorized(): true {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
@@ -49,8 +50,7 @@ const { api, pluginApi } = createApiClients({
|
|||||||
hooks: {
|
hooks: {
|
||||||
markServerOnline: globalOfflineStatus.markServerOnline,
|
markServerOnline: globalOfflineStatus.markServerOnline,
|
||||||
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
||||||
onForbidden: handleAuthenticationFailure,
|
onUnauthorized: handleUnauthorized,
|
||||||
onUnauthorized: handleAuthenticationFailure,
|
|
||||||
},
|
},
|
||||||
notifier: {
|
notifier: {
|
||||||
error: message => toast.error(message),
|
error: message => toast.error(message),
|
||||||
@@ -98,6 +98,7 @@ if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
|||||||
export {
|
export {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
createPluginInstanceApi,
|
createPluginInstanceApi,
|
||||||
|
getApiErrorMessage,
|
||||||
getApiBusinessErrorMessage,
|
getApiBusinessErrorMessage,
|
||||||
isApiBusinessFailure,
|
isApiBusinessFailure,
|
||||||
isApiResponse,
|
isApiResponse,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiErrorMessage } from '@/api/client'
|
||||||
import type { User, PassKey } from '@/api/types'
|
import type { User, PassKey } from '@/api/types'
|
||||||
import avatar1 from '@images/avatars/avatar-1.png'
|
import avatar1 from '@images/avatars/avatar-1.png'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
@@ -197,13 +197,9 @@ async function fetchUserInfo() {
|
|||||||
isProfileLoading.value = true
|
isProfileLoading.value = true
|
||||||
profileLoadFailed.value = false
|
profileLoadFailed.value = false
|
||||||
try {
|
try {
|
||||||
const result: User = await api.get(`user/${userStore.userName}`)
|
const result: User = await api.get('user/current')
|
||||||
if (result) {
|
if (result) {
|
||||||
accountInfo.value = result
|
applyUserProfile(result)
|
||||||
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
|
|
||||||
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
|
|
||||||
currentUserName.value = accountInfo.value.name
|
|
||||||
currentAvatar.value = accountInfo.value.avatar
|
|
||||||
// Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。
|
// Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。
|
||||||
void fetchPassKeyList()
|
void fetchPassKeyList()
|
||||||
}
|
}
|
||||||
@@ -238,47 +234,28 @@ async function saveAccountInfo() {
|
|||||||
}
|
}
|
||||||
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
|
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
|
||||||
|
|
||||||
const oldUserName = accountInfo.value.name
|
|
||||||
const oldAvatar = accountInfo.value.avatar
|
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
// 请求数据独立于已确认快照,失败后保留当前输入供用户重试。
|
// 请求数据只包含自助资料字段,失败后保留当前输入供用户重试。
|
||||||
const userData: User = {
|
const userData: CurrentUserUpdatePayload = {
|
||||||
...accountInfo.value,
|
email: accountInfo.value.email,
|
||||||
avatar: currentAvatar.value,
|
avatar: currentAvatar.value,
|
||||||
name: currentUserName.value,
|
settings: accountInfo.value.settings,
|
||||||
...(newPassword.value ? { password: newPassword.value } : {}),
|
...(newPassword.value ? { password: newPassword.value } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put<null>('user/', userData, { feedback: 'silent' })
|
const updatedUser = await api.put<User>('user/current', userData, { feedback: 'silent' })
|
||||||
|
applyUserProfile(updatedUser)
|
||||||
accountInfo.value.name = currentUserName.value
|
userStore.setUserName(updatedUser.name)
|
||||||
accountInfo.value.avatar = currentAvatar.value
|
|
||||||
if (oldUserName !== currentUserName.value) {
|
|
||||||
$toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value }))
|
|
||||||
// 更新本地用户名显示
|
|
||||||
userStore.setUserName(currentUserName.value)
|
|
||||||
} else {
|
|
||||||
$toast.success(t('profile.saveSuccess'))
|
|
||||||
}
|
|
||||||
// 更新本地头像显示
|
|
||||||
if (oldAvatar !== currentAvatar.value) {
|
|
||||||
userStore.setAvatar(currentAvatar.value)
|
userStore.setAvatar(currentAvatar.value)
|
||||||
}
|
// 凭据仅在服务端确认成功后清空,失败时保留输入便于修正或重试。
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
$toast.success(t('profile.saveSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('保存失败:', error)
|
console.log('保存失败:', error)
|
||||||
const message = getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed')
|
const message = getApiErrorMessage(error) || t('common.serverConnectionFailed')
|
||||||
if (oldUserName !== currentUserName.value) {
|
|
||||||
$toast.error(
|
|
||||||
t('profile.saveFailedWithNameChange', {
|
|
||||||
oldName: oldUserName,
|
|
||||||
newName: currentUserName.value,
|
|
||||||
message,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
$toast.error(t('profile.saveFailed', { message }))
|
$toast.error(t('profile.saveFailed', { message }))
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
@@ -291,6 +268,29 @@ interface VerifyPasswordPayload {
|
|||||||
callback: (password: string) => void
|
callback: (password: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前用户自助修改资料时允许提交的字段,权限与身份字段由服务端维护。 */
|
||||||
|
interface CurrentUserUpdatePayload {
|
||||||
|
// 用户邮箱
|
||||||
|
email: User['email']
|
||||||
|
// 用户头像
|
||||||
|
avatar: User['avatar']
|
||||||
|
// 个性化设置及通知身份绑定
|
||||||
|
settings: User['settings']
|
||||||
|
// 非空时更新密码
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将服务端用户资料投影到页面状态,避免把本地编辑状态当成保存成功的事实。 */
|
||||||
|
function applyUserProfile(user: User) {
|
||||||
|
accountInfo.value = {
|
||||||
|
...user,
|
||||||
|
settings: user.settings ?? {},
|
||||||
|
nickname: user.settings?.nickname ?? '',
|
||||||
|
}
|
||||||
|
currentUserName.value = user.name
|
||||||
|
currentAvatar.value = user.avatar || avatar1
|
||||||
|
}
|
||||||
|
|
||||||
// 密码验证并执行回调
|
// 密码验证并执行回调
|
||||||
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
|
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
|
||||||
verifyTitle.value = title
|
verifyTitle.value = title
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import UserProfileView from '@/views/user/UserProfileView.vue'
|
import UserProfileView from '@/views/user/UserProfileView.vue'
|
||||||
|
import { type AxiosResponse } from 'axios'
|
||||||
import type { PassKey } from '@/api/types'
|
import type { PassKey } from '@/api/types'
|
||||||
|
import { ApiRequestError } from '@/api/client'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { createPassKey, createUser } from '@tests/support/factories/user'
|
import { createPassKey, createUser } from '@tests/support/factories/user'
|
||||||
@@ -29,18 +31,28 @@ vi.mock('vue-toastification', () => ({
|
|||||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function currentUser() {
|
function currentUser(overrides: Partial<ReturnType<typeof createUser>> = {}) {
|
||||||
return createUser({
|
return createUser({
|
||||||
avatar: 'saved-avatar.png',
|
avatar: 'saved-avatar.png',
|
||||||
email: 'alice@example.com',
|
email: 'alice@example.com',
|
||||||
is_otp: false,
|
is_otp: false,
|
||||||
settings: { nickname: 'Alice' },
|
settings: { nickname: 'Alice' },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造带 HTTP 状态和 FastAPI detail 的统一请求错误。 */
|
||||||
|
function createHttpError(status: number, detail: string) {
|
||||||
|
const payload = { detail }
|
||||||
|
return new ApiRequestError('Request failed', {
|
||||||
|
payload,
|
||||||
|
response: { data: payload, status } as AxiosResponse,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSuccessfulLoad() {
|
function mockSuccessfulLoad() {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
if (url === 'user/current') return Promise.resolve(currentUser())
|
||||||
if (url === 'mfa/passkey/list') return Promise.resolve({ data: [createPassKey()], success: true })
|
if (url === 'mfa/passkey/list') return Promise.resolve({ data: [createPassKey()], success: true })
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`))
|
return Promise.reject(new Error(`unexpected GET ${url}`))
|
||||||
})
|
})
|
||||||
@@ -89,7 +101,7 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
|
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
|
||||||
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
|
||||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/alice')
|
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/current')
|
||||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'mfa/passkey/list')
|
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'mfa/passkey/list')
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||||
@@ -98,7 +110,7 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
it('shows a retryable error instead of a blank profile after loading fails', async () => {
|
it('shows a retryable error instead of a blank profile after loading fails', async () => {
|
||||||
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockImplementation((url: string) => {
|
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockImplementation((url: string) => {
|
||||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
if (url === 'user/current') return Promise.resolve(currentUser())
|
||||||
return Promise.resolve({ data: [], success: true })
|
return Promise.resolve({ data: [], success: true })
|
||||||
})
|
})
|
||||||
await renderProfile()
|
await renderProfile()
|
||||||
@@ -113,7 +125,7 @@ describe('UserProfileView', () => {
|
|||||||
it('shows the loaded profile while the passkey badge request is still pending', async () => {
|
it('shows the loaded profile while the passkey badge request is still pending', async () => {
|
||||||
const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>()
|
const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>()
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
if (url === 'user/current') return Promise.resolve(currentUser())
|
||||||
if (url === 'mfa/passkey/list') return passkeyRequest.promise
|
if (url === 'mfa/passkey/list') return passkeyRequest.promise
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`))
|
return Promise.reject(new Error(`unexpected GET ${url}`))
|
||||||
})
|
})
|
||||||
@@ -159,7 +171,7 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
it('keeps notification identity fields editable in the profile payload', async () => {
|
it('keeps notification identity fields editable in the profile payload', async () => {
|
||||||
mockSuccessfulLoad()
|
mockSuccessfulLoad()
|
||||||
mocks.apiPut.mockResolvedValue({ success: true })
|
mocks.apiPut.mockResolvedValue(currentUser())
|
||||||
await renderProfile()
|
await renderProfile()
|
||||||
await screen.findByDisplayValue('alice@example.com')
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
@@ -178,6 +190,9 @@ describe('UserProfileView', () => {
|
|||||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.apiPut.mock.calls[0][0]).toBe('user/current')
|
||||||
|
expect(Object.keys(mocks.apiPut.mock.calls[0][1]).sort()).toEqual(['avatar', 'email', 'settings'])
|
||||||
|
expect(mocks.apiPut.mock.calls[0][1]).not.toHaveProperty('password')
|
||||||
expect(mocks.apiPut.mock.calls[0][1]).toMatchObject({
|
expect(mocks.apiPut.mock.calls[0][1]).toMatchObject({
|
||||||
settings: {
|
settings: {
|
||||||
discord_userid: 'discord-user',
|
discord_userid: 'discord-user',
|
||||||
@@ -195,7 +210,13 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
it('saves edited profile data and updates the current-user store only after success', async () => {
|
it('saves edited profile data and updates the current-user store only after success', async () => {
|
||||||
mockSuccessfulLoad()
|
mockSuccessfulLoad()
|
||||||
mocks.apiPut.mockResolvedValue({ success: true })
|
mocks.apiPut.mockResolvedValue(
|
||||||
|
currentUser({
|
||||||
|
avatar: 'server-avatar.png',
|
||||||
|
email: 'server@example.com',
|
||||||
|
settings: { nickname: 'Server Name' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
await renderProfile()
|
await renderProfile()
|
||||||
await screen.findByDisplayValue('alice@example.com')
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
@@ -205,15 +226,40 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||||
expect(mocks.apiPut).toHaveBeenCalledWith(
|
expect(mocks.apiPut).toHaveBeenCalledWith(
|
||||||
'user/',
|
'user/current',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
email: 'updated@example.com',
|
email: 'updated@example.com',
|
||||||
name: 'alice',
|
|
||||||
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
|
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!')
|
||||||
expect(useUserStore().userName).toBe('alice')
|
expect(useUserStore().userName).toBe('alice')
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('server@example.com')).toBeInTheDocument())
|
||||||
|
expect(screen.getByDisplayValue('Server Name')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('仅在成功时提交非空密码并清空两次密码输入', async () => {
|
||||||
|
mockSuccessfulLoad()
|
||||||
|
mocks.apiPut.mockResolvedValue(currentUser({ email: 'updated@example.com' }))
|
||||||
|
await renderProfile()
|
||||||
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('邮箱'), 'updated@example.com')
|
||||||
|
await fireEvent.update(screen.getByLabelText('密码'), 'new-password')
|
||||||
|
await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.apiPut.mock.calls[0][1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: 'updated@example.com',
|
||||||
|
password: expect.any(String),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mocks.apiPut.mock.calls[0][1].password).toBe('new-password')
|
||||||
|
expect(Object.keys(mocks.apiPut.mock.calls[0][1]).sort()).toEqual(['avatar', 'email', 'password', 'settings'])
|
||||||
|
expect(screen.getByLabelText('密码')).toHaveValue('')
|
||||||
|
expect(screen.getByLabelText('确认密码')).toHaveValue('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps edited input retryable after a business failure', async () => {
|
it('keeps edited input retryable after a business failure', async () => {
|
||||||
@@ -223,16 +269,54 @@ describe('UserProfileView', () => {
|
|||||||
await screen.findByDisplayValue('alice@example.com')
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
|
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
|
||||||
|
await fireEvent.update(screen.getByLabelText('密码'), 'new-password')
|
||||||
|
await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password')
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!'))
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!'))
|
||||||
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('密码')).toHaveValue('new-password')
|
||||||
|
expect(screen.getByLabelText('确认密码')).toHaveValue('new-password')
|
||||||
|
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the HTTP 403 permission message once and keeps edited input', async () => {
|
||||||
|
mockSuccessfulLoad()
|
||||||
|
mocks.apiPut.mockRejectedValue(createHttpError(403, '当前用户无权修改资料'))
|
||||||
|
await renderProfile()
|
||||||
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('邮箱'), 'forbidden@example.com')
|
||||||
|
await fireEvent.update(screen.getByLabelText('密码'), 'new-password')
|
||||||
|
await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:当前用户无权修改资料!')
|
||||||
|
expect(screen.getByDisplayValue('forbidden@example.com')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('密码')).toHaveValue('new-password')
|
||||||
|
expect(screen.getByLabelText('确认密码')).toHaveValue('new-password')
|
||||||
|
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the HTTP 400 business detail and keeps edited input', async () => {
|
||||||
|
mockSuccessfulLoad()
|
||||||
|
mocks.apiPut.mockRejectedValue(createHttpError(400, '密码格式不符合要求'))
|
||||||
|
await renderProfile()
|
||||||
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('邮箱'), 'invalid@example.com')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:密码格式不符合要求!')
|
||||||
|
expect(screen.getByDisplayValue('invalid@example.com')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an HTTP save failure and allows the same input to be retried', async () => {
|
it('shows an HTTP save failure and allows the same input to be retried', async () => {
|
||||||
mockSuccessfulLoad()
|
mockSuccessfulLoad()
|
||||||
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
|
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(currentUser())
|
||||||
await renderProfile()
|
await renderProfile()
|
||||||
await screen.findByDisplayValue('alice@example.com')
|
await screen.findByDisplayValue('alice@example.com')
|
||||||
|
|
||||||
@@ -247,7 +331,7 @@ describe('UserProfileView', () => {
|
|||||||
|
|
||||||
it('prevents duplicate save submissions while a request is pending', async () => {
|
it('prevents duplicate save submissions while a request is pending', async () => {
|
||||||
mockSuccessfulLoad()
|
mockSuccessfulLoad()
|
||||||
let resolveUpdate!: (value: { success: boolean }) => void
|
let resolveUpdate!: (value: ReturnType<typeof currentUser>) => void
|
||||||
mocks.apiPut.mockReturnValue(
|
mocks.apiPut.mockReturnValue(
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
resolveUpdate = resolve
|
resolveUpdate = resolve
|
||||||
@@ -261,7 +345,7 @@ describe('UserProfileView', () => {
|
|||||||
await fireEvent.click(save)
|
await fireEvent.click(save)
|
||||||
expect(mocks.apiPut).toHaveBeenCalledOnce()
|
expect(mocks.apiPut).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
resolveUpdate({ success: true })
|
resolveUpdate(currentUser())
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled())
|
await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user