mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 02:54:20 +08:00
768 lines
28 KiB
Python
768 lines
28 KiB
Python
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi import FastAPI, HTTPException
|
||
from pydantic import BaseModel, ValidationError
|
||
from starlette.responses import StreamingResponse
|
||
|
||
from app.api.response import (
|
||
RAW_RESPONSE_OPENAPI_KEY,
|
||
ResponseAPIRoute,
|
||
ResponseAPIRouter,
|
||
)
|
||
from app.factory import (
|
||
localized_http_exception_handler,
|
||
localized_unhandled_exception_handler,
|
||
localized_validation_exception_handler,
|
||
)
|
||
from app.helper.locale import LocaleHelper
|
||
from app.schemas.common import JsonData
|
||
from app.schemas.response import Response
|
||
|
||
|
||
pytestmark = pytest.mark.anyio
|
||
|
||
|
||
class Item(BaseModel):
|
||
"""统一响应测试使用的业务数据模型。"""
|
||
|
||
id: int
|
||
|
||
|
||
@pytest.fixture()
|
||
def anyio_backend():
|
||
"""使用 asyncio 运行异步接口测试。"""
|
||
return "asyncio"
|
||
|
||
|
||
@pytest.fixture()
|
||
def api_app() -> FastAPI:
|
||
"""构造使用统一响应路由的最小测试应用。"""
|
||
app = FastAPI()
|
||
app.router.route_class = ResponseAPIRoute
|
||
app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||
from fastapi.exceptions import RequestValidationError
|
||
|
||
app.add_exception_handler(
|
||
RequestValidationError,
|
||
localized_validation_exception_handler,
|
||
)
|
||
app.add_exception_handler(Exception, localized_unhandled_exception_handler)
|
||
|
||
@app.middleware("http")
|
||
async def locale_middleware(request, call_next):
|
||
"""在测试应用中模拟生产环境的请求语言上下文。"""
|
||
token = LocaleHelper.set_current_locale(
|
||
LocaleHelper.get_locale_from_request(request)
|
||
)
|
||
try:
|
||
return await call_next(request)
|
||
finally:
|
||
LocaleHelper.reset_current_locale(token)
|
||
|
||
@app.get("/items", response_model=list[Item])
|
||
async def get_items() -> list[Item]:
|
||
"""返回需要自动封装的业务数据。"""
|
||
return [Item(id=1)]
|
||
|
||
@app.get("/wrapped", response_model=Response[Item])
|
||
async def get_wrapped_response() -> Response[Item]:
|
||
"""返回已经封装的响应。"""
|
||
return Response(success=True, message="模块不支持测试", data=Item(id=2))
|
||
|
||
@app.get(
|
||
"/oauth-token",
|
||
response_model=Item,
|
||
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||
)
|
||
async def get_oauth_token() -> Item:
|
||
"""模拟必须保持顶层字段的标准协议响应。"""
|
||
return Item(id=3)
|
||
|
||
@app.get("/error")
|
||
async def get_error() -> None:
|
||
"""抛出需要统一处理的 HTTP 错误。"""
|
||
raise HTTPException(status_code=400, detail="用户名或密码错误")
|
||
|
||
@app.get("/validated/{item_id}", response_model=Item)
|
||
async def get_validated_item(item_id: int) -> Item:
|
||
"""返回带路径参数校验的业务数据。"""
|
||
return Item(id=item_id)
|
||
|
||
@app.get("/crash", response_model=Item)
|
||
async def get_crash() -> Item:
|
||
"""抛出需要隐藏内部细节的未捕获异常。"""
|
||
raise RuntimeError("private failure detail")
|
||
|
||
@app.get("/native", response_model=None)
|
||
async def get_native_response() -> dict[str, bool]:
|
||
"""返回显式旁路的原生 JSON 协议。"""
|
||
return {"native": True}
|
||
|
||
@app.get("/events", response_model=None)
|
||
async def get_events() -> StreamingResponse:
|
||
"""返回不应封装的事件流。"""
|
||
|
||
async def event_source():
|
||
"""生成一条测试事件。"""
|
||
yield "data: ok\n\n"
|
||
|
||
return StreamingResponse(event_source(), media_type="text/event-stream")
|
||
|
||
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_route_wraps_data_and_keeps_existing_response(api_app: FastAPI):
|
||
"""普通数据应自动封装,已经封装的响应不应重复套壳。"""
|
||
async with make_client(api_app) as client:
|
||
items_response = await client.get("/items")
|
||
wrapped_response = await client.get("/wrapped")
|
||
|
||
assert items_response.json() == {
|
||
"success": True,
|
||
"message": "",
|
||
"data": [{"id": 1}],
|
||
}
|
||
assert wrapped_response.json() == {
|
||
"success": True,
|
||
"message": "模块不支持测试",
|
||
"data": {"id": 2},
|
||
}
|
||
|
||
|
||
async def test_explicit_none_and_stream_keep_native_protocol(api_app: FastAPI):
|
||
"""显式无响应模型和流式响应应保持原生协议。"""
|
||
async with make_client(api_app) as client:
|
||
native_response = await client.get("/native")
|
||
stream_response = await client.get("/events")
|
||
|
||
assert native_response.json() == {"native": True}
|
||
assert stream_response.headers["content-type"].startswith("text/event-stream")
|
||
assert stream_response.text == "data: ok\n\n"
|
||
|
||
|
||
async def test_accept_language_localizes_success_and_http_error(api_app: FastAPI):
|
||
"""Accept-Language 应直接决定成功与 HTTP 错误响应的 message。"""
|
||
async with make_client(api_app) as client:
|
||
wrapped_response = await client.get(
|
||
"/wrapped", headers={"Accept-Language": "en-US"}
|
||
)
|
||
error_response = await client.get(
|
||
"/error", headers={"Accept-Language": "en-US"}
|
||
)
|
||
zh_error_response = await client.get(
|
||
"/error", headers={"Accept-Language": "zh-CN"}
|
||
)
|
||
|
||
assert wrapped_response.json()["message"] == "Module does not support testing"
|
||
assert error_response.status_code == 400
|
||
assert error_response.json() == {
|
||
"success": False,
|
||
"message": "Incorrect username or password",
|
||
"data": None,
|
||
}
|
||
assert zh_error_response.json()["message"] == "用户名或密码错误"
|
||
|
||
|
||
async def test_validation_error_uses_unified_model(api_app: FastAPI):
|
||
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
||
async with make_client(api_app) as client:
|
||
response = await client.get(
|
||
"/validated/not-an-integer",
|
||
headers={"Accept-Language": "en-US"},
|
||
)
|
||
zh_response = await client.get(
|
||
"/validated/not-an-integer",
|
||
headers={"Accept-Language": "zh-CN"},
|
||
)
|
||
|
||
payload = response.json()
|
||
assert response.status_code == 422
|
||
assert payload["success"] is False
|
||
assert payload["message"] == "Request parameters are incorrect"
|
||
assert payload["data"] == [
|
||
{
|
||
"location": ["path", "item_id"],
|
||
"message": "Input should be a valid integer, unable to parse string as an integer",
|
||
"error_type": "int_parsing",
|
||
}
|
||
]
|
||
assert zh_response.json()["message"] == "请求参数不正确"
|
||
|
||
|
||
async def test_unhandled_exception_uses_localized_unified_response(api_app: FastAPI):
|
||
"""未捕获异常应返回本地化统一响应且不泄露内部错误。"""
|
||
async with httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(
|
||
app=api_app,
|
||
raise_app_exceptions=False,
|
||
),
|
||
base_url="http://testserver",
|
||
) as client:
|
||
response = await client.get(
|
||
"/crash",
|
||
headers={"Accept-Language": "en-US"},
|
||
)
|
||
|
||
assert response.status_code == 500
|
||
assert response.json() == {
|
||
"success": False,
|
||
"message": "Unknown error",
|
||
"data": None,
|
||
}
|
||
assert "private failure detail" not in response.text
|
||
|
||
|
||
def test_openapi_declares_generic_success_and_error_models(api_app: FastAPI):
|
||
"""OpenAPI 应展示业务数据类型及统一的 HTTP/422 错误响应结构。"""
|
||
schema = api_app.openapi()
|
||
operation = schema["paths"]["/items"]["get"]
|
||
success_ref = operation["responses"]["200"]["content"]["application/json"][
|
||
"schema"
|
||
]["$ref"]
|
||
validation_ref = operation["responses"]["422"]["content"][
|
||
"application/json"
|
||
]["schema"]["$ref"]
|
||
|
||
assert success_ref.endswith("/Response_list_Item__")
|
||
assert validation_ref.endswith("/Response_list_ValidationIssue__")
|
||
assert "HTTPValidationError" not in schema["components"]["schemas"]
|
||
success_schema = schema["components"]["schemas"][success_ref.rsplit("/", 1)[-1]]
|
||
assert success_schema["required"] == ["success", "message", "data"]
|
||
|
||
|
||
async def test_openapi_marker_keeps_oauth_payload_at_top_level(api_app: FastAPI):
|
||
"""显式原生标记应保留 OAuth 等标准协议的顶层字段及模型。"""
|
||
async with make_client(api_app) as client:
|
||
response = await client.get("/oauth-token")
|
||
|
||
operation = api_app.openapi()["paths"]["/oauth-token"]["get"]
|
||
schema_ref = operation["responses"]["200"]["content"]["application/json"][
|
||
"schema"
|
||
]["$ref"]
|
||
|
||
assert response.json() == {"id": 3}
|
||
assert schema_ref.endswith("/Item")
|
||
assert operation[RAW_RESPONSE_OPENAPI_KEY] is True
|
||
validation_ref = operation["responses"]["422"]["content"][
|
||
"application/json"
|
||
]["schema"]["$ref"]
|
||
assert validation_ref.endswith("/Response_list_ValidationIssue__")
|
||
|
||
|
||
def test_response_localizes_zh_en_and_falls_back_to_source():
|
||
"""Response 应支持中英文上下文,未知文案按原文回退。"""
|
||
zh_token = LocaleHelper.set_current_locale("zh-CN")
|
||
try:
|
||
zh_response = Response[None](success=False, message="用户名或密码错误")
|
||
finally:
|
||
LocaleHelper.reset_current_locale(zh_token)
|
||
|
||
en_token = LocaleHelper.set_current_locale("en-US")
|
||
try:
|
||
en_response = Response[None](success=False, message="用户名或密码错误")
|
||
fallback_response = Response[None](success=False, message="未登记的新错误文案")
|
||
finally:
|
||
LocaleHelper.reset_current_locale(en_token)
|
||
|
||
assert zh_response.message == "用户名或密码错误"
|
||
assert en_response.message == "Incorrect username or password"
|
||
assert fallback_response.message == "未登记的新错误文案"
|
||
|
||
|
||
def test_response_defaults_are_serialized_despite_required_openapi_fields():
|
||
"""省略默认值构造仍应在序列化结果中完整输出三块结构。"""
|
||
response = Response[None](success=True)
|
||
|
||
assert response.model_dump() == {
|
||
"success": True,
|
||
"message": "",
|
||
"data": None,
|
||
}
|
||
assert Response[None].model_json_schema()["required"] == [
|
||
"success",
|
||
"message",
|
||
"data",
|
||
]
|
||
|
||
|
||
def test_response_rejects_fields_outside_unified_protocol():
|
||
"""统一响应顶层只允许 success、message、data 三个字段。"""
|
||
with pytest.raises(ValidationError):
|
||
Response[None](success=True, message_i18n="unexpected")
|
||
|
||
|
||
def test_v1_routes_use_response_route_except_native_protocols():
|
||
"""v1 普通接口应使用统一路由,标准协议路由保持原生实现。"""
|
||
from fastapi.routing import APIRoute
|
||
|
||
from app.api.apiv1 import api_router
|
||
|
||
api_routes = [
|
||
route for route in api_router.routes if isinstance(route, APIRoute)
|
||
]
|
||
native_paths = {
|
||
"/openai/v1/models",
|
||
"/openai/v1/chat/completions",
|
||
"/openai/v1/responses",
|
||
"/anthropic/v1/messages",
|
||
}
|
||
|
||
assert all(
|
||
isinstance(route, ResponseAPIRoute) or route.path in native_paths
|
||
for route in api_routes
|
||
)
|
||
|
||
|
||
def test_v1_json_routes_have_concrete_data_models():
|
||
"""普通 v1 JSON 路由禁止未参数化、Any 或通用 JSON 顶层输出模型。"""
|
||
from fastapi.routing import APIRoute
|
||
|
||
from app.api.apiv1 import api_router
|
||
|
||
weak_routes = []
|
||
for route in api_router.routes:
|
||
if not isinstance(route, APIRoute):
|
||
continue
|
||
response_model = route.response_model
|
||
try:
|
||
is_response_model = issubclass(response_model, Response)
|
||
except TypeError:
|
||
is_response_model = False
|
||
if not is_response_model:
|
||
continue
|
||
generic_args = response_model.__pydantic_generic_metadata__.get("args")
|
||
if not generic_args or generic_args in ((Any,), (JsonData,)):
|
||
weak_routes.append((route.path, route.name, generic_args))
|
||
|
||
assert weak_routes == []
|
||
|
||
|
||
def test_v1_model_free_routes_match_audited_native_allowlist():
|
||
"""无响应模型仅允许固定的协议、流、文件、图片、HTML 与 204 路由。"""
|
||
from app.api.apiv1 import api_router
|
||
|
||
expected_routes = {
|
||
("/message/", "incoming_verify"),
|
||
("/message/agent/file/{file_id}", "download_web_agent_file"),
|
||
("/message/agent/stream", "web_agent_stream"),
|
||
("/search/media/{media_id}/stream", "search_by_id_stream"),
|
||
("/search/title/stream", "search_by_title_stream"),
|
||
("/search/subtitle/title/stream", "search_subtitle_by_title_stream"),
|
||
(
|
||
"/search/subtitle/media/{media_id}/stream",
|
||
"search_subtitle_by_id_stream",
|
||
),
|
||
("/system/img/{proxy}", "proxy_img"),
|
||
("/system/cache/image", "cache_img"),
|
||
("/system/progress/{process_type}", "get_progress"),
|
||
("/system/message", "get_message"),
|
||
("/system/logging", "get_logging"),
|
||
("/system/logging/download/{name}", "download_logging"),
|
||
(
|
||
"/llm/provider-auth/callback/{provider_id}",
|
||
"llm_provider_auth_callback",
|
||
),
|
||
("/plugin/file/{plugin_id}/{filepath:path}", "plugin_static_file"),
|
||
("/storage/download", "download"),
|
||
("/storage/image", "image"),
|
||
("/mcp", "delete_mcp_session"),
|
||
}
|
||
actual_routes = {
|
||
(route.path, route.name)
|
||
for route in api_router.routes
|
||
if isinstance(route, ResponseAPIRoute) and route.response_model is None
|
||
}
|
||
|
||
assert actual_routes == expected_routes
|
||
|
||
|
||
def test_native_protocol_openapi_has_explicit_response_schemas():
|
||
"""OpenAI、Anthropic 与 MCP 原生协议响应必须在 OpenAPI 中明确建模。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
schema = app.openapi()
|
||
operations = {
|
||
("/api/v1/openai/v1/models", "get"): "OpenAIErrorResponse",
|
||
("/api/v1/openai/v1/chat/completions", "post"): "OpenAIErrorResponse",
|
||
("/api/v1/openai/v1/responses", "post"): "OpenAIErrorResponse",
|
||
("/api/v1/anthropic/v1/messages", "post"): "AnthropicErrorResponse",
|
||
}
|
||
|
||
for (path, method), error_model in operations.items():
|
||
success_content = schema["paths"][path][method]["responses"]["200"][
|
||
"content"
|
||
]
|
||
response_schema = success_content["application/json"]["schema"]
|
||
assert response_schema
|
||
if path.endswith(("/chat/completions", "/messages")):
|
||
assert success_content["text/event-stream"]["schema"] == {
|
||
"type": "string"
|
||
}
|
||
for status_code in ("400", "401", "422", "500", "503"):
|
||
error_schema = schema["paths"][path][method]["responses"][status_code][
|
||
"content"
|
||
]["application/json"]["schema"]
|
||
assert error_schema["$ref"].endswith(f"/{error_model}")
|
||
|
||
mcp_schema = schema["paths"]["/api/v1/mcp"]["post"]["responses"]["200"][
|
||
"content"
|
||
]["application/json"]["schema"]
|
||
assert len(mcp_schema["anyOf"]) == 2
|
||
post_responses = schema["paths"]["/api/v1/mcp"]["post"]["responses"]
|
||
delete_responses = schema["paths"]["/api/v1/mcp"]["delete"]["responses"]
|
||
assert delete_responses["204"] == {"description": "MCP 会话已终止"}
|
||
for status_code in ("400", "401", "403", "404", "409", "422", "500"):
|
||
for responses in (post_responses, delete_responses):
|
||
error_ref = responses[status_code]["content"]["application/json"][
|
||
"schema"
|
||
]["$ref"]
|
||
assert error_ref.endswith("/McpJsonRpcError")
|
||
|
||
|
||
async def test_native_protocol_validation_errors_keep_native_shapes():
|
||
"""OpenAI 与 Anthropic 的请求校验错误应保持各自协议的错误结构。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
async with make_client(app) as client:
|
||
openai_response = await client.post(
|
||
"/api/v1/openai/v1/chat/completions",
|
||
json={"messages": "invalid"},
|
||
)
|
||
openai_responses_response = await client.post(
|
||
"/api/v1/openai/v1/responses",
|
||
json={},
|
||
)
|
||
anthropic_response = await client.post(
|
||
"/api/v1/anthropic/v1/messages",
|
||
json={"messages": "invalid"},
|
||
)
|
||
|
||
assert openai_response.status_code == 422
|
||
assert openai_response.json() == {
|
||
"error": {
|
||
"message": "Input should be a valid list",
|
||
"type": "invalid_request_error",
|
||
"param": "messages",
|
||
"code": "invalid_request_error",
|
||
}
|
||
}
|
||
assert openai_responses_response.status_code == 422
|
||
assert openai_responses_response.json()["error"]["type"] == (
|
||
"invalid_request_error"
|
||
)
|
||
assert openai_responses_response.json()["error"]["param"] == "input"
|
||
assert anthropic_response.status_code == 422
|
||
assert anthropic_response.json() == {
|
||
"type": "error",
|
||
"error": {
|
||
"type": "invalid_request_error",
|
||
"message": "messages: Input should be a valid list",
|
||
},
|
||
}
|
||
|
||
|
||
async def test_mcp_root_auth_error_keeps_jsonrpc_shape():
|
||
"""MCP 根端点的依赖异常应保持 JSON-RPC,REST 子端点仍由统一协议处理。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
async with make_client(app) as client:
|
||
response = await client.post(
|
||
"/api/v1/mcp",
|
||
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
||
)
|
||
|
||
payload = response.json()
|
||
assert response.status_code == 401
|
||
assert payload["jsonrpc"] == "2.0"
|
||
assert payload["id"] is None
|
||
assert payload["error"]["code"] == -32001
|
||
assert "success" not in payload
|
||
|
||
|
||
async def test_native_ai_http_and_unhandled_errors_keep_protocol_shapes():
|
||
"""兼容协议的依赖异常与未捕获异常都应返回原生错误体。"""
|
||
from starlette.requests import Request
|
||
|
||
def request_for(path: str) -> Request:
|
||
"""构造直接调用异常处理器所需的最小请求对象。"""
|
||
return Request(
|
||
{
|
||
"type": "http",
|
||
"method": "POST",
|
||
"path": path,
|
||
"headers": [],
|
||
"query_string": b"",
|
||
"server": ("testserver", 80),
|
||
"client": ("testclient", 123),
|
||
"scheme": "http",
|
||
}
|
||
)
|
||
|
||
openai_http = await localized_http_exception_handler(
|
||
request_for("/api/v1/openai/v1/chat/completions"),
|
||
HTTPException(status_code=401, detail="Invalid bearer token."),
|
||
)
|
||
anthropic_http = await localized_http_exception_handler(
|
||
request_for("/api/v1/anthropic/v1/messages"),
|
||
HTTPException(status_code=403, detail="invalid x-api-key"),
|
||
)
|
||
openai_crash = await localized_unhandled_exception_handler(
|
||
request_for("/api/v1/openai/v1/responses"),
|
||
RuntimeError("private openai failure"),
|
||
)
|
||
anthropic_crash = await localized_unhandled_exception_handler(
|
||
request_for("/api/v1/anthropic/v1/messages"),
|
||
RuntimeError("private anthropic failure"),
|
||
)
|
||
|
||
openai_http_payload = openai_http.body.decode()
|
||
anthropic_http_payload = anthropic_http.body.decode()
|
||
openai_crash_payload = openai_crash.body.decode()
|
||
anthropic_crash_payload = anthropic_crash.body.decode()
|
||
assert openai_http.status_code == 401
|
||
assert '"type":"authentication_error"' in openai_http_payload
|
||
assert '"success"' not in openai_http_payload
|
||
assert anthropic_http.status_code == 403
|
||
assert '"type":"authentication_error"' in anthropic_http_payload
|
||
assert '"success"' not in anthropic_http_payload
|
||
assert openai_crash.status_code == 500
|
||
assert '"type":"server_error"' in openai_crash_payload
|
||
assert "private openai failure" not in openai_crash_payload
|
||
assert anthropic_crash.status_code == 500
|
||
assert '"type":"api_error"' in anthropic_crash_payload
|
||
assert "private anthropic failure" not in anthropic_crash_payload
|
||
|
||
|
||
def test_servarr_and_cookiecloud_openapi_has_explicit_models():
|
||
"""兼容协议成功响应必须显式建模,错误响应必须声明统一结构。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
schema = app.openapi()
|
||
compatible_paths = [
|
||
path
|
||
for path in schema["paths"]
|
||
if path.startswith(("/api/v3", "/cookiecloud"))
|
||
]
|
||
|
||
assert compatible_paths
|
||
for path in compatible_paths:
|
||
for operation in schema["paths"][path].values():
|
||
success_response = operation["responses"].get("200")
|
||
if success_response:
|
||
success_schemas = [
|
||
content["schema"]
|
||
for content in success_response.get("content", {}).values()
|
||
]
|
||
assert success_schemas and all(success_schemas)
|
||
for status_code in ("400", "401", "403", "404", "409", "422", "500"):
|
||
error_response = operation["responses"][status_code]
|
||
error_schema = next(iter(error_response["content"].values()))[
|
||
"schema"
|
||
]
|
||
assert error_schema["$ref"].startswith(
|
||
"#/components/schemas/Response_"
|
||
)
|
||
|
||
|
||
def test_all_openapi_error_responses_use_json_schemas():
|
||
"""所有普通与原生协议错误响应都应在文档中声明 JSON 媒体类型和结构。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
schema = app.openapi()
|
||
methods = {"get", "post", "put", "patch", "delete", "options", "head"}
|
||
|
||
invalid_responses = []
|
||
for path, path_item in schema["paths"].items():
|
||
for method, operation in path_item.items():
|
||
if method not in methods:
|
||
continue
|
||
for status_code, response in operation["responses"].items():
|
||
if not str(status_code).startswith(("4", "5")):
|
||
continue
|
||
json_schema = response.get("content", {}).get(
|
||
"application/json", {}
|
||
).get("schema")
|
||
if not json_schema:
|
||
invalid_responses.append((path, method, status_code))
|
||
|
||
assert invalid_responses == []
|
||
|
||
|
||
def test_openapi_success_models_have_no_implicit_empty_nested_schemas():
|
||
"""2xx 响应可达模型不得包含裸 Any、裸数组或未声明值类型的开放映射。"""
|
||
from app.factory import create_app
|
||
from app.startup.routers_initializer import init_routers
|
||
|
||
app = create_app()
|
||
init_routers(app)
|
||
schema = app.openapi()
|
||
components = schema["components"]["schemas"]
|
||
allowed_open_components = {
|
||
# 三个外部兼容协议允许规范声明之外的请求扩展字段。
|
||
"AnthropicMessage",
|
||
"AnthropicMessagesRequest",
|
||
"OpenAIChatCompletionsRequest",
|
||
"OpenAIChatMessage",
|
||
"OpenAIResponsesRequest",
|
||
# 分类规则与 CookieCloud 解密载荷按设计接受扩展键。
|
||
"CategoryRule",
|
||
"CookieDecryptedPayload",
|
||
}
|
||
allowed_empty_components = {"McpJsonRpcEmptyResult"}
|
||
violations = []
|
||
|
||
def visit(node: Any, component: str, location: str) -> None:
|
||
"""递归检查单个组件节点中的隐式弱类型。"""
|
||
if not isinstance(node, dict):
|
||
return
|
||
if node == {} and component not in allowed_empty_components:
|
||
violations.append((component, location, "empty"))
|
||
if (
|
||
node.get("type") == "array"
|
||
and "items" not in node
|
||
and "prefixItems" not in node
|
||
):
|
||
violations.append((component, location, "untyped-array"))
|
||
if (
|
||
node.get("additionalProperties") is True
|
||
and component not in allowed_open_components
|
||
):
|
||
violations.append((component, location, "open-object"))
|
||
if (
|
||
node.get("type") == "object"
|
||
and not node.get("properties")
|
||
and "additionalProperties" not in node
|
||
and component not in allowed_empty_components
|
||
):
|
||
violations.append((component, location, "untyped-object"))
|
||
for key, value in node.items():
|
||
if key in {"default", "example", "examples"}:
|
||
continue
|
||
if isinstance(value, dict):
|
||
visit(value, component, f"{location}.{key}")
|
||
elif isinstance(value, list):
|
||
for index, item in enumerate(value):
|
||
visit(item, component, f"{location}.{key}[{index}]")
|
||
|
||
for component_name, component_schema in components.items():
|
||
visit(component_schema, component_name, component_name)
|
||
|
||
assert violations == []
|
||
|
||
|
||
def test_plugin_routes_only_register_v1(monkeypatch):
|
||
"""插件动态路由只应注册 v1 地址并由应用统一路由类处理。"""
|
||
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"
|
||
]
|
||
|
||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
||
assert fake_app.routes == []
|
||
|
||
|
||
def test_response_router_uses_response_route_class():
|
||
"""统一路由器应默认创建统一响应路由。"""
|
||
router = ResponseAPIRouter()
|
||
|
||
@router.get("/health", response_model=bool)
|
||
def health() -> bool:
|
||
"""返回测试健康状态。"""
|
||
return True
|
||
|
||
assert isinstance(router.routes[0], ResponseAPIRoute)
|
||
|
||
|
||
def test_dynamic_route_without_annotation_uses_recursive_json_model():
|
||
"""动态插件未声明模型时应以 OpenAPI 可递归展示的 JSON 类型约束 data。"""
|
||
app = FastAPI()
|
||
app.router.route_class = ResponseAPIRoute
|
||
|
||
def plugin_endpoint():
|
||
"""模拟未声明返回注解的插件动态接口。"""
|
||
return {"ok": True}
|
||
|
||
app.add_api_route("/plugin", plugin_endpoint, methods=["GET"])
|
||
route = app.routes[-1]
|
||
generic_args = route.response_model.__pydantic_generic_metadata__["args"]
|
||
|
||
assert generic_args == (JsonData,)
|
||
|
||
|
||
def test_dynamic_bare_response_uses_recursive_json_without_double_wrapping():
|
||
"""动态插件声明裸 Response 时应补齐递归 JSON 类型且不重复封装。"""
|
||
router = ResponseAPIRouter()
|
||
|
||
@router.get("/plugin", response_model=Response)
|
||
def plugin_endpoint() -> Response:
|
||
"""模拟返回统一响应但未参数化 data 的插件接口。"""
|
||
return Response(success=True, data={"ok": True})
|
||
|
||
route = router.routes[0]
|
||
generic_args = route.response_model.__pydantic_generic_metadata__["args"]
|
||
result = route.endpoint()
|
||
|
||
assert generic_args == (JsonData,)
|
||
assert isinstance(result, Response)
|
||
assert result.data == {"ok": True}
|