mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-10 16:04:37 +08:00
feat(api): add unified v2 response layer
This commit is contained in:
259
tests/test_api_v2.py
Normal file
259
tests/test_api_v2.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.apiv2_utils import (
|
||||
OPENAPI_V2_PATH,
|
||||
V2ResponseMiddleware,
|
||||
configure_v2_openapi,
|
||||
)
|
||||
from app.schemas.response import Response
|
||||
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def anyio_backend():
|
||||
"""使用 asyncio 运行异步接口测试。"""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_app() -> FastAPI:
|
||||
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
|
||||
app = FastAPI()
|
||||
app.add_middleware(V2ResponseMiddleware)
|
||||
|
||||
@app.get("/api/v1/items")
|
||||
async def get_v1_items() -> list[dict]:
|
||||
"""返回未封装的 v1 列表。"""
|
||||
return [{"id": 1}]
|
||||
|
||||
@app.get("/api/v2/items")
|
||||
async def get_v2_items() -> list[dict]:
|
||||
"""返回供 v2 适配器封装的列表。"""
|
||||
return [{"id": 1}]
|
||||
|
||||
@app.get("/api/v2/wrapped", response_model=Response)
|
||||
async def get_wrapped_response() -> Response:
|
||||
"""返回已经使用通用结构封装的响应。"""
|
||||
return Response(success=True, message="操作成功", data={"id": 1})
|
||||
|
||||
@app.get("/api/v2/error")
|
||||
async def get_error() -> None:
|
||||
"""返回供 v2 适配器转换的 HTTP 错误。"""
|
||||
raise HTTPException(status_code=400, detail="请求参数错误")
|
||||
|
||||
@app.get("/api/v2/validated/{item_id}")
|
||||
async def get_validated_item(item_id: int) -> dict:
|
||||
"""返回带路径参数校验的示例数据。"""
|
||||
return {"id": item_id}
|
||||
|
||||
@app.get("/api/v2/openai/v1/models")
|
||||
async def get_openai_models() -> dict:
|
||||
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
|
||||
return {"object": "list", "data": []}
|
||||
|
||||
@app.get("/api/v2/events")
|
||||
async def get_events() -> None:
|
||||
"""返回不应封装的 SSE 流。"""
|
||||
async def event_source():
|
||||
"""生成一条测试事件。"""
|
||||
yield "data: ok\n\n"
|
||||
|
||||
return StreamingResponse(event_source(), media_type="text/event-stream")
|
||||
|
||||
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
|
||||
async def get_v2_openapi_schema() -> dict:
|
||||
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
|
||||
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
|
||||
|
||||
configure_v2_openapi(app)
|
||||
return app
|
||||
|
||||
|
||||
def make_client(app: FastAPI) -> httpx.AsyncClient:
|
||||
"""创建不访问真实网络的 ASGI 测试客户端。"""
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
|
||||
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
|
||||
async with make_client(api_app) as client:
|
||||
v1_response = await client.get("/api/v1/items")
|
||||
v2_response = await client.get("/api/v2/items")
|
||||
|
||||
assert v1_response.json() == [{"id": 1}]
|
||||
assert v2_response.json() == {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"data": [{"id": 1}],
|
||||
}
|
||||
|
||||
|
||||
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
|
||||
"""已经使用 Response 的接口不应被重复封装。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/wrapped")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "操作成功"
|
||||
assert payload["data"] == {"id": 1}
|
||||
assert "success" not in payload["data"]
|
||||
|
||||
|
||||
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
|
||||
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/error")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"success": False,
|
||||
"message": "请求参数错误",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
|
||||
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
|
||||
"""v2 参数校验错误也应返回可直接展示的 message。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/validated/not-an-integer")
|
||||
|
||||
payload = response.json()
|
||||
assert response.status_code == 422
|
||||
assert payload["success"] is False
|
||||
assert payload["message"]
|
||||
assert payload["data"] == {}
|
||||
|
||||
|
||||
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
|
||||
"""OpenAI 等标准协议接口应保持原始响应结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/openai/v1/models")
|
||||
|
||||
assert response.json() == {"object": "list", "data": []}
|
||||
|
||||
|
||||
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
|
||||
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/events")
|
||||
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
assert response.text == "data: ok\n\n"
|
||||
|
||||
|
||||
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
|
||||
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get(OPENAPI_V2_PATH)
|
||||
|
||||
assert response.json() == {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test", "version": "1.0.0"},
|
||||
}
|
||||
|
||||
|
||||
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
|
||||
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
|
||||
schema = api_app.openapi()
|
||||
|
||||
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
|
||||
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
|
||||
stream_schema = schema["paths"]["/api/v2/events"]["get"]
|
||||
|
||||
assert raw_schema["content"]["application/json"]["schema"] == {
|
||||
"$ref": "#/components/schemas/Response"
|
||||
}
|
||||
assert protocol_schema["responses"]["200"]["content"]["application/json"][
|
||||
"schema"
|
||||
] != {"$ref": "#/components/schemas/Response"}
|
||||
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
|
||||
"schema"
|
||||
) != {"$ref": "#/components/schemas/Response"}
|
||||
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
|
||||
assert data_schema["title"] == "Data"
|
||||
assert {} in data_schema["anyOf"]
|
||||
|
||||
|
||||
def test_response_accepts_scalar_data():
|
||||
"""通用 Response 的 data 应支持标量接口数据。"""
|
||||
response = Response(success=True, data=1)
|
||||
|
||||
assert response.data == 1
|
||||
|
||||
|
||||
def test_v2_router_reuses_v1_route_endpoints():
|
||||
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app.api.apiv1 import api_router
|
||||
from app.api.apiv2 import api_router_v2
|
||||
|
||||
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
|
||||
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
|
||||
|
||||
assert len(v2_routes) == len(v1_routes)
|
||||
assert all(
|
||||
v2_route.path == v1_route.path
|
||||
and v2_route.methods == v1_route.methods
|
||||
and v2_route.endpoint is v1_route.endpoint
|
||||
for v1_route, v2_route in zip(v1_routes, v2_routes)
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
|
||||
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
|
||||
class FakeApp:
|
||||
"""记录动态注册路径的应用桩。"""
|
||||
|
||||
def __init__(self):
|
||||
self.routes = []
|
||||
self.openapi_schema = None
|
||||
|
||||
def add_api_route(self, **kwargs):
|
||||
"""记录新增的路由路径。"""
|
||||
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
||||
|
||||
def setup(self):
|
||||
"""模拟 FastAPI 路由重建。"""
|
||||
|
||||
class FakePluginManager:
|
||||
"""返回单个测试插件 API 的管理器桩。"""
|
||||
|
||||
def get_plugin_apis(self, plugin_id):
|
||||
"""返回测试插件 API。"""
|
||||
assert plugin_id == "DemoPlugin"
|
||||
return [
|
||||
{
|
||||
"path": "/DemoPlugin/health",
|
||||
"endpoint": lambda: {"ok": True},
|
||||
"methods": ["GET"],
|
||||
}
|
||||
]
|
||||
|
||||
fake_app = FakeApp()
|
||||
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||
|
||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
|
||||
|
||||
assert [route.path for route in fake_app.routes] == [
|
||||
"/api/v1/plugin/DemoPlugin/health",
|
||||
"/api/v2/plugin/DemoPlugin/health",
|
||||
]
|
||||
|
||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
||||
|
||||
assert fake_app.routes == []
|
||||
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||
expected_versions = {
|
||||
"database-operation": "3",
|
||||
"moviepilot-api": "7",
|
||||
"moviepilot-api": "8",
|
||||
"moviepilot-cli": "6",
|
||||
"moviepilot-update": "3",
|
||||
"transfer-failed-retry": "2",
|
||||
|
||||
@@ -6,11 +6,12 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.factory import localized_http_exception_handler
|
||||
from app.factory import create_app, localized_http_exception_handler
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
||||
from app.schemas.response import Response
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
def _has_chinese(text: str) -> bool:
|
||||
@@ -247,8 +248,8 @@ def test_response_auto_fills_message_i18n_from_locale_context():
|
||||
assert response.message_i18n == "Module does not support testing"
|
||||
|
||||
|
||||
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
||||
"""HTTPException 响应应补充多语言 detail 字段。"""
|
||||
def test_http_exception_handler_returns_untranslated_response_envelope():
|
||||
"""HTTPException 响应应统一封装并保留原始错误文本。"""
|
||||
token = LocaleHelper.set_current_locale("en-US")
|
||||
try:
|
||||
response = asyncio.run(
|
||||
@@ -260,9 +261,22 @@ def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
||||
finally:
|
||||
LocaleHelper.reset_current_locale(token)
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = json.loads(response.body)
|
||||
assert payload["detail"] == "用户名或密码错误"
|
||||
assert payload["detail_i18n"] == "Incorrect username or password"
|
||||
assert payload == {
|
||||
"success": False,
|
||||
"message": "用户名或密码错误",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
|
||||
def test_application_docs_use_v2_openapi_and_backend_version():
|
||||
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
|
||||
app = create_app()
|
||||
|
||||
assert app.openapi_url == "/api/v2/openapi.json"
|
||||
assert app.version == APP_VERSION
|
||||
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
|
||||
|
||||
|
||||
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
||||
|
||||
@@ -95,3 +95,23 @@ def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "用户名或密码错误"
|
||||
assert "X-MFA-Required" not in (exc_info.value.headers or {})
|
||||
|
||||
|
||||
def test_wallpaper_returns_url_in_data(monkeypatch):
|
||||
"""登录壁纸地址应放入 data,message 只保留消息文本。"""
|
||||
|
||||
class FakeWallpaperHelper:
|
||||
"""返回固定登录壁纸地址。"""
|
||||
|
||||
def get_wallpaper(self):
|
||||
"""返回测试壁纸地址。"""
|
||||
return "https://images.example/wallpaper.jpg"
|
||||
|
||||
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
|
||||
|
||||
response = login_endpoint.wallpaper()
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == "https://images.example/wallpaper.jpg"
|
||||
assert response.message is None
|
||||
assert response.message_i18n is None
|
||||
|
||||
Reference in New Issue
Block a user