mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-21 08:22:02 +08:00
refactor(api): adopt unified responses and restore media config hints
This commit is contained in:
@@ -1,9 +1,73 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { abortAllRequests } from '@/utils/requestOptimizer'
|
||||
import { cleanup } from '@testing-library/vue'
|
||||
import { HttpResponse, type HttpResponseInit, type JsonBodyType } from 'msw'
|
||||
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
|
||||
import { createDataApiMock as buildDataApiMock } from './support/apiMock'
|
||||
import { server } from './support/msw/server'
|
||||
|
||||
declare global {
|
||||
// 测试文件中的 hoisted API mock 在应用模块加载前执行,因此通过 setup 注册统一适配器。
|
||||
var createDataApiMock: typeof buildDataApiMock
|
||||
}
|
||||
|
||||
globalThis.createDataApiMock = buildDataApiMock
|
||||
|
||||
const originalJsonResponse = HttpResponse.json.bind(HttpResponse)
|
||||
|
||||
/** 判断测试夹具是否已经表达了业务响应状态。 */
|
||||
function isLegacyApiEnvelope(body: unknown): body is Record<string, unknown> & { success: boolean } {
|
||||
return (
|
||||
body !== null &&
|
||||
typeof body === 'object' &&
|
||||
!Array.isArray(body) &&
|
||||
typeof (body as { success?: unknown }).success === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
/** 识别 MSW 夹具中历史遗留的 Axios `{ data }` 响应壳。 */
|
||||
function isLegacyDataWrapper(body: unknown): body is { data: unknown } {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return false
|
||||
return Object.keys(body).length === 1 && 'data' in body
|
||||
}
|
||||
|
||||
/**
|
||||
* 将业务测试中的裸成功数据适配为当前后端统一 envelope。
|
||||
*
|
||||
* 低层客户端协议测试使用 Axios adapter,不经过这里;HTTP 错误保持原载荷,
|
||||
* 便于继续覆盖 detail、Blob 和非标准错误响应的归一化行为。
|
||||
*/
|
||||
function installApiEnvelopeFixtureAdapter() {
|
||||
Object.defineProperty(HttpResponse, 'json', {
|
||||
configurable: true,
|
||||
value: <BodyType extends JsonBodyType>(body?: BodyType | null, init: HttpResponseInit = {}) => {
|
||||
const status = init.status ?? 200
|
||||
if (status >= 400) return originalJsonResponse(body, init)
|
||||
|
||||
if (isLegacyApiEnvelope(body)) {
|
||||
const envelope = body as Record<string, unknown> & { success: boolean }
|
||||
return originalJsonResponse(
|
||||
{
|
||||
...envelope,
|
||||
message: typeof envelope.message === 'string' ? envelope.message : '',
|
||||
data: Object.hasOwn(envelope, 'data') ? envelope.data : null,
|
||||
},
|
||||
init,
|
||||
)
|
||||
}
|
||||
|
||||
if (isLegacyDataWrapper(body)) {
|
||||
return originalJsonResponse({ success: true, message: '', data: body.data }, init)
|
||||
}
|
||||
|
||||
return originalJsonResponse({ success: true, message: '', data: body ?? null }, init)
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
installApiEnvelopeFixtureAdapter()
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
|
||||
59
tests/support/apiMock.ts
Normal file
59
tests/support/apiMock.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApiRequestError } from '@/api/client'
|
||||
|
||||
type ApiMethodMock = (...args: unknown[]) => unknown
|
||||
|
||||
/** 判断旧测试夹具是否是后端标准响应结构。 */
|
||||
function isApiEnvelope(value: unknown): value is { data?: unknown; message?: string; success: boolean } {
|
||||
return Boolean(value) && typeof value === 'object' && typeof (value as { success?: unknown }).success === 'boolean'
|
||||
}
|
||||
|
||||
/** 识别测试中历史遗留的 Axios `{ data }` 响应壳。 */
|
||||
function isLegacyDataWrapper(value: unknown): value is { data: unknown } {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
return Object.keys(value).length === 1 && 'data' in value
|
||||
}
|
||||
|
||||
/** 去掉请求层反馈选项,让业务测试的 spy 继续只关注端点、参数和载荷。 */
|
||||
function businessArguments(args: unknown[]) {
|
||||
const normalized = [...args]
|
||||
const config = normalized.at(-1)
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config) || !Object.hasOwn(config, 'feedback')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const businessConfig = { ...(config as Record<string, unknown>) }
|
||||
delete businessConfig.feedback
|
||||
if (Object.keys(businessConfig).length > 0) normalized[normalized.length - 1] = businessConfig
|
||||
else normalized.pop()
|
||||
if (normalized.at(-1) === undefined) normalized.pop()
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 让直接 mock 的 API 方法遵循生产数据客户端语义。
|
||||
*
|
||||
* 成功响应解包为 data,业务失败转为拒绝 Promise;裸数据保持不变。
|
||||
*/
|
||||
export function createDataApiMock<T extends Record<string, ApiMethodMock>>(methods: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(methods).map(([name, method]) => [
|
||||
name,
|
||||
async (...args: unknown[]) => {
|
||||
let result: unknown
|
||||
try {
|
||||
result = await method(...businessArguments(args))
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError) throw error
|
||||
if (error instanceof Error) throw new ApiRequestError(error.message, { cause: error })
|
||||
throw new ApiRequestError('服务器连接失败', { cause: error })
|
||||
}
|
||||
if (isLegacyDataWrapper(result)) return result.data
|
||||
if (!isApiEnvelope(result)) return result
|
||||
if (!result.success) {
|
||||
throw new ApiRequestError(result.message || '请求失败', { businessFailure: true, payload: result })
|
||||
}
|
||||
return Object.hasOwn(result, 'data') ? result.data : null
|
||||
},
|
||||
]),
|
||||
) as { [K in keyof T]: ApiMethodMock }
|
||||
}
|
||||
@@ -11,12 +11,24 @@ import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
interface SiteMutationResponse {
|
||||
interface ApiEnvelopeInput<T> {
|
||||
success: boolean
|
||||
data?: Record<string, unknown>
|
||||
data?: T | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
type SiteMutationResponse = ApiEnvelopeInput<Record<string, unknown>>
|
||||
|
||||
/** 构造站点测试请求的严格三段式网络响应。 */
|
||||
function apiEnvelope<T>(data: T | null, success = true, message = ''): ApiResponse<T> {
|
||||
return { data, message, success }
|
||||
}
|
||||
|
||||
/** 将测试用的简写输入规范化为后端实际发送的完整 envelope。 */
|
||||
function normalizeEnvelope<T>(input: ApiEnvelopeInput<T>): ApiResponse<T> {
|
||||
return apiEnvelope(input.data ?? null, input.success, input.message ?? '')
|
||||
}
|
||||
|
||||
export const siteApiUrls = {
|
||||
categories: (siteId: number) => new URL(`site/category/${siteId}`, API_BASE_URL).href,
|
||||
cookie: (id: number) => new URL(`site/cookie/${id}`, API_BASE_URL).href,
|
||||
@@ -41,7 +53,7 @@ export function siteListHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.list, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(sites as unknown as JsonBodyType, {
|
||||
return HttpResponse.json(apiEnvelope(sites) as unknown as JsonBodyType, {
|
||||
status: typeof status === 'function' ? status() : status,
|
||||
})
|
||||
})
|
||||
@@ -54,7 +66,7 @@ export function siteStatisticsHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.statistics, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(statistics as unknown as JsonBodyType, { status })
|
||||
return HttpResponse.json(apiEnvelope(statistics) as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +78,7 @@ export function siteStatisticHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.statistic(domain), async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(statistic as unknown as JsonBodyType, { status })
|
||||
return HttpResponse.json(apiEnvelope(statistic) as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,31 +89,31 @@ export function siteUserDataLatestHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.userDataLatest, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(userData as unknown as JsonBodyType, { status })
|
||||
return HttpResponse.json(apiEnvelope(userData) as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function siteUserDataHandler(
|
||||
id: number,
|
||||
result: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'>,
|
||||
result: ApiEnvelopeInput<SiteUserData[]>,
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.userData(id), async () => {
|
||||
await onRequest()
|
||||
return response(result as unknown as JsonBodyType, status)
|
||||
return response(normalizeEnvelope(result) as unknown as JsonBodyType, status)
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshSiteUserDataHandler(
|
||||
id: number,
|
||||
result: Pick<ApiResponse<Record<string, unknown>>, 'data' | 'message' | 'success'>,
|
||||
result: ApiEnvelopeInput<Record<string, unknown>>,
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.post(siteApiUrls.userData(id), async () => {
|
||||
await onRequest()
|
||||
return response(result as unknown as JsonBodyType, status)
|
||||
return response(normalizeEnvelope(result) as unknown as JsonBodyType, status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,10 +124,8 @@ export function saveSitePrioritiesHandler(
|
||||
return http.post(siteApiUrls.priorities, async ({ request }) => {
|
||||
const priorities = (await request.json()) as Array<{ id: number; pri: number }>
|
||||
await onSave(priorities)
|
||||
return HttpResponse.json(
|
||||
{ success: options.success ?? (options.status ?? 200) < 400 },
|
||||
{ status: options.status ?? 200 },
|
||||
)
|
||||
const success = options.success ?? (options.status ?? 200) < 400
|
||||
return HttpResponse.json(apiEnvelope(null, success), { status: options.status ?? 200 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,31 +141,31 @@ export function siteIconHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.icon(id), async () => {
|
||||
await onRequest()
|
||||
return response({ data: icon ? { icon } : {}, success: Boolean(icon) }, status)
|
||||
return response(apiEnvelope(icon ? { icon } : {}, Boolean(icon)), status)
|
||||
})
|
||||
}
|
||||
|
||||
export function testSiteConnectionHandler(
|
||||
id: number,
|
||||
result: Pick<ApiResponse<never>, 'message' | 'success'>,
|
||||
result: ApiEnvelopeInput<null>,
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.test(id), async () => {
|
||||
await onRequest()
|
||||
return response(result, status)
|
||||
return response(normalizeEnvelope(result), status)
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSiteHandler(
|
||||
id: number,
|
||||
result: Pick<ApiResponse<never>, 'message' | 'success'> = { success: true },
|
||||
result: ApiEnvelopeInput<null> = { success: true },
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.delete(siteApiUrls.delete(id), async () => {
|
||||
await onRequest()
|
||||
return response(result, status)
|
||||
return response(normalizeEnvelope(result), status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,7 +176,7 @@ export function siteDownloadersHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.downloaders, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||
return HttpResponse.json(apiEnvelope(response) as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,7 +188,7 @@ export function siteDetailsHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.details(id), async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||
return HttpResponse.json(apiEnvelope(response) as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,7 +199,7 @@ export function addSiteHandler(
|
||||
) {
|
||||
return http.post(siteApiUrls.list, async ({ request }) => {
|
||||
await onRequest((await request.json()) as Site)
|
||||
return HttpResponse.json(response, { status })
|
||||
return HttpResponse.json(normalizeEnvelope(response), { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,7 +210,7 @@ export function updateSiteHandler(
|
||||
) {
|
||||
return http.put(siteApiUrls.list, async ({ request }) => {
|
||||
await onRequest((await request.json()) as Site)
|
||||
return HttpResponse.json(response, { status })
|
||||
return HttpResponse.json(normalizeEnvelope(response), { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,7 +222,7 @@ export function updateSiteCookieHandler(
|
||||
) {
|
||||
return http.post(siteApiUrls.cookie(id), async ({ request }) => {
|
||||
await onRequest((await request.json()) as { code: string; password: string; username: string })
|
||||
return HttpResponse.json(response, { status })
|
||||
return HttpResponse.json(normalizeEnvelope(response), { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -224,7 +234,7 @@ export function siteCategoriesHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.categories(siteId), ({ request }) => {
|
||||
onRequest(new URL(request.url))
|
||||
return HttpResponse.json(response, { status })
|
||||
return HttpResponse.json(apiEnvelope(response), { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,6 +246,6 @@ export function siteResourcesHandler(
|
||||
) {
|
||||
return http.get(siteApiUrls.resources(siteId), ({ request }) => {
|
||||
onRequest(new URL(request.url))
|
||||
return HttpResponse.json(response, { status })
|
||||
return HttpResponse.json(apiEnvelope(response), { status })
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user