mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +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 InternalAxiosRequestConfig,
|
||||
} 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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -314,6 +314,23 @@ describe('MoviePilot API client', () => {
|
||||
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 () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async () => {
|
||||
|
||||
@@ -118,14 +118,15 @@ describe('API application wiring', () => {
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('token 校验失败的 403 完成签退后不弹技术错误', async () => {
|
||||
it('403 保留当前会话并通过请求反馈展示授权错误', async () => {
|
||||
mocks.authState.token = 'invalid-token'
|
||||
const module = await installFailingAdapter(403, { detail: 'token校验不通过' })
|
||||
|
||||
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
|
||||
|
||||
expect(mocks.logout).toHaveBeenCalledOnce()
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.logout).not.toHaveBeenCalled()
|
||||
expect(mocks.routerPush).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 响应中声明的业务失败消息。 */
|
||||
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
||||
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
||||
const payload = error.payload
|
||||
if (!payload || typeof payload !== 'object') return undefined
|
||||
const message = (payload as { message?: unknown }).message
|
||||
return typeof message === 'string' && message.trim() ? message : undefined
|
||||
return getApiErrorMessage(error)
|
||||
}
|
||||
|
||||
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
||||
|
||||
+5
-4
@@ -8,6 +8,7 @@ import i18n, { getCurrentLocale } from '@/plugins/i18n'
|
||||
import {
|
||||
ApiRequestError,
|
||||
createApiClients,
|
||||
getApiErrorMessage,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiBusinessFailure,
|
||||
isApiResponse,
|
||||
@@ -33,8 +34,8 @@ const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
||||
timeout: 'common.requestTimeout',
|
||||
}
|
||||
|
||||
/** 认证失效只负责代码签退;原始异常继续交给发起请求的业务界面处理。 */
|
||||
function handleAuthenticationFailure(): true {
|
||||
/** 只有认证失效(401)才清理会话并回到登录页;授权拒绝(403)保留会话交给业务处理。 */
|
||||
function handleUnauthorized(): true {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
authStore.logout()
|
||||
@@ -49,8 +50,7 @@ const { api, pluginApi } = createApiClients({
|
||||
hooks: {
|
||||
markServerOnline: globalOfflineStatus.markServerOnline,
|
||||
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
||||
onForbidden: handleAuthenticationFailure,
|
||||
onUnauthorized: handleAuthenticationFailure,
|
||||
onUnauthorized: handleUnauthorized,
|
||||
},
|
||||
notifier: {
|
||||
error: message => toast.error(message),
|
||||
@@ -98,6 +98,7 @@ if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
||||
export {
|
||||
ApiRequestError,
|
||||
createPluginInstanceApi,
|
||||
getApiErrorMessage,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiBusinessFailure,
|
||||
isApiResponse,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import { getApiErrorMessage } from '@/api/client'
|
||||
import type { User, PassKey } from '@/api/types'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -197,13 +197,9 @@ async function fetchUserInfo() {
|
||||
isProfileLoading.value = true
|
||||
profileLoadFailed.value = false
|
||||
try {
|
||||
const result: User = await api.get(`user/${userStore.userName}`)
|
||||
const result: User = await api.get('user/current')
|
||||
if (result) {
|
||||
accountInfo.value = 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
|
||||
applyUserProfile(result)
|
||||
// Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。
|
||||
void fetchPassKeyList()
|
||||
}
|
||||
@@ -238,47 +234,28 @@ async function saveAccountInfo() {
|
||||
}
|
||||
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
|
||||
|
||||
const oldUserName = accountInfo.value.name
|
||||
const oldAvatar = accountInfo.value.avatar
|
||||
isSaving.value = true
|
||||
try {
|
||||
// 请求数据独立于已确认快照,失败后保留当前输入供用户重试。
|
||||
const userData: User = {
|
||||
...accountInfo.value,
|
||||
// 请求数据只包含自助资料字段,失败后保留当前输入供用户重试。
|
||||
const userData: CurrentUserUpdatePayload = {
|
||||
email: accountInfo.value.email,
|
||||
avatar: currentAvatar.value,
|
||||
name: currentUserName.value,
|
||||
settings: accountInfo.value.settings,
|
||||
...(newPassword.value ? { password: newPassword.value } : {}),
|
||||
}
|
||||
|
||||
await api.put<null>('user/', userData, { feedback: 'silent' })
|
||||
|
||||
accountInfo.value.name = currentUserName.value
|
||||
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)
|
||||
}
|
||||
const updatedUser = await api.put<User>('user/current', userData, { feedback: 'silent' })
|
||||
applyUserProfile(updatedUser)
|
||||
userStore.setUserName(updatedUser.name)
|
||||
userStore.setAvatar(currentAvatar.value)
|
||||
// 凭据仅在服务端确认成功后清空,失败时保留输入便于修正或重试。
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
$toast.success(t('profile.saveSuccess'))
|
||||
} catch (error) {
|
||||
console.log('保存失败:', error)
|
||||
const message = getApiBusinessErrorMessage(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 }))
|
||||
}
|
||||
const message = getApiErrorMessage(error) || t('common.serverConnectionFailed')
|
||||
$toast.error(t('profile.saveFailed', { message }))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -291,6 +268,29 @@ interface VerifyPasswordPayload {
|
||||
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) {
|
||||
verifyTitle.value = title
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import UserProfileView from '@/views/user/UserProfileView.vue'
|
||||
import { type AxiosResponse } from 'axios'
|
||||
import type { PassKey } from '@/api/types'
|
||||
import { ApiRequestError } from '@/api/client'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createPassKey, createUser } from '@tests/support/factories/user'
|
||||
@@ -29,18 +31,28 @@ vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
function currentUser() {
|
||||
function currentUser(overrides: Partial<ReturnType<typeof createUser>> = {}) {
|
||||
return createUser({
|
||||
avatar: 'saved-avatar.png',
|
||||
email: 'alice@example.com',
|
||||
is_otp: false,
|
||||
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() {
|
||||
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 })
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`))
|
||||
})
|
||||
@@ -89,7 +101,7 @@ describe('UserProfileView', () => {
|
||||
|
||||
expect(await screen.findByDisplayValue('alice@example.com')).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')
|
||||
|
||||
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 () => {
|
||||
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 })
|
||||
})
|
||||
await renderProfile()
|
||||
@@ -113,7 +125,7 @@ describe('UserProfileView', () => {
|
||||
it('shows the loaded profile while the passkey badge request is still pending', async () => {
|
||||
const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>()
|
||||
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
|
||||
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 () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockResolvedValue({ success: true })
|
||||
mocks.apiPut.mockResolvedValue(currentUser())
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
@@ -178,6 +190,9 @@ describe('UserProfileView', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
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({
|
||||
settings: {
|
||||
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 () => {
|
||||
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 screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
@@ -205,15 +226,40 @@ describe('UserProfileView', () => {
|
||||
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith(
|
||||
'user/',
|
||||
'user/current',
|
||||
expect.objectContaining({
|
||||
email: 'updated@example.com',
|
||||
name: 'alice',
|
||||
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!')
|
||||
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 () => {
|
||||
@@ -223,16 +269,54 @@ describe('UserProfileView', () => {
|
||||
await screen.findByDisplayValue('alice@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 waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!'))
|
||||
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()
|
||||
})
|
||||
|
||||
it('shows an HTTP save failure and allows the same input to be retried', async () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
|
||||
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(currentUser())
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
@@ -247,7 +331,7 @@ describe('UserProfileView', () => {
|
||||
|
||||
it('prevents duplicate save submissions while a request is pending', async () => {
|
||||
mockSuccessfulLoad()
|
||||
let resolveUpdate!: (value: { success: boolean }) => void
|
||||
let resolveUpdate!: (value: ReturnType<typeof currentUser>) => void
|
||||
mocks.apiPut.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveUpdate = resolve
|
||||
@@ -261,7 +345,7 @@ describe('UserProfileView', () => {
|
||||
await fireEvent.click(save)
|
||||
expect(mocks.apiPut).toHaveBeenCalledOnce()
|
||||
|
||||
resolveUpdate({ success: true })
|
||||
resolveUpdate(currentUser())
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled())
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user