Files
MoviePilot-Frontend/src/api/__tests__/client.spec.ts
T

238 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})