mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
122 lines
5.1 KiB
Python
122 lines
5.1 KiB
Python
"""Agent API surface inventory and generated contract drift tests."""
|
|
|
|
import json
|
|
import re
|
|
import runpy
|
|
from pathlib import Path
|
|
|
|
from app.agent.policy.api import API_OPERATION_ROUTES
|
|
from app.agent.tools.impl.api import MoviePilotApiTool
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
AUDIT_JSON = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.json"
|
|
AUDIT_MARKDOWN = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.md"
|
|
API_SKILL = PROJECT_ROOT / "skills/moviepilot-api/SKILL.md"
|
|
|
|
|
|
def _load_generator() -> dict:
|
|
"""Load the audit generator without invoking its file-writing entrypoint."""
|
|
return runpy.run_path(str(PROJECT_ROOT / "scripts/generate_agent_api_surface_audit.py"))
|
|
|
|
|
|
def test_agent_api_surface_audit_matches_live_openapi_and_registry() -> None:
|
|
"""The checked-in complete inventory must match live OpenAPI and the gateway registry."""
|
|
generator = _load_generator()
|
|
live = generator["generate_audit"]()
|
|
checked_in = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
|
|
|
|
assert checked_in == live
|
|
assert AUDIT_MARKDOWN.read_text(encoding="utf-8") == generator["render_markdown"](live)
|
|
assert live["openapi_operation_count"] == len(live["operations"])
|
|
assert live["gateway_operation_count"] == len(API_OPERATION_ROUTES)
|
|
assert sum(live["disposition_counts"].values()) == live["openapi_operation_count"]
|
|
assert {item["disposition"] for item in live["operations"]} == {
|
|
"alternate-auth-duplicate",
|
|
"consolidated",
|
|
"gateway",
|
|
"provider-skill",
|
|
"stream_or_binary",
|
|
"transport_or_identity",
|
|
"ui_presentation",
|
|
}
|
|
|
|
|
|
def test_cleanup_confirmation_stays_in_authenticated_management_workflow() -> None:
|
|
"""人工确认清理只关闭历史提示,不应作为 Agent 已验证外部清理的业务工具。"""
|
|
classify = _load_generator()["_classify"]
|
|
disposition, owner, _, operations = classify(
|
|
method="POST",
|
|
path="/api/v1/history/transfer/{history_id}/cleanup-resolved",
|
|
tags=["history"],
|
|
gateway_routes={},
|
|
)
|
|
assert (disposition, owner, operations) == ("ui_presentation", "host-ui", [])
|
|
|
|
|
|
def test_every_gateway_operation_has_one_exact_english_skill_and_mcp_contract() -> None:
|
|
"""Every approved operation must be discoverable with matching exact English contracts."""
|
|
skill = API_SKILL.read_text(encoding="utf-8")
|
|
schema = MoviePilotApiTool(session_id="audit", user_id="1").get_mcp_input_schema()
|
|
branches = {
|
|
branch["properties"]["operation_id"]["const"]: branch
|
|
for branch in schema["oneOf"]
|
|
}
|
|
|
|
assert set(branches) == set(API_OPERATION_ROUTES)
|
|
for operation_id, route in API_OPERATION_ROUTES.items():
|
|
assert skill.count(f"### `{operation_id}`") == 1
|
|
branch = branches[operation_id]
|
|
assert branch["description"].strip()
|
|
assert not re.search(r"[\u3400-\u9fff]", branch["description"]), operation_id
|
|
assert route.method in skill.split(f"### `{operation_id}`", 1)[1].split("\n### `", 1)[0]
|
|
assert route.path in skill.split(f"### `{operation_id}`", 1)[1].split("\n### `", 1)[0]
|
|
|
|
|
|
def test_every_gateway_path_placeholder_is_required_by_its_mcp_branch() -> None:
|
|
"""固定路由的每个路径占位符都必须在工具 schema 中以同名必填字段暴露。"""
|
|
schema = MoviePilotApiTool(session_id="audit", user_id="1").get_mcp_input_schema()
|
|
branches = {
|
|
branch["properties"]["operation_id"]["const"]: branch
|
|
for branch in schema["oneOf"]
|
|
}
|
|
|
|
for operation_id, route in API_OPERATION_ROUTES.items():
|
|
expected = set(re.findall(r"{([^}]+)}", route.path))
|
|
path_schema = branches[operation_id].get("properties", {}).get("path_params", {})
|
|
assert set(path_schema.get("properties", {})) == expected, operation_id
|
|
assert set(path_schema.get("required", [])) == expected, operation_id
|
|
|
|
|
|
def test_every_non_gateway_openapi_route_has_an_explicit_owner_and_reason() -> None:
|
|
"""Unexposed REST routes must remain visible and deliberately owned, never silently omitted."""
|
|
audit = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
|
|
keys = set()
|
|
for item in audit["operations"]:
|
|
key = (item["method"], item["path"])
|
|
assert key not in keys
|
|
keys.add(key)
|
|
assert item["owner"].strip()
|
|
assert item["reason"].strip()
|
|
if item["disposition"] != "gateway":
|
|
assert item["disposition"] in {
|
|
"alternate-auth-duplicate",
|
|
"consolidated",
|
|
"provider-skill",
|
|
"stream_or_binary",
|
|
"transport_or_identity",
|
|
"ui_presentation",
|
|
}
|
|
|
|
dynamic = audit["dynamic_gateway_routes"]
|
|
assert dynamic == [
|
|
{
|
|
"method": "GET",
|
|
"path": "/api/v1/{source}/person/credits/{person_id}",
|
|
"operation_ids": ["media.person.credits"],
|
|
"reason": (
|
|
"The executor validates and expands this bounded source placeholder to one of "
|
|
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
|
|
),
|
|
}
|
|
]
|