import asyncio import json from types import SimpleNamespace from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor from app.agent.tools.base import format_tool_result_for_agent from app.agent.tools.manager import MoviePilotToolsManager from app.schemas.types import NotificationChannel @pytest.mark.parametrize("app_domain", ["", "https://custom.domain.example:5443/moviepilot/"]) @pytest.mark.parametrize( ("host", "port", "base_url"), [ (None, 3001, "http://127.0.0.1:3001"), ("", 3001, "http://127.0.0.1:3001"), ("0.0.0.0", 13001, "http://127.0.0.1:13001"), ("127.0.0.1", 3001, "http://127.0.0.1:3001"), ("192.0.2.10", 13001, "http://192.0.2.10:13001"), ("localhost", 13001, "http://localhost:13001"), ("::", 13001, "http://[::1]:13001"), ("[::]", 13001, "http://[::1]:13001"), ("::1", 3001, "http://[::1]:3001"), ("[::1]", 3001, "http://[::1]:3001"), ("2001:db8::10", 13001, "http://[2001:db8::10]:13001"), ("[2001:db8::10]", 13001, "http://[2001:db8::10]:13001"), ], ) def test_executor_requests_local_listener_independently_of_public_domain( app_domain: str, host: Optional[str], port: int, base_url: str, ) -> None: """配置对外反代域名后,API 仍须按本机监听地址和端口发送且绕过环境代理。""" runtime_settings = {"APP_DOMAIN": app_domain, "HOST": host, "PORT": port} response = SimpleNamespace( status_code=200, headers={}, json=lambda: {"success": True, "data": []}, aclose=AsyncMock(), ) request = AsyncMock(return_value=response) request_factory = MagicMock(return_value=SimpleNamespace(request=request)) executor = MoviePilotApiExecutor( context=ApiExecutionContext(user_id="1", username="admin", is_admin=True), request_factory=request_factory, ) with ( patch("app.agent.api.executor.get_runtime_setting", side_effect=runtime_settings.get), patch("app.agent.api.executor.create_access_token", return_value="token"), ): result = asyncio.run(executor.execute("subscription.list", query={"page": 1})) assert json.loads(result) == {"success": True, "data": []} request.assert_awaited_once_with( method="GET", url=f"{base_url}/api/v1/subscribe/", params={"page": 1}, json=None, raise_exception=True, ) assert request_factory.call_args.kwargs["headers"]["Authorization"] == "Bearer token" assert request_factory.call_args.kwargs["trust_env"] is False response.aclose.assert_awaited_once() def _execute_with_headers(headers: dict[str, str]) -> tuple[dict, AsyncMock]: """用内存 HTTP 响应执行一次固定 API operation。""" response = SimpleNamespace( status_code=200, headers=headers, json=lambda: {"success": True, "message": "", "data": [{"id": 1}]}, aclose=AsyncMock(), ) request = AsyncMock(return_value=response) request_factory = MagicMock(return_value=SimpleNamespace(request=request)) executor = MoviePilotApiExecutor( context=ApiExecutionContext( user_id="1", username="admin", is_admin=True, ), request_factory=request_factory, ) with patch("app.agent.api.executor.create_access_token", return_value="token"): result = asyncio.run(executor.execute("subscription.list")) return json.loads(result), response.aclose def test_executor_exposes_exact_collection_total_when_api_reports_it() -> None: """Agent 结果应把精确数量响应头变成可直接读取的集合元数据。""" result, close = _execute_with_headers( { "X-Result-Count": "20", "X-Total-Count": "57", "X-Page": "2", "X-Page-Size": "20", } ) assert result["data"] == [{"id": 1}] assert result["collection"] == { "result_count": 20, "total_count": 57, "page": 2, "count": 20, } close.assert_awaited_once() def test_executor_does_not_invent_total_for_upstream_window() -> None: """外部接口未报告总数时,Agent 元数据只能包含当前窗口数量。""" result, close = _execute_with_headers( { "X-Result-Count": "20", "X-Page": "3", "X-Page-Size": "20", } ) assert result["collection"] == { "result_count": 20, "page": 3, "count": 20, } assert "total_count" not in result["collection"] close.assert_awaited_once() def test_executor_keeps_non_collection_payload_unchanged_without_headers() -> None: """非列表 API 未返回数量头时必须保持既有输出形状。""" result, close = _execute_with_headers({}) assert result == {"success": True, "message": "", "data": [{"id": 1}]} close.assert_awaited_once() def _execute_and_get_request_timeout( operation_id: str, *, path_params: Optional[dict] = None, query: Optional[dict] = None, ) -> float: """执行一个内存 API 请求并返回构造 HTTP 客户端时采用的超时。""" response = SimpleNamespace( status_code=200, headers={}, json=lambda: {"success": True}, aclose=AsyncMock(), ) request_factory = MagicMock( return_value=SimpleNamespace(request=AsyncMock(return_value=response)) ) executor = MoviePilotApiExecutor( context=ApiExecutionContext(user_id="1", username="admin", is_admin=True), request_factory=request_factory, ) with patch("app.agent.api.executor.create_access_token", return_value="token"): asyncio.run( executor.execute( operation_id, path_params=path_params, query=query, ) ) return request_factory.call_args.kwargs["timeout"] def test_executor_extends_request_timeout_only_for_search_torrents() -> None: """慢速种子搜索允许等待 290 秒,其它 operation 保持原有 30 秒。""" assert _execute_and_get_request_timeout( "search.torrents", path_params={"media_id": "1423191"}, query={"media_source": "themoviedb"}, ) == 290 assert _execute_and_get_request_timeout( "search.title", query={"keyword": "生化危机:爆发夜"}, ) == 30 assert _execute_and_get_request_timeout("subscription.list") == 30 def test_manager_default_session_id_is_http_header_compatible() -> None: """HTTP API 工具管理器生成的默认会话标识必须是可发送的字符串。""" manager = MoviePilotToolsManager(user_id="1") executor = MoviePilotApiExecutor( context=ApiExecutionContext( user_id="1", username="admin", is_admin=True, session_id=manager.session_id, ) ) with patch("app.agent.api.executor.create_access_token", return_value="token"): headers = executor._build_headers() session_header = headers["X-MoviePilot-Agent-Session"] assert isinstance(manager.session_id, str) assert isinstance(session_header, str) assert session_header == manager.session_id def test_executor_keeps_collection_total_visible_in_truncated_tool_preview() -> None: """列表内容过大时,精确总数必须位于截断预览开头供 Agent 直接使用。""" response = SimpleNamespace( status_code=200, headers={"X-Result-Count": "200", "X-Total-Count": "357"}, json=lambda: { "success": True, "message": "", "data": [{"title": "x" * 1024} for _ in range(100)], }, aclose=AsyncMock(), ) request = AsyncMock(return_value=response) request_factory = MagicMock(return_value=SimpleNamespace(request=request)) executor = MoviePilotApiExecutor( context=ApiExecutionContext( user_id="1", username="admin", is_admin=True, ), request_factory=request_factory, ) with patch("app.agent.api.executor.create_access_token", return_value="token"): raw_result = asyncio.run(executor.execute("subscription.list")) result = json.loads( format_tool_result_for_agent(raw_result, tool_name="moviepilot_api") ) assert result["tool_result_truncated"] is True assert result["content_preview"].startswith( '{"collection": {"result_count": 200, "total_count": 357}' ) assert result["content_preview"].index('"total_count": 357') < result[ "content_preview" ].index('"data"') response.aclose.assert_awaited_once() def test_executor_serializes_channel_and_source_headers_as_ascii() -> None: """中文通知渠道和来源必须以 ASCII 形式发送到 Agent API。""" request_factory = MagicMock(return_value=SimpleNamespace(request=AsyncMock())) executor = MoviePilotApiExecutor( context=ApiExecutionContext( user_id="1", username="admin", is_admin=True, channel=NotificationChannel.Wechat.value, source="企业微信", ), request_factory=request_factory, ) with patch("app.agent.api.executor.create_access_token", return_value="token"): headers = executor._build_headers() assert headers["X-MoviePilot-Agent-Channel"] == "%E5%BE%AE%E4%BF%A1" assert headers["X-MoviePilot-Agent-Channel"].isascii() assert headers["X-MoviePilot-Agent-Source"] == "%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1" assert headers["X-MoviePilot-Agent-Source"].isascii()