refactor: unify agent data port access

This commit is contained in:
jxxghp
2026-08-24 04:56:11 +08:00
parent c8ad54a190
commit ae38e61880
37 changed files with 217 additions and 87 deletions
+9 -6
View File
@@ -1,5 +1,6 @@
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
@@ -28,8 +29,10 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "")),
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.UserOper.get_name",
return_value="moviepilot-user",
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(
get_name=lambda **_kwargs: "moviepilot-user"
),
):
result = asyncio.run(
tool.run(
@@ -55,8 +58,8 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "")),
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.UserOper.get_name",
return_value=None,
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(get_name=lambda **_kwargs: None),
):
result = asyncio.run(
tool.run(
@@ -81,8 +84,8 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "")),
) as async_add, patch(
"app.agent.tools.impl.add_subscribe.UserOper.get_name",
return_value=None,
"app.agent.tools.impl.add_subscribe.get_agent_user_port",
return_value=SimpleNamespace(get_name=lambda **_kwargs: None),
):
result = asyncio.run(
tool.run(
+28
View File
@@ -0,0 +1,28 @@
"""Agent 命名数据端口工厂测试。"""
from app.application import agentdata
def test_named_agent_data_getters_use_registered_factories(monkeypatch) -> None:
"""每个命名 getter 都应创建组合根登记的对应端口实例。"""
names = {
"agent_chat": agentdata.get_agent_chat_port,
"agent_task": agentdata.get_agent_task_port,
"user": agentdata.get_agent_user_port,
"site": agentdata.get_agent_site_port,
"subscribe": agentdata.get_agent_subscribe_port,
"subscribe_history": agentdata.get_agent_subscribe_history_port,
"transfer_history": agentdata.get_agent_transfer_history_port,
"download_history": agentdata.get_agent_download_history_port,
"workflow": agentdata.get_agent_workflow_port,
"plugin_data": agentdata.get_agent_plugin_data_port,
}
factories = {
name: (lambda current=name: current)
for name in names
}
monkeypatch.setattr(agentdata, "_ports", agentdata.AgentDataPorts(**factories))
assert {name: getter() for name, getter in names.items()} == {
name: name for name in names
}
+6 -4
View File
@@ -428,8 +428,10 @@ def test_add_download_preserves_album_context_and_full_coverage_marker():
"_async_resolve_cached_context",
new=AsyncMock(return_value=cached_context),
), patch(
"app.agent.tools.impl.add_download_tasks.SiteOper.async_get_by_name",
new=AsyncMock(return_value=site),
"app.agent.tools.impl.add_download_tasks.get_agent_site_port",
return_value=SimpleNamespace(
async_get_by_name=AsyncMock(return_value=site)
),
), patch.object(
AddDownloadTasksTool,
"_download_single_sync",
@@ -479,7 +481,7 @@ def test_query_subscribe_history_uses_database_media_values_and_music_fields(mon
return [record] if mtype == MediaType.MUSIC.value else []
monkeypatch.setattr(
"app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper",
"app.agent.tools.impl.query_subscribe_history.get_agent_subscribe_history_port",
FakeHistoryOper,
)
tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001")
@@ -513,7 +515,7 @@ def test_music_history_filter_excludes_video_records(monkeypatch):
return [movie_record] if mtype == MediaType.MOVIE.value else []
monkeypatch.setattr(
"app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper",
"app.agent.tools.impl.query_subscribe_history.get_agent_subscribe_history_port",
FakeHistoryOper,
)
tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001")
+1 -1
View File
@@ -422,7 +422,7 @@ def test_query_plugin_data_truncates_large_payload() -> None:
return_value=_plugin_snapshot(),
),
patch(
"app.agent.tools.impl.query_plugin_data.PluginDataOper",
"app.agent.tools.impl.query_plugin_data.get_agent_plugin_data_port",
return_value=plugin_data_oper,
),
):
+6 -3
View File
@@ -1,6 +1,7 @@
import asyncio
import json
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
from app.db.models.subscribe import Subscribe
@@ -22,8 +23,10 @@ def test_agent_query_subscribes_returns_manual_total_episode():
)
with patch(
"app.agent.tools.impl.query_subscribes.SubscribeOper.async_list",
return_value=[subscribe],
"app.agent.tools.impl.query_subscribes.get_agent_subscribe_port",
return_value=SimpleNamespace(
async_list=AsyncMock(return_value=[subscribe])
),
):
result = asyncio.run(
QuerySubscribesTool(session_id="session-1", user_id="10001").run(
+1 -1
View File
@@ -28,7 +28,7 @@ class TestQueryWorkflowsTool(unittest.TestCase):
workflow_oper.async_list = AsyncMock(return_value=[workflow])
with patch(
"app.agent.tools.impl.query_workflows.WorkflowOper",
"app.agent.tools.impl.query_workflows.get_agent_workflow_port",
return_value=workflow_oper,
):
result = asyncio.run(tool.run())
@@ -115,7 +115,7 @@ def test_query_sites_hides_only_sensitive_fields_for_non_admin_user():
)
with patch(
"app.agent.tools.impl.query_sites.SiteOper"
"app.agent.tools.impl.query_sites.get_agent_site_port"
) as site_oper:
site_oper.return_value.async_list = AsyncMock(return_value=[site])
result = asyncio.run(tool.run())
@@ -177,7 +177,7 @@ def test_query_sites_keeps_full_fields_for_admin_context():
)
with patch(
"app.agent.tools.impl.query_sites.SiteOper"
"app.agent.tools.impl.query_sites.get_agent_site_port"
) as site_oper:
site_oper.return_value.async_list = AsyncMock(return_value=[site])
result = asyncio.run(tool.run())
@@ -369,7 +369,7 @@ def test_channel_agent_admin_user_id_does_not_bypass_user_lookup():
username="normal-user",
)
with patch("app.agent.orchestrator.UserOper") as user_oper:
with patch("app.agent.orchestrator.get_agent_user_port") as user_oper:
user_oper.return_value.async_get_by_name.return_value = SimpleNamespace(
is_superuser=False
)
@@ -391,7 +391,7 @@ def test_channel_agent_rejects_local_admin_username_without_trusted_principal():
)
agent.is_channel_admin = False
with patch("app.agent.orchestrator.UserOper") as user_oper:
with patch("app.agent.orchestrator.get_agent_user_port") as user_oper:
user_oper.return_value.async_get_by_name = AsyncMock(
return_value=SimpleNamespace(is_superuser=True)
)
@@ -414,7 +414,7 @@ def test_channel_agent_accepts_trusted_admin_principal_without_local_user():
)
agent.is_channel_admin = True
with patch("app.agent.orchestrator.UserOper") as user_oper:
with patch("app.agent.orchestrator.get_agent_user_port") as user_oper:
context = asyncio.run(
agent._build_tool_context(should_dispatch_reply=True)
)
+2 -2
View File
@@ -47,7 +47,7 @@ def test_search_subscribe_uses_async_data_port(monkeypatch) -> None:
raise AssertionError("async 工具不应调用同步订阅更新")
port = _AsyncSubscribePort()
monkeypatch.setattr(search_subscribe_module, "SubscribeOper", lambda: port)
monkeypatch.setattr(search_subscribe_module, "get_agent_subscribe_port", lambda: port)
async def _run_blocking(*_args, **_kwargs):
await asyncio.sleep(0)
@@ -79,7 +79,7 @@ def test_search_subscribe_rejects_paused_subscription_without_search(monkeypatch
async_update = AsyncMock()
port = _AsyncSubscribePort()
monkeypatch.setattr(search_subscribe_module, "SubscribeOper", lambda: port)
monkeypatch.setattr(search_subscribe_module, "get_agent_subscribe_port", lambda: port)
run_blocking = AsyncMock()
monkeypatch.setattr(SearchSubscribeTool, "run_blocking", run_blocking)
+31
View File
@@ -568,6 +568,37 @@ def test_transfer_chains_use_explicit_data_port_getters():
assert violations == []
def test_agent_consumers_use_explicit_data_port_getters():
"""Agent 生产模块不得把兼容数据端口代理重新伪装成数据库 Oper。"""
forbidden = {
"AgentChatPort",
"AgentTaskPort",
"DownloadHistoryPort",
"PluginDataPort",
"SitePort",
"SubscribeHistoryPort",
"SubscribePort",
"TransferHistoryPort",
"UserPort",
"WorkflowPort",
}
violations: list[str] = []
for path in (APP_ROOT / "agent").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
if node.module != "app.application.agentdata":
continue
for alias in node.names:
if alias.name in forbidden:
violations.append(
f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}:{alias.name}"
)
assert violations == []
def test_plugin_components_do_not_reexport_legacy_abi_names():
"""新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。"""
violations: list[str] = []
+7 -7
View File
@@ -41,7 +41,7 @@ def test_delete_transfer_history_tool_removes_old_dest_file_before_history(monke
return True
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -96,7 +96,7 @@ def test_delete_transfer_history_tool_keeps_history_when_old_dest_delete_fails(m
return False
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -150,7 +150,7 @@ def test_delete_transfer_history_tool_deletes_history_when_old_dest_is_missing(m
return False
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -205,7 +205,7 @@ def test_delete_transfer_history_tool_keeps_successful_move_dest_as_reorganize_s
return True
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -258,7 +258,7 @@ def test_delete_transfer_history_tool_only_treats_exact_move_as_reorganize_sourc
return True
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -313,7 +313,7 @@ def test_delete_transfer_history_storage_work_runs_outside_event_loop(monkeypatc
return True
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(
@@ -371,7 +371,7 @@ def test_delete_transfer_history_cancellation_keeps_history_record(monkeypatch):
return True
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
FakeTransferHistoryOper,
)
monkeypatch.setattr(