Files
MoviePilot/tests/test_cli_tool_gateway.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

273 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
本地 CLI 统一 Agent 工具目录测试。
"""
import json
from collections.abc import Iterator
from unittest.mock import Mock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
from app.cli import _parse_key_value_pairs, cli
@pytest.fixture
def http_request() -> Iterator[Mock]:
"""
只替换 HTTP 边界,保留工具信封解析与命令渲染。
"""
with (
patch("app.cli._backend_runtime", return_value={}),
patch("app.cli._runtime_api_token", return_value="cli-test-token"),
patch("app.cli._http_request") as request,
):
yield request
def _http_response(data: object, status: int = 200) -> dict:
"""
构造与 MCP REST 端点一致的统一成功信封。
"""
payload = {"success": True, "message": "", "data": data}
return {"status": status, "json": payload, "text": json.dumps(payload)}
def test_tool_list_unwraps_rest_envelope(http_request: Mock) -> None:
"""
工具列表应解包后按名称排序,不能把整个成功信封当错误。
"""
http_request.return_value = _http_response([{"name": "read_skill"}, {"name": "moviepilot_api"}])
result = CliRunner().invoke(cli, ["tool", "list"])
assert result.exit_code == 0
assert result.output == "moviepilot_api\nread_skill\n"
def test_tool_show_unwraps_rest_envelope(http_request: Mock) -> None:
"""
工具详情必须展示 data 内的名称、描述及参数。
"""
http_request.return_value = _http_response({
"name": "moviepilot_api",
"description": "统一 API 网关",
"inputSchema": {
"properties": {"operation_id": {"type": "string", "description": "操作 ID"}},
"required": ["operation_id"],
},
})
result = CliRunner().invoke(cli, ["tool", "show", "moviepilot_api"])
assert result.exit_code == 0
assert "Command: moviepilot_api" in result.output
assert "Description: 统一 API 网关" in result.output
assert "operation_id*" in result.output
assert "string" in result.output
assert "操作 ID" in result.output
@pytest.mark.parametrize("tool_result", ['{"version":"v3.1.3"}', '[1,2]', "执行完成", "false", "null", ""])
def test_tool_run_unwraps_and_prints_result(http_request: Mock, tool_result: str) -> None:
"""
执行结果保留 JSON 或文本语义,空结果也必须有明确回显。
"""
http_request.return_value = _http_response({"result": tool_result}, status=201)
result = CliRunner().invoke(cli, ["tool", "run", "moviepilot_api", "operation_id=system.versions"])
assert result.exit_code == 0
assert result.output.strip()
if tool_result.startswith(("{", "[")):
assert json.loads(result.output) == json.loads(tool_result)
elif tool_result == "false":
assert result.output == "False\n"
elif tool_result in {"null", ""}:
assert result.output == "工具执行成功(无返回内容)\n"
else:
assert result.output == f"{tool_result}\n"
def test_tool_arguments_parse_structured_json() -> None:
"""
tool run 应支持 API 网关的对象、数组和布尔参数。
"""
assert _parse_key_value_pairs(
(
"operation_id=scheduler.run",
'query={"job_id":"job-1"}',
"body=[1,2]",
"enabled=true",
)
) == {
"operation_id": "scheduler.run",
"query": {"job_id": "job-1"},
"body": [1, 2],
"enabled": True,
}
def test_tool_arguments_reject_invalid_structured_json() -> None:
"""
看似结构化但无效的 JSON 不得静默降级为字符串。
"""
with pytest.raises(ClickException, match="JSON 格式错误"):
_parse_key_value_pairs(("query={broken}",))
def test_scheduler_cli_uses_final_api_gateway_catalog(http_request: Mock) -> None:
"""
scheduler 子命令不得继续调用已删除的旧工具名。
"""
runner = CliRunner()
http_request.return_value = _http_response({"result": json.dumps({
"success": True,
"message": "",
"data": [{"id": "job-1", "status": "waiting", "next_run": None, "name": "测试任务"}],
})})
result = runner.invoke(cli, ["scheduler", "list"])
assert result.exit_code == 0
assert result.output == "job-1\twaiting\tNone\t测试任务\n"
http_request.assert_called_once_with(
"POST",
"/api/v1/mcp/tools/call",
json_body={"tool_name": "moviepilot_api", "arguments": {"operation_id": "scheduler.list"}},
headers={"X-API-KEY": "cli-test-token"},
timeout=30.0,
runtime={},
)
def test_scheduler_run_uses_structured_operation(http_request: Mock) -> None:
"""
scheduler run 应把 job_id 放入固定 operation 的 query。
"""
runner = CliRunner()
http_request.return_value = _http_response({
"result": json.dumps({"success": True, "message": "", "data": True})
})
result = runner.invoke(cli, ["scheduler", "run", "job-1"])
assert result.exit_code == 0
assert result.output == "True\n"
http_request.assert_called_once_with(
"POST",
"/api/v1/mcp/tools/call",
json_body={
"tool_name": "moviepilot_api",
"arguments": {"operation_id": "scheduler.run", "query": {"job_id": "job-1"}},
},
headers={"X-API-KEY": "cli-test-token"},
timeout=30.0,
runtime={},
)
@pytest.mark.parametrize("command", [
["tool", "list"],
["tool", "show", "moviepilot_api"],
["tool", "run", "moviepilot_api"],
["scheduler", "list"],
["scheduler", "run", "job-1"],
])
@pytest.mark.parametrize("status, payload, expected", [
(200, {"success": False, "message": "工具不可用", "data": None}, "工具不可用"),
(401, {"success": False, "message": "认证失败", "data": None}, "认证失败"),
(500, {"detail": "服务异常"}, "服务异常"),
(502, None, "响应格式错误"),
(200, {"data": ["huge directory"]}, "HTTP 200"),
])
def test_tool_commands_report_http_failures(
http_request: Mock, command: list[str], status: int, payload: object, expected: str,
) -> None:
"""
传输或信封错误必须明确失败,且不得倾倒完整响应正文。
"""
http_request.return_value = {"status": status, "json": payload, "text": "huge response" * 40000}
result = CliRunner().invoke(cli, command)
assert result.exit_code == 1
assert expected in result.output
assert "huge response" not in result.output
assert len(result.output) < 200
http_request.assert_called_once()
@pytest.mark.parametrize("command, data, expected", [
(["tool", "list"], {}, "不是工具列表"),
(["tool", "show", "moviepilot_api"], [], "不是工具对象"),
(["tool", "run", "moviepilot_api"], {}, "data.result"),
(["tool", "run", "moviepilot_api"], {"result": None}, "data.result"),
])
def test_tool_commands_reject_invalid_data(
http_request: Mock, command: list[str], data: object, expected: str,
) -> None:
"""
损坏的成功信封不得变成静默输出或错误的成功回显。
"""
http_request.return_value = _http_response(data)
result = CliRunner().invoke(cli, command)
assert result.exit_code == 1
assert expected in result.output
def test_tool_show_preserves_missing_tool_error(http_request: Mock) -> None:
"""
不存在的工具仍使用 CLI 原有的明确错误。
"""
http_request.return_value = {"status": 404, "json": {}, "text": ""}
result = CliRunner().invoke(cli, ["tool", "show", "missing"])
assert result.exit_code == 1
assert "工具不存在:missing" in result.output
@pytest.mark.parametrize("command", [["scheduler", "list"], ["scheduler", "run", "job-1"]])
def test_scheduler_reports_inner_business_failure(http_request: Mock, command: list[str]) -> None:
"""
REST 调用成功不掩盖网关返回的业务失败。
"""
http_request.return_value = _http_response({
"result": json.dumps({"success": False, "message": "任务不存在", "data": None}),
})
result = CliRunner().invoke(cli, command)
assert result.exit_code == 1
assert "任务不存在" in result.output
@pytest.mark.parametrize("data", [None, ""])
def test_scheduler_run_reports_empty_success(http_request: Mock, data: object) -> None:
"""
无业务返回值时仍显示调度调用成功,并且只调用一次。
"""
http_request.return_value = _http_response({
"result": json.dumps({"success": True, "message": "", "data": data}),
})
result = CliRunner().invoke(cli, ["scheduler", "run", "job-1"])
assert result.exit_code == 0
assert result.output == "调度任务调用成功(无返回内容)\n"
http_request.assert_called_once()
def test_tool_list_accepts_empty_catalog(http_request: Mock) -> None:
"""
合法空工具目录不应误判为请求失败。
"""
http_request.return_value = _http_response([])
result = CliRunner().invoke(cli, ["tool", "list"])
assert result.exit_code == 0
assert result.output == ""