mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
refactor(api): adopt unified responses and restore media config hints
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosHeaders,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
type RawAxiosHeaders,
|
||||
} from 'axios'
|
||||
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const notifier: ApiFeedbackNotifier = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}
|
||||
|
||||
/** 构造 Axios 适配器响应,避免客户端单测依赖真实网络。 */
|
||||
function createResponse<T>(
|
||||
config: InternalAxiosRequestConfig,
|
||||
data: T,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {},
|
||||
): AxiosResponse<T> {
|
||||
return {
|
||||
config,
|
||||
data,
|
||||
headers: new AxiosHeaders(headers),
|
||||
status,
|
||||
statusText: status >= 400 ? 'Error' : 'OK',
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建始终成功返回指定数据的 Axios 适配器。 */
|
||||
function resolveWith<T>(data: T, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => createResponse(config, data, 200, headers)
|
||||
}
|
||||
|
||||
/** 创建带 Axios 响应元数据的 HTTP 失败适配器。 */
|
||||
function rejectWith<T>(data: T, status: number, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => {
|
||||
const response = createResponse(config, data, status, headers)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
}
|
||||
|
||||
/** 断言并收窄统一 API 错误,避免测试绕过 unknown 错误边界。 */
|
||||
function requireApiRequestError(reason: unknown): ApiRequestError {
|
||||
expect(reason).toBeInstanceOf(ApiRequestError)
|
||||
if (!(reason instanceof ApiRequestError)) throw reason
|
||||
return reason
|
||||
}
|
||||
|
||||
describe('MoviePilot API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(notifier.error).mockReset()
|
||||
vi.mocked(notifier.success).mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['object', { id: 7 }],
|
||||
['array', ['one', 'two']],
|
||||
['scalar', 42],
|
||||
['null', null],
|
||||
])('仅向内部调用方返回成功 envelope 的 data:%s', async (_label, data) => {
|
||||
const envelope: ApiResponse<typeof data> = { success: true, message: '', data }
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(api.get<typeof data>('/resource')).resolves.toEqual(data)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只在 feedback=all 时展示后端成功消息', async () => {
|
||||
const envelope: ApiResponse<{ saved: boolean }> = {
|
||||
success: true,
|
||||
message: 'Saved',
|
||||
data: { saved: true },
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await api.post('/setting', {}, { feedback: 'default' })
|
||||
await api.post('/setting', {}, { feedback: 'all' })
|
||||
|
||||
expect(notifier.success).toHaveBeenCalledOnce()
|
||||
expect(notifier.success).toHaveBeenCalledWith('Saved')
|
||||
})
|
||||
|
||||
it('将 success=false 转为保留完整响应元数据的 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ reason: string }> = {
|
||||
success: false,
|
||||
message: 'Cannot save',
|
||||
data: { reason: 'locked' },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith(envelope, { 'x-request-id': 'request-1' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/setting', {}).catch(reason => reason))
|
||||
|
||||
expect(axios.isAxiosError(error)).toBe(true)
|
||||
expect(error).toMatchObject({
|
||||
message: 'Cannot save',
|
||||
payload: envelope,
|
||||
status: 200,
|
||||
})
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.headers as RawAxiosHeaders | undefined).get('x-request-id')).toBe('request-1')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||
})
|
||||
|
||||
it('将 HTTP 错误统一转换为 ApiRequestError 并保留 payload、status 和 headers', async () => {
|
||||
const envelope: ApiResponse<{ mfa_methods: string[] }> = {
|
||||
success: false,
|
||||
message: 'Additional verification required',
|
||||
data: { mfa_methods: ['otp'] },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(envelope, 403, { 'x-mfa-required': 'true' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/login/access-token', {}).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Additional verification required')
|
||||
expect(error.status).toBe(403)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.response?.headers as RawAxiosHeaders | undefined).get('x-mfa-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('silent 关闭失败 Toast,但不丢失错误元数据', async () => {
|
||||
const envelope: ApiResponse<null> = { success: false, message: 'Invalid credentials', data: null }
|
||||
const { api } = createApiClients({ adapter: rejectWith(envelope, 401), notifier })
|
||||
|
||||
const error = requireApiRequestError(
|
||||
await api.post('/login/access-token', {}, { feedback: 'silent' }).catch(reason => reason),
|
||||
)
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('插件客户端保留完整 envelope,同时沿用统一失败反馈', async () => {
|
||||
const envelope: ApiResponse<{ plugin: string }> = {
|
||||
success: false,
|
||||
message: 'Plugin rejected request',
|
||||
data: { plugin: 'demo' },
|
||||
}
|
||||
const { pluginApi } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(pluginApi.get<unknown, ApiResponse<{ plugin: string }>>('/plugin/demo')).resolves.toEqual(envelope)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Plugin rejected request')
|
||||
})
|
||||
|
||||
it('Blob 成功响应绕过 envelope 解包', async () => {
|
||||
const blob = new Blob(['moviepilot'], { type: 'application/octet-stream' })
|
||||
const { api } = createApiClients({ adapter: resolveWith(blob), notifier })
|
||||
|
||||
await expect(api.get<Blob>('/download', { responseType: 'blob' })).resolves.toBe(blob)
|
||||
})
|
||||
|
||||
it('解析 Blob 中的 JSON HTTP 错误后再构造 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ file: string }> = {
|
||||
success: false,
|
||||
message: 'File is unavailable',
|
||||
data: { file: 'movie.mkv' },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(envelope)], { type: 'application/json' })
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(blob, 404, { 'content-type': 'application/json' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/download', { responseType: 'blob' }).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('File is unavailable')
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
})
|
||||
|
||||
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async config => {
|
||||
throw new CanceledError('Request cancelled', config)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||
|
||||
const error = await api.get('/slow').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(CanceledError)
|
||||
expect(error).not.toBeInstanceOf(ApiRequestError)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('拒绝带旧顶层 message_i18n 的 envelope', async () => {
|
||||
const legacyEnvelope = {
|
||||
success: false,
|
||||
message: 'Failed',
|
||||
message_i18n: '旧版错误',
|
||||
data: null,
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(legacyEnvelope), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
expect(error.payload).toEqual(legacyEnvelope)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '服务器返回了无效响应'],
|
||||
['en-US', 'The server returned an invalid response'],
|
||||
])('使用应用注入的 %s fallback 文案提示协议异常', async (_locale, translatedMessage) => {
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith({ value: 1 }),
|
||||
notifier,
|
||||
resolveFallbackMessage: key => (key === 'invalid-envelope' ? translatedMessage : key),
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe(translatedMessage)
|
||||
expect(notifier.error).toHaveBeenCalledWith(translatedMessage)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18nT: vi.fn((key: string) => `translated:${key}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({ logout: vi.fn(), token: null }),
|
||||
}))
|
||||
vi.mock('@/utils/requestOptimizer', () => ({
|
||||
initializeRequestOptimizer: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/composables/useOfflineStatus', () => ({
|
||||
useGlobalOfflineStatus: () => ({
|
||||
markServerOnline: vi.fn(),
|
||||
reportNetworkError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
default: { global: { t: mocks.i18nT } },
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('API application wiring', () => {
|
||||
it('向 window 暴露插件 envelope 客户端,而内部默认导出数据客户端', async () => {
|
||||
const module = await import('@/api')
|
||||
|
||||
expect(window.MoviePilotAPI).toBe(module.pluginApi)
|
||||
expect(module.default).not.toBe(module.pluginApi)
|
||||
})
|
||||
|
||||
it('通过 i18n 实例解析请求层 fallback 文案', async () => {
|
||||
const module = await import('@/api')
|
||||
module.default.defaults.adapter = async config => ({
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
data: { legacy: true },
|
||||
headers: new AxiosHeaders(),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const error = await module.default.get('/legacy').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(module.ApiRequestError)
|
||||
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
||||
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user