mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-19 19:52:03 +08:00
Refactor movie pilot config and test coverage
This commit is contained in:
@@ -73,6 +73,22 @@ def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
|
||||
assert "query_activity_log" in system_text
|
||||
|
||||
|
||||
def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
|
||||
"""复用 Agent 图时,活动日志索引仍应在每轮执行前刷新。"""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
|
||||
state = {"activity_log_contents": {"old": "旧索引"}}
|
||||
|
||||
_write_activity_log(
|
||||
tmp_path,
|
||||
date_str,
|
||||
["- **10:00** 新增活动记录"],
|
||||
)
|
||||
state_update = asyncio.run(middleware.abefore_agent(state, runtime=None))
|
||||
|
||||
assert state_update == {"activity_log_contents": {date_str: "1 条活动记录"}}
|
||||
|
||||
|
||||
def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
|
||||
"""无实际任务的寒暄不应调用 LLM,也不应写入活动日志。"""
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
|
||||
92
tests/test_agent_graph_cache.py
Normal file
92
tests/test_agent_graph_cache.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Agent 图缓存行为测试。"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from app.agent import MoviePilotAgent, ReplyMode, _CompiledAgentBundle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
"""使用 asyncio 后端运行 anyio 异步测试。"""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class _FakeGraphState:
|
||||
"""提供 LangGraph get_state 测试替身。"""
|
||||
|
||||
def __init__(self, messages):
|
||||
"""保存测试消息状态。"""
|
||||
self.values = {"messages": messages}
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
"""捕获传入消息的非流式 Agent 测试替身。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化捕获容器。"""
|
||||
self.payload = None
|
||||
|
||||
async def ainvoke(self, payload, config=None):
|
||||
"""记录 Agent 调用输入。"""
|
||||
self.payload = payload
|
||||
|
||||
def get_state(self, _config):
|
||||
"""返回包含最终 AI 回复的图状态。"""
|
||||
return _FakeGraphState([AIMessage(content="ok")])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_agent_reuses_cached_graph_when_signature_matches():
|
||||
"""构造签名一致时应直接复用已编译 Agent 图。"""
|
||||
cached_graph = object()
|
||||
agent = MoviePilotAgent(session_id="cache-hit", user_id="user-1")
|
||||
agent._compiled_agent_bundle = _CompiledAgentBundle(
|
||||
signature=("sig",),
|
||||
agent=cached_graph,
|
||||
streaming=False,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
agent,
|
||||
"_agent_bundle_signature",
|
||||
new=AsyncMock(return_value=("sig",)),
|
||||
), patch("app.agent.create_agent") as create_agent:
|
||||
graph = await agent._create_agent(streaming=False)
|
||||
|
||||
assert graph is cached_graph
|
||||
assert agent._last_agent_cache_hit is True
|
||||
create_agent.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_execute_agent_sends_only_latest_message_on_cache_hit():
|
||||
"""缓存命中时只把本轮新消息交给 LangGraph,避免重复提交历史。"""
|
||||
fake_graph = _CapturingAgent()
|
||||
agent = MoviePilotAgent(session_id="cache-hit", user_id="user-1")
|
||||
agent.reply_mode = ReplyMode.CAPTURE_ONLY
|
||||
agent._tool_context = {"user_reply_sent": False}
|
||||
agent._streamed_output = ""
|
||||
agent._should_stream = lambda: False
|
||||
agent.stream_handler = SimpleNamespace(
|
||||
stop_streaming=AsyncMock(return_value=(False, ""))
|
||||
)
|
||||
|
||||
async def _create_agent(streaming=False):
|
||||
"""模拟缓存命中后的 Agent 创建结果。"""
|
||||
agent._last_agent_cache_hit = True
|
||||
return fake_graph
|
||||
|
||||
agent._create_agent = _create_agent
|
||||
messages = [HumanMessage(content="上一轮"), HumanMessage(content="本轮")]
|
||||
|
||||
with patch("app.agent.eventmanager.send_event"):
|
||||
await agent._execute_agent(messages)
|
||||
|
||||
assert agent._streamed_output == "ok"
|
||||
assert fake_graph.payload["messages"] == [messages[-1]]
|
||||
@@ -7,16 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
|
||||
|
||||
|
||||
class _AsyncSessionContext:
|
||||
"""为工作流查询工具提供最小异步 DB 上下文。"""
|
||||
|
||||
async def __aenter__(self):
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class TestQueryWorkflowsTool(unittest.TestCase):
|
||||
def test_query_workflows_omits_large_result_field(self):
|
||||
tool = QueryWorkflowsTool(session_id="session-1", user_id="10001")
|
||||
@@ -38,9 +28,6 @@ class TestQueryWorkflowsTool(unittest.TestCase):
|
||||
workflow_oper.async_list = AsyncMock(return_value=[workflow])
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_workflows.AsyncSessionFactory",
|
||||
return_value=_AsyncSessionContext(),
|
||||
), patch(
|
||||
"app.agent.tools.impl.query_workflows.WorkflowOper",
|
||||
return_value=workflow_oper,
|
||||
):
|
||||
|
||||
@@ -466,14 +466,16 @@ def test_after_agent_cancels_unfinished_tasks():
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(task_started.wait(), timeout=1)
|
||||
task_id = start_payload["tasks"][0]["task_id"]
|
||||
await middleware.aafter_agent({}, None)
|
||||
status_payload = json.loads(
|
||||
await middleware._control_task(
|
||||
action="status",
|
||||
task_ids=[start_payload["tasks"][0]["task_id"]],
|
||||
task_ids=[task_id],
|
||||
)
|
||||
)
|
||||
|
||||
assert status_payload["tasks"][0]["status"] == "cancelled"
|
||||
assert status_payload["tasks"] == []
|
||||
assert status_payload["missing_task_ids"] == [task_id]
|
||||
|
||||
asyncio.run(_run_test())
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.core.config import settings
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class TestAgentSystemSettingsTools(unittest.TestCase):
|
||||
@@ -24,6 +25,63 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
|
||||
self.assertEqual(payload["matched_count"], 1)
|
||||
self.assertEqual(payload["settings"][0]["setting_key"], "Downloaders")
|
||||
self.assertEqual(payload["settings"][0]["value"][0]["name"], "qb")
|
||||
system_config_oper.return_value.get.assert_called_once_with(
|
||||
SystemConfigKey.Downloaders
|
||||
)
|
||||
|
||||
def test_query_system_settings_redacts_secret_values_by_default(self):
|
||||
"""查询系统设置默认应脱敏 API Key、Token、Cookie 等敏感字段。"""
|
||||
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
||||
) as system_config_oper:
|
||||
system_config_oper.return_value.get.return_value = [
|
||||
{
|
||||
"name": "site-a",
|
||||
"apikey": "site-api-key",
|
||||
"token": "site-token",
|
||||
"cookie": "uid=1; passkey=secret",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
]
|
||||
result = asyncio.run(
|
||||
tool.run(setting_key="UserSiteAuthParams", include_values=True)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
item = payload["settings"][0]
|
||||
self.assertTrue(item["redacted"])
|
||||
self.assertFalse(payload["show_secrets"])
|
||||
self.assertEqual("***", item["value"][0]["apikey"])
|
||||
self.assertEqual("***", item["value"][0]["token"])
|
||||
self.assertEqual("***", item["value"][0]["cookie"])
|
||||
self.assertEqual("https://example.com", item["value"][0]["url"])
|
||||
|
||||
def test_query_system_settings_show_secrets_requires_admin_context(self):
|
||||
"""只有管理员显式请求 show_secrets 时才返回敏感配置原值。"""
|
||||
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
|
||||
tool.set_agent_context({"is_admin": True})
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
||||
) as system_config_oper:
|
||||
system_config_oper.return_value.get.return_value = [
|
||||
{"name": "site-a", "apikey": "site-api-key"}
|
||||
]
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
setting_key="UserSiteAuthParams",
|
||||
include_values=True,
|
||||
show_secrets=True,
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
item = payload["settings"][0]
|
||||
self.assertTrue(payload["show_secrets"])
|
||||
self.assertFalse(item["redacted"])
|
||||
self.assertEqual("site-api-key", item["value"][0]["apikey"])
|
||||
|
||||
def test_query_system_settings_group_defaults_to_summary_for_multiple_items(self):
|
||||
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
||||
@@ -67,7 +125,7 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertTrue(payload["changed"])
|
||||
config_oper.async_set.assert_awaited_once_with(
|
||||
"AIAgentConfig",
|
||||
SystemConfigKey.AIAgentConfig,
|
||||
{"chatgpt": {"enabled": False}, "gemini": {"enabled": True}},
|
||||
)
|
||||
send_event.assert_awaited_once()
|
||||
@@ -99,6 +157,42 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertEqual(payload["saved_value"], [{"name": "qb", "enabled": True}])
|
||||
config_oper.async_set.assert_awaited_once_with(
|
||||
SystemConfigKey.Downloaders,
|
||||
[{"name": "qb", "enabled": True}],
|
||||
)
|
||||
|
||||
def test_update_system_settings_redacts_secret_values_in_response(self):
|
||||
"""更新敏感系统设置后响应不应回显旧值和新值中的密钥。"""
|
||||
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
||||
config_oper = MagicMock()
|
||||
config_oper.get.side_effect = [
|
||||
[{"name": "site-a", "apikey": "old-key"}],
|
||||
[{"name": "site-a", "apikey": "new-key"}],
|
||||
]
|
||||
config_oper.async_set = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.update_system_settings.SystemConfigOper",
|
||||
return_value=config_oper,
|
||||
), patch(
|
||||
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
setting_key="UserSiteAuthParams",
|
||||
operation="upsert_list_item",
|
||||
value={"name": "site-a", "apikey": "new-key"},
|
||||
match_field="name",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertTrue(payload["success"])
|
||||
self.assertTrue(payload["values_redacted"])
|
||||
self.assertEqual("***", payload["previous_value"][0]["apikey"])
|
||||
self.assertEqual("***", payload["saved_value"][0]["apikey"])
|
||||
|
||||
def test_update_system_settings_updates_basic_settings(self):
|
||||
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
||||
|
||||
@@ -137,8 +137,29 @@ class TestExecuteCommandTool(unittest.TestCase):
|
||||
|
||||
payload = json.loads(result)
|
||||
self.assertEqual(payload["status"], "error")
|
||||
# rm -rf / 命中删除根目录防护;断言拒绝原因点明 rm 根目录,避免锁死单一文案
|
||||
self.assertIn("不允许使用 rm", payload["error"])
|
||||
# rm -rf / 命中高危命令防护;断言拒绝且提示需要显式确认,避免锁死单一文案。
|
||||
self.assertIn("confirm_dangerous=true", payload["error"])
|
||||
|
||||
def test_dangerous_command_requires_explicit_confirmation(self):
|
||||
"""高危命令只有携带显式确认参数时才允许进入执行层。"""
|
||||
tool = ExecuteCommandTool(session_id="session-1", user_id="10001")
|
||||
|
||||
rejected = asyncio.run(
|
||||
tool.run(action="run", command="echo ok && shutdown now", timeout=1)
|
||||
)
|
||||
allowed = asyncio.run(
|
||||
tool.run(
|
||||
action="run",
|
||||
command=_python_command("print('shutdown now confirmed')"),
|
||||
timeout=1,
|
||||
confirm_dangerous=True,
|
||||
)
|
||||
)
|
||||
|
||||
rejected_payload = json.loads(rejected)
|
||||
self.assertEqual("error", rejected_payload["status"])
|
||||
self.assertIn("confirm_dangerous=true", rejected_payload["error"])
|
||||
self.assertIn("shutdown now confirmed", allowed)
|
||||
|
||||
|
||||
class TestExecuteCommandSessionTool(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
Reference in New Issue
Block a user