diff --git a/app/api/apiv2.py b/app/api/apiv2.py new file mode 100644 index 00000000..96734310 --- /dev/null +++ b/app/api/apiv2.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.apiv1 import api_router + + +api_router_v2 = APIRouter() +api_router_v2.include_router(api_router) diff --git a/app/api/apiv2_utils.py b/app/api/apiv2_utils.py new file mode 100644 index 00000000..badac2e4 --- /dev/null +++ b/app/api/apiv2_utils.py @@ -0,0 +1,224 @@ +import json +from typing import Any, Awaitable, Callable + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response as StarletteResponse + +from app.schemas.response import Response + + +API_V2_STR = "/api/v2" +OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json" +_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp") +_JSON_CONTENT_TYPES = ("application/json", "+json") + + +def _is_protocol_path(path: str) -> bool: + """判断路径是否属于需要保留原始协议响应的接口。""" + relative_path = path.removeprefix(API_V2_STR) + return any( + relative_path == prefix or relative_path.startswith(f"{prefix}/") + for prefix in _PROTOCOL_PREFIXES + ) + + +def _is_json_response(response: StarletteResponse) -> bool: + """判断响应是否为可安全解析的 JSON 响应。""" + content_type = response.headers.get("content-type", "").split(";", 1)[0] + return any( + content_type == accepted_type or content_type.endswith(accepted_type) + for accepted_type in _JSON_CONTENT_TYPES + ) + + +def _is_response_payload(payload: Any) -> bool: + """判断响应内容是否已经符合通用 Response 结构。""" + return isinstance(payload, dict) and { + "success", + "message", + "data", + }.issubset(payload) + + +def _get_error_message(payload: Any) -> str: + """从旧版错误响应中提取统一的错误消息。""" + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str) and detail: + return detail + if isinstance(detail, list): + messages = [ + item.get("msg") + for item in detail + if isinstance(item, dict) and isinstance(item.get("msg"), str) + ] + if messages: + return "; ".join(messages) + if detail is not None: + return json.dumps(detail, ensure_ascii=False) + message = payload.get("message") + if isinstance(message, str) and message: + return message + if isinstance(payload, str) and payload: + return payload + return "请求失败" + + +def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None: + """复制适配前响应中仍然有效的头信息。""" + for key, value in source.raw_headers: + if key.lower() not in {b"content-length", b"content-type"}: + target.raw_headers.append((key, value)) + + +def _restore_response_body( + source: StarletteResponse, + body: bytes, +) -> StarletteResponse: + """在检查响应体后恢复原始响应内容和头信息。""" + restored_response = StarletteResponse( + content=body, + status_code=source.status_code, + background=source.background, + ) + restored_response.raw_headers = list(source.raw_headers) + return restored_response + + +class V2ResponseMiddleware(BaseHTTPMiddleware): + """ + 为 v2 REST 接口适配统一的 Response 响应结构。 + + 已经返回项目 Response 模型的成功响应保持原样,避免改变既有接口语义; + OpenAI、Anthropic 和 MCP 协议接口也保持原始协议响应。 + """ + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[StarletteResponse]], + ) -> StarletteResponse: + """处理 v2 请求并在必要时封装 JSON 响应。""" + response = await call_next(request) + if not request.url.path.startswith(f"{API_V2_STR}/"): + return response + if request.url.path == OPENAPI_V2_PATH: + return response + if _is_protocol_path(request.url.path): + return response + if response.status_code in {204, 304} or not _is_json_response(response): + return response + if response.headers.get("content-encoding"): + return response + + route = request.scope.get("route") + route_response_model = getattr(route, "response_model", None) + if response.status_code < 400 and route_response_model is Response: + return response + + body = b"".join([chunk async for chunk in response.body_iterator]) + if not body: + return _restore_response_body(response, body) + try: + payload = json.loads(body) + except (TypeError, ValueError): + return _restore_response_body(response, body) + + if _is_response_payload(payload): + return _restore_response_body(response, body) + + if response.status_code >= 400: + content = { + "success": False, + "message": _get_error_message(payload), + "data": {}, + } + if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str): + content["message_i18n"] = payload["detail_i18n"] + else: + content = { + "success": True, + "message": "", + "data": payload, + } + + wrapped_response = JSONResponse( + content=content, + status_code=response.status_code, + background=response.background, + ) + _copy_response_headers(response, wrapped_response) + return wrapped_response + + +def configure_v2_openapi(app: FastAPI) -> None: + """ + 将 v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response。 + + :param app: 已完成 v1/v2 路由注册的 FastAPI 应用 + """ + if getattr(app, "_v2_openapi_configured", False): + return + + original_openapi = app.openapi + + def custom_openapi() -> dict[str, Any]: + """生成包含 v2 通用响应模型的 OpenAPI 文档。""" + schema = original_openapi() + components = schema.setdefault("components", {}).setdefault("schemas", {}) + components["Response"] = Response.model_json_schema( + ref_template="#/components/schemas/{model}" + ) + + route_map = { + (route.path, method.lower()): route + for route in app.routes + if isinstance(route, APIRoute) + for method in route.methods + } + response_ref = {"$ref": "#/components/schemas/Response"} + for path, path_item in schema.get("paths", {}).items(): + if not path.startswith(f"{API_V2_STR}/"): + continue + for method, operation in path_item.items(): + if method not in { + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + }: + continue + route = route_map.get((path, method)) + if ( + route is None + or route.response_model is None + or route.response_model is Any + or route.response_model is Response + or _is_protocol_path(path) + ): + continue + if route.status_code in {204, 304}: + continue + content_type = getattr(route.response_class, "media_type", None) + if content_type and not ( + content_type == "application/json" or content_type.endswith("+json") + ): + continue + status_code = str(route.status_code or 200) + response = operation.get("responses", {}).get(status_code) + if response and "content" in response: + json_content = response["content"].get("application/json") + if json_content is not None: + json_content["schema"] = response_ref + + app.openapi_schema = schema + return schema + + app.openapi = custom_openapi + app._v2_openapi_configured = True diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 1b1098a7..ffc070c0 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -90,7 +90,7 @@ def wallpaper() -> Any: """ url = WallpaperHelper().get_wallpaper() if url: - return schemas.Response(success=True, message=url) + return schemas.Response(success=True, data=url) return schemas.Response(success=False) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 856c07fd..9dfe576f 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -11,6 +11,7 @@ from starlette import status from starlette.responses import StreamingResponse from app import schemas +from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH from app.command import Command from app.core.cache import async_fresh from app.core.config import settings @@ -36,8 +37,15 @@ from app.scheduler import Scheduler from app.schemas.event import PluginDataResetEventData from app.schemas.types import ChainEventType, SystemConfigKey -PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"} +PROTECTED_ROUTES = { + "/api/v1/openapi.json", + OPENAPI_V2_PATH, + "/docs", + "/docs/oauth2-redirect", + "/redoc", +} PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" +PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin" router = APIRouter() _plugin_release_refresh_tasks: set[asyncio.Task] = set() @@ -158,8 +166,11 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str): elif Depends(verify_apikey) not in dependencies: dependencies.append(Depends(verify_apikey)) app.add_api_route(**api, tags=["plugin"]) + v2_api = api.copy() + v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1) + app.add_api_route(**v2_api, tags=["plugin"]) is_modified = True - logger.debug(f"Added plugin route: {api_path}") + logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}") except Exception as e: logger.error(f"Error adding plugin route {api_path}: {str(e)}") @@ -177,8 +188,13 @@ def _remove_routes(plugin_id: str) -> bool: """ if not plugin_id: return False - prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" - routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)] + prefixes = { + f"{PLUGIN_PREFIX}/{plugin_id}/", + f"{PLUGIN_V2_PREFIX}/{plugin_id}/", + } + routes_to_remove = [ + route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes) + ] removed = False for route in routes_to_remove: try: diff --git a/app/factory.py b/app/factory.py index 403f29de..1ba3e678 100644 --- a/app/factory.py +++ b/app/factory.py @@ -1,12 +1,27 @@ -from typing import Awaitable, Callable +import json +from typing import Any, Awaitable, Callable from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware from app.core.config import settings from app.helper.locale import LocaleHelper from app.startup.lifecycle import lifespan +from version import APP_VERSION + + +def _get_http_exception_message(detail: Any) -> str: + """将 HTTPException 的 detail 转换为统一消息文本。""" + if isinstance(detail, str) and detail: + return detail + if detail is None: + return "请求失败" + try: + return json.dumps(detail, ensure_ascii=False) + except TypeError: + return str(detail) async def localized_http_exception_handler( @@ -14,18 +29,20 @@ async def localized_http_exception_handler( exc: HTTPException, ) -> JSONResponse: """ - 为 HTTPException 响应补充多语言错误详情。 + 将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。 :param _request: 当前 HTTP 请求 :param exc: FastAPI HTTP 异常 - :return: 带 detail_i18n 的 JSON 错误响应 + :return: 统一 JSON 错误响应 """ - content = {"detail": exc.detail} - if isinstance(exc.detail, str): - content["detail_i18n"] = LocaleHelper.translate_text(exc.detail) + message = _get_http_exception_message(exc.detail) return JSONResponse( status_code=exc.status_code, - content=content, + content={ + "success": False, + "message": message, + "data": {}, + }, headers=exc.headers, ) @@ -36,10 +53,16 @@ def create_app() -> FastAPI: """ _app = FastAPI( title=settings.PROJECT_NAME, - openapi_url=f"{settings.API_V1_STR}/openapi.json", + version=APP_VERSION, + openapi_url=OPENAPI_V2_PATH, lifespan=lifespan ) + @_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False) + def get_v1_openapi_schema() -> dict[str, Any]: + """保留旧版 OpenAPI 地址并返回当前完整接口文档。""" + return _app.openapi() + _app.add_exception_handler(HTTPException, localized_http_exception_handler) # 配置 CORS 中间件 @@ -50,6 +73,7 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + _app.add_middleware(V2ResponseMiddleware) @_app.middleware("http") async def locale_context_middleware( diff --git a/app/schemas/response.py b/app/schemas/response.py index 89df3165..a264442c 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Any, Optional from pydantic import BaseModel, Field, model_validator @@ -15,7 +15,7 @@ class Response(BaseModel): # 多语言消息文本 message_i18n: Optional[str] = None # 数据 - data: Optional[Union[dict, list]] = Field(default_factory=dict) + data: Optional[Any] = Field(default_factory=dict) @model_validator(mode="after") def fill_message_i18n(self) -> "Response": diff --git a/app/startup/routers_initializer.py b/app/startup/routers_initializer.py index 101b8603..4e999b81 100644 --- a/app/startup/routers_initializer.py +++ b/app/startup/routers_initializer.py @@ -8,10 +8,15 @@ def init_routers(app: FastAPI): 初始化路由 """ from app.api.apiv1 import api_router + from app.api.apiv2 import api_router_v2 + from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi from app.api.servarr import arr_router from app.api.servcookie import cookie_router # API路由 app.include_router(api_router, prefix=settings.API_V1_STR) + # v2 API复用v1路由,仅在响应出口统一封装 + app.include_router(api_router_v2, prefix=API_V2_STR) + configure_v2_openapi(app) # Radarr、Sonarr路由 app.include_router(arr_router, prefix="/api/v3") # CookieCloud路由 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 1fcfe47c..c588568b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -112,9 +112,23 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器 MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=`。 -标准 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。 +#### REST API 版本 -FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`。 +- `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`。 +- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。 +- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`。 +- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。 +- SSE、文件、图片、空响应,以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。 + +因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`。 + +通用 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。 + +`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中,`message` 不再承载业务数据。 + +FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。 + +交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。 #### 媒体识别 / 整理 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index d44a1974..45d3eb3c 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 7 +version: 8 description: >- Use this skill when you need to call MoviePilot REST API endpoints directly with the bundled Python client. Covers MoviePilot HTTP endpoints across media @@ -65,6 +65,25 @@ python scripts/mp-api.py [key=value ...] [--json ''] - Both methods validate against the same `API_TOKEN` value. - Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available. +### API versions and response envelopes + +- `/api/v1` preserves the existing endpoint-specific response shapes by + default; the login wallpaper URL is now returned in `data`. +- `/api/v2` reuses the same routes, parameters, authentication dependencies, + and business handlers, but wraps ordinary JSON responses in the shared + `Response` envelope. +- A successful raw v1 payload becomes + `{"success":true,"message":"","data":}` in v2. +- Existing `Response` payloads are not wrapped again. HTTP errors on both v1 + and v2 keep their original status code and expose the error text in + `message` with `data={}`. Non-business HTTP exceptions are not translated. +- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol + endpoints keep their protocol-native response body. + +Use `/api/v2` for app clients that require one JSON envelope. Any ordinary +REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without +changing its method, parameters, request body, or authentication. + ### Examples ```bash @@ -79,6 +98,9 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123 # Endpoints that require ?token= auth python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param + +# Uniform v2 JSON response envelope +python scripts/mp-api.py GET /api/v2/dashboard/cpu ``` ## Complete API Reference @@ -488,7 +510,7 @@ The list endpoint returns local cache totals plus `shared_recognized` and | Method | Path | Description | |--------|------|-------------| | POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) | -| GET | `/api/v1/login/wallpaper` | Login page wallpaper | +| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` | | GET | `/api/v1/login/wallpapers` | Login page wallpaper list | ### MCP Tools (6 endpoints) diff --git a/tests/test_api_v2.py b/tests/test_api_v2.py new file mode 100644 index 00000000..12d93659 --- /dev/null +++ b/tests/test_api_v2.py @@ -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 == [] diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 8adab2cd..a62138dc 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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", diff --git a/tests/test_locale_helper.py b/tests/test_locale_helper.py index 4b1eb0db..020af75b 100644 --- a/tests/test_locale_helper.py +++ b/tests/test_locale_helper.py @@ -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(): diff --git a/tests/test_login_mfa_methods.py b/tests/test_login_mfa_methods.py index 8fa9ccdd..7c0c4f8f 100644 --- a/tests/test_login_mfa_methods.py +++ b/tests/test_login_mfa_methods.py @@ -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