Merge origin/v3 into codex/feat/plugin-data-query-sdk-v3

This commit is contained in:
jxxghp
2026-08-28 06:57:20 +08:00
45 changed files with 1258 additions and 494 deletions
+16 -10
View File
@@ -211,11 +211,12 @@ def configure_plugin_system_services():
from app.application.site.query import SiteQueryService, configure_site_query_service
from app.application.workflow import (
WorkflowQueryService,
configure_workflow_execution,
configure_workflow_query,
configure_workflow_runtime,
)
from app.workflow import WorkFlowManager
configure_workflow_runtime(lambda: WorkFlowManager())
from app.workflow import WorkflowManager
configure_workflow_runtime(lambda: WorkflowManager())
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
AgentTaskExecutionService,
@@ -229,7 +230,10 @@ def configure_plugin_system_services():
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.workflow import TransactionalWorkflowExecutionService
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
TransactionalWorkflowQueryRepository,
)
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
@@ -240,7 +244,7 @@ def configure_plugin_system_services():
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.user import UserOper
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
from app.db.oper.workflow import WorkflowOper
def create_sync_session() -> Session:
"""为无显式会话的 Oper 测试入口创建独占同步 Session。"""
@@ -255,9 +259,8 @@ def configure_plugin_system_services():
async_=transaction_runner.async_,
)
configure_workflow_legacy_writer(
TransactionalWorkflowExecutionService(SessionFactory)
)
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
configure_workflow_execution(workflow_execution)
configure_api_data_ports(
sync_session=get_db,
@@ -301,7 +304,6 @@ def configure_plugin_system_services():
configure_chain_data_ports(
site=site_repository,
subscribe=lambda: SubscribeOper(),
workflow=lambda: WorkflowOper(),
download_history=lambda: DownloadHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
@@ -332,7 +334,12 @@ def configure_plugin_system_services():
))
configure_site_query_service(SiteQueryService(repository=site_repository()))
configure_site_health_service(SiteHealthService(repository=site_repository()))
configure_workflow_query(WorkflowQueryService(repository=WorkflowOper()))
configure_workflow_query(WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
))
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.plugindata import PluginDataOper
configure_agent_data_ports(
@@ -344,7 +351,6 @@ def configure_plugin_system_services():
subscribe_history=lambda: SubscribeHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
download_history=lambda: DownloadHistoryOper(),
workflow=lambda: WorkflowOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10068,
"percent": 78.81,
"statements": 12775
"covered_lines": 10084,
"percent": 78.89,
"statements": 12782
},
"domain": {
"covered_lines": 3392,
+17 -12
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6940,
"edge_sha256": "9e3e8485c94c46a75eb577ccfc24708c80d4296b6422ec66ab9f23ef9964568f",
"edge_count": 6944,
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2587,7 +2587,7 @@
"app.agent.tools.impl.query_workflows -> app.agent.tools.base",
"app.agent.tools.impl.query_workflows -> app.agent.tools.tags",
"app.agent.tools.impl.query_workflows -> app.application",
"app.agent.tools.impl.query_workflows -> app.application.agentdata",
"app.agent.tools.impl.query_workflows -> app.application.workflow",
"app.agent.tools.impl.query_workflows -> app.runtime",
"app.agent.tools.impl.query_workflows -> app.runtime.log",
"app.agent.tools.impl.read_file -> app.agent",
@@ -3979,7 +3979,6 @@
"app.application.backup -> app.runtime.log",
"app.application.chain.context -> app.application",
"app.application.chain.context -> app.application.chain",
"app.application.chain.context -> app.application.chain.data",
"app.application.chain.context -> app.application.chain.events",
"app.application.chain.context -> app.application.configuration",
"app.application.chain.context -> app.runtime",
@@ -4344,6 +4343,8 @@
"app.application.servarr -> app.schemas.types",
"app.application.server.report -> app.schemas",
"app.application.server.report -> app.schemas.media",
"app.application.server.share -> app.application",
"app.application.server.share -> app.application.workflow",
"app.application.server.share -> app.schemas",
"app.application.server.share -> app.schemas.media",
"app.application.service -> app.schemas",
@@ -4476,10 +4477,11 @@
"app.application.transfer.workflow -> app.schemas.tmdb",
"app.application.transfer.workflow -> app.schemas.transfer",
"app.application.transfer.workflow -> app.schemas.types",
"app.application.workflow -> app.schemas",
"app.application.workflow -> app.schemas.common",
"app.chain -> app.application",
"app.chain -> app.application.chain",
"app.chain -> app.application.chain.context",
"app.chain -> app.application.chain.data",
"app.chain -> app.application.configuration",
"app.chain -> app.chain._messaging",
"app.chain -> app.chain._recognition",
@@ -5064,8 +5066,6 @@
"app.chain.webhook -> app.schemas",
"app.chain.webhook -> app.schemas.types",
"app.chain.workflow -> app.application",
"app.chain.workflow -> app.application.chain",
"app.chain.workflow -> app.application.chain.data",
"app.chain.workflow -> app.application.workflow",
"app.chain.workflow -> app.chain",
"app.chain.workflow -> app.runtime",
@@ -5215,6 +5215,8 @@
"app.db.adapters.workflow -> app.db.oper",
"app.db.adapters.workflow -> app.db.oper.workflow",
"app.db.adapters.workflow -> app.db.uow",
"app.db.adapters.workflow -> app.schemas",
"app.db.adapters.workflow -> app.schemas.common",
"app.db.base -> app.db",
"app.db.base -> app.db.uow",
"app.db.base -> app.runtime",
@@ -7583,6 +7585,7 @@
"app.scheduler -> app.application.plugin.runtime",
"app.scheduler -> app.application.scheduling",
"app.scheduler -> app.application.site",
"app.scheduler -> app.application.workflow",
"app.scheduler -> app.chain",
"app.scheduler -> app.chain.mediaserver",
"app.scheduler -> app.chain.recommend",
@@ -7608,7 +7611,6 @@
"app.scheduler -> app.schemas.message",
"app.scheduler -> app.schemas.system",
"app.scheduler -> app.schemas.types",
"app.scheduler -> app.schemas.workflow",
"app.schemas -> app.schemas.exports",
"app.schemas.agent -> app.schemas",
"app.schemas.agent -> app.schemas.common",
@@ -7730,6 +7732,11 @@
"app.sdk._legacy.user -> app.db",
"app.sdk._legacy.user -> app.db.oper",
"app.sdk._legacy.user -> app.db.oper.user",
"app.sdk._legacy.workflow -> app.application",
"app.sdk._legacy.workflow -> app.application.workflow",
"app.sdk._legacy.workflow -> app.db",
"app.sdk._legacy.workflow -> app.db.oper",
"app.sdk._legacy.workflow -> app.db.oper.workflow",
"app.sdk.browser -> app.adapters",
"app.sdk.browser -> app.adapters.network",
"app.sdk.browser -> app.adapters.network.browser",
@@ -8209,14 +8216,11 @@
"app.testing.bootstrap -> app.startup.initializers.database",
"app.testing.bootstrap -> app.startup.initializers.domain",
"app.workflow -> app.application",
"app.workflow -> app.application.chain",
"app.workflow -> app.application.chain.data",
"app.workflow -> app.application.workflow",
"app.workflow -> app.foundation",
"app.workflow -> app.foundation.reflection",
"app.workflow -> app.foundation.singleton",
"app.workflow -> app.runtime",
"app.workflow -> app.runtime.config",
"app.workflow -> app.runtime.events",
"app.workflow -> app.runtime.log",
"app.workflow -> app.runtime.stop",
@@ -8385,7 +8389,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 849,
"module_count": 850,
"modules": [
"app",
"app.adapters",
@@ -9179,6 +9183,7 @@
"app.sdk._legacy.transfer",
"app.sdk._legacy.transferpending",
"app.sdk._legacy.user",
"app.sdk._legacy.workflow",
"app.sdk.browser",
"app.sdk.cache",
"app.sdk.config",
+8 -9
View File
@@ -848,7 +848,7 @@
"arg-type": 7
},
"app/api/dependencies/workflow.py": {
"arg-type": 4,
"arg-type": 3,
"redundant-cast": 2
},
"app/api/endpoints/agent.py": {
@@ -1107,7 +1107,7 @@
"no-any-return": 4
},
"app/application/agentdata.py": {
"attr-defined": 10
"attr-defined": 9
},
"app/application/agenttask.py": {
"type-arg": 1
@@ -1299,7 +1299,6 @@
"type-arg": 5
},
"app/application/server/share.py": {
"no-any-return": 1,
"type-arg": 6
},
"app/application/service.py": {
@@ -1588,13 +1587,13 @@
"index": 1
},
"app/chain/workflow.py": {
"arg-type": 2,
"arg-type": 1,
"assignment": 1,
"call-arg": 1,
"index": 4,
"misc": 1,
"no-any-return": 10,
"no-untyped-def": 3,
"no-any-return": 7,
"no-untyped-def": 2,
"truthy-function": 2,
"type-arg": 17,
"union-attr": 4,
@@ -3232,7 +3231,7 @@
"unused-ignore": 1
},
"app/scheduler.py": {
"arg-type": 8,
"arg-type": 7,
"attr-defined": 1,
"import-untyped": 1,
"misc": 1,
@@ -3406,7 +3405,7 @@
"misc": 1,
"no-any-return": 2,
"no-untyped-call": 33,
"no-untyped-def": 13,
"no-untyped-def": 12,
"return-value": 5
},
"app/startup/initializers/plugins.py": {
@@ -3448,7 +3447,7 @@
"no-untyped-call": 2
},
"app/workflow/__init__.py": {
"arg-type": 2,
"arg-type": 1,
"assignment": 2,
"index": 1,
"no-any-return": 1,
-28
View File
@@ -291,9 +291,6 @@
"F401": 1,
"I001": 1
},
"app/application/agentdata.py": {
"I001": 1
},
"app/application/agenttask.py": {
"I001": 1
},
@@ -380,9 +377,6 @@
"app/application/torrent_cache.py": {
"I001": 1
},
"app/application/workflow.py": {
"I001": 1
},
"app/chain/_music.py": {
"E402": 5
},
@@ -401,9 +395,6 @@
"app/db/adapters/transaction.py": {
"I001": 1
},
"app/db/adapters/workflow.py": {
"I001": 1
},
"app/db/base.py": {
"I001": 1
},
@@ -482,9 +473,6 @@
"app/db/oper/userconfig.py": {
"I001": 1
},
"app/db/oper/workflow.py": {
"I001": 1
},
"app/db/session.py": {
"I001": 1
},
@@ -1050,9 +1038,6 @@
"E402": 27,
"F401": 1
},
"app/workflow/__init__.py": {
"F401": 1
},
"scripts/architecture/task_ownership.py": {
"I001": 1
},
@@ -1219,9 +1204,6 @@
"E402": 4,
"I001": 1
},
"tests/test_chain_runtime_context.py": {
"I001": 1
},
"tests/test_cli_auto_update.py": {
"I001": 1
},
@@ -1266,9 +1248,6 @@
"tests/test_db_lazy_engine.py": {
"I001": 1
},
"tests/test_db_oper_layer.py": {
"I001": 1
},
"tests/test_db_oper_layer_extra.py": {
"I001": 1
},
@@ -1353,10 +1332,6 @@
"tests/test_health_probes.py": {
"I001": 1
},
"tests/test_host_runtime_context.py": {
"F401": 1,
"I001": 1
},
"tests/test_indexer_spider_search_url.py": {
"I001": 1
},
@@ -1777,9 +1752,6 @@
"tests/test_workflow_authorization.py": {
"I001": 1
},
"tests/test_workflow_execution.py": {
"I001": 1
},
"tests/test_workflow_runtime_config.py": {
"I001": 1
}
+22 -8
View File
@@ -284,9 +284,9 @@
"app.db.workflow_oper": {
"introduced": "v3.0.0",
"is_package": false,
"owner": "db",
"replacement": "app.db.oper.workflow",
"target": "app.db.oper.workflow"
"owner": "sdk",
"replacement": "app.application.workflow.WorkflowExecutionPort",
"target": "app.sdk._legacy.workflow"
},
"app.domain.string": {
"introduced": "v3.0.0",
@@ -926,6 +926,13 @@
"target_name": "MediaInteractionChain"
}
},
"app.db.oper": {
"WorkflowOper": {
"replacement": "app.application.workflow.WorkflowExecutionPort",
"target_module": "app.sdk._legacy.workflow",
"target_name": "WorkflowOper"
}
},
"app.domain.media": {
"MEDIA_SOURCE_ALIASES": {
"replacement": "app.schemas.media.MEDIA_SOURCE_ALIASES",
@@ -1184,6 +1191,13 @@
"target_module": "app.runtime.log",
"target_name": "log_settings"
}
},
"app.workflow": {
"WorkFlowManager": {
"replacement": "app.workflow.WorkflowManager",
"target_module": "app.workflow",
"target_name": "WorkflowManager"
}
}
},
"virtual_packages": [
@@ -1440,12 +1454,12 @@
"caller": "app.workflow",
"dynamic": true,
"events": [],
"fingerprint": "042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188",
"fingerprint": "b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09",
"handler": "self._handle_event",
"invalid": false,
"method": "add_event_listener",
"priority": "<default>",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"receiver_kind": "canonical_singleton",
"registration_kind": "listener"
}
@@ -1836,7 +1850,7 @@
"50704edda70674af0932e769ddda40d2c21d50b134155c975d675035d7c933cd"
],
"producer_fingerprints": [
"611abaaf708f0c2d555d3b963ff540c015f2155ff493ed17b92d7c7f0bd45e96"
"b92d348c57dac3ba348079b4e71d02a0c13933bbf14b7e81188b042c3c5e2db3"
]
}
},
@@ -2980,10 +2994,10 @@
"events": [
"EventType.WorkflowExecute"
],
"fingerprint": "611abaaf708f0c2d555d3b963ff540c015f2155ff493ed17b92d7c7f0bd45e96",
"fingerprint": "b92d348c57dac3ba348079b4e71d02a0c13933bbf14b7e81188b042c3c5e2db3",
"invalid": false,
"method": "send_event",
"qualname": "WorkFlowManager._trigger_workflow",
"qualname": "WorkflowManager._trigger_workflow",
"receiver_kind": "canonical_singleton"
},
{
+2 -2
View File
@@ -300,7 +300,7 @@
},
{
"caller": "app.workflow",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"method": "add_event_listener",
"receiver_kind": "canonical_singleton",
"events": [],
@@ -309,7 +309,7 @@
"handler": "self._handle_event",
"registration_kind": "listener",
"priority": "<default>",
"fingerprint": "042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188",
"fingerprint": "b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09",
"classification": "approved_dynamic_exception",
"owner": "app.workflow",
"reason": "工作流配置在运行期决定事件类型,receiver 与 handler 仍可静态证明。"
-1
View File
@@ -14,7 +14,6 @@ def test_named_agent_data_getters_use_registered_factories(monkeypatch) -> None:
"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 = {
+41 -31
View File
@@ -1,39 +1,49 @@
import asyncio
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.application.workflow import WorkflowSnapshot
class TestQueryWorkflowsTool(unittest.TestCase):
def test_query_workflows_omits_large_result_field(self):
tool = QueryWorkflowsTool(session_id="session-1", user_id="10001")
workflow = SimpleNamespace(
id=1,
name="demo",
description="demo workflow",
state="S",
trigger_type="manual",
run_count=1,
timer=None,
event_type=None,
add_time="2026-05-08 10:00:00",
last_time="2026-05-08 10:01:00",
current_action=None,
result="x" * 10000,
)
workflow_oper = MagicMock()
workflow_oper.async_list = AsyncMock(return_value=[workflow])
def _workflow() -> WorkflowSnapshot:
"""构造 Agent 查询使用的真实工作流快照。"""
return WorkflowSnapshot(
id=1,
name="demo",
description="demo workflow",
timer=None,
trigger_type="manual",
event_type=None,
event_conditions={},
state="S",
current_action=None,
result="x" * 10000,
run_count=1,
actions=(),
flows=(),
context={},
execution_config={},
execution_state={},
add_time="2026-05-08 10:00:00",
last_time="2026-05-08 10:01:00",
)
with patch(
"app.agent.tools.impl.query_workflows.get_agent_workflow_port",
return_value=workflow_oper,
):
result = asyncio.run(tool.run())
payload = json.loads(result)
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["name"], "demo")
self.assertNotIn("result", payload[0])
def test_query_workflows_omits_large_result_field(monkeypatch) -> None:
"""Agent 列表查询使用统一快照服务且不返回大结果字段。"""
tool = QueryWorkflowsTool(session_id="session-1", user_id="10001")
query = MagicMock()
query.list = AsyncMock(return_value=[_workflow()])
monkeypatch.setattr(
"app.agent.tools.impl.query_workflows.get_configured_workflow_query",
lambda: query,
)
result = asyncio.run(tool.run())
payload = json.loads(result)
assert len(payload) == 1
assert payload[0]["name"] == "demo"
assert "result" not in payload[0]
query.list.assert_awaited_once_with()
+210
View File
@@ -283,6 +283,216 @@ def test_retired_canonical_filenames_do_not_return():
assert leftovers == []
def test_workflow_query_contract_returns_only_typed_snapshots():
"""工作流正式查询端口不得退化为 Any 或 ORM 返回值。"""
path = APP_ROOT / "application" / "workflow.py"
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
query_classes = {
node.name: node
for node in tree.body
if isinstance(node, ast.ClassDef)
and node.name in {"WorkflowQueryRepository", "WorkflowQueryService"}
}
methods = [
node
for query_class in query_classes.values()
for node in query_class.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and not node.name.startswith("__")
and node.returns is not None
]
assert set(query_classes) == {"WorkflowQueryRepository", "WorkflowQueryService"}
assert methods
for method in methods:
annotation = ast.unparse(method.returns)
assert "Any" not in annotation
assert "WorkflowSnapshot" in annotation
def test_workflow_query_consumers_do_not_reach_raw_oper():
"""API、Agent、共享服务和运行时管理器只消费统一快照查询服务。"""
consumer_paths = (
"app/api/dependencies/workflow.py",
"app/agent/tools/impl/query_workflows.py",
"app/application/server/share.py",
"app/workflow/__init__.py",
)
violations = {}
for relative_path in consumer_paths:
source = (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
forbidden = {
name
for name in (
"WorkflowOper",
"get_agent_workflow_port",
"get_chain_workflow_port",
)
if name in source
}
if forbidden:
violations[relative_path] = sorted(forbidden)
assert violations == {}
def test_workflow_query_adapter_owns_projection_sessions():
"""唯一查询适配器必须在自有同步和异步 Session 内投影快照。"""
path = APP_ROOT / "db" / "adapters" / "workflow.py"
source = path.read_text(encoding="utf-8")
assert "class TransactionalWorkflowQueryRepository" in source
assert "session.close()" in source
assert "async with self._async_session() as session" in source
assert "_project_workflow(record)" in source
def test_workflow_execution_chain_uses_single_application_owned_port():
"""工作流 Chain 写端必须只使用 Application owner 的唯一配置入口。"""
contract_path = APP_ROOT / "application" / "workflow.py"
contract_tree = ast.parse(
contract_path.read_text(encoding="utf-8"),
filename=str(contract_path),
)
contract = next(
node
for node in contract_tree.body
if isinstance(node, ast.ClassDef)
and node.name == "WorkflowExecutionPort"
)
methods = {
node.name: ast.unparse(node.returns)
for node in contract.body
if isinstance(node, ast.FunctionDef)
and node.returns is not None
}
assert methods == {
"start": "bool",
"success": "bool",
"fail": "bool",
"step": "bool",
"reset": "bool",
}
data_path = APP_ROOT / "application" / "chain" / "data.py"
data_tree = ast.parse(
data_path.read_text(encoding="utf-8"),
filename=str(data_path),
)
data_class = next(
node
for node in data_tree.body
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
)
data_fields = {
node.target.id
for node in data_class.body
if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
}
data_functions = {
node.name
for node in data_tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
assert "workflow" not in data_fields
assert "get_chain_workflow_port" not in data_functions
chain_source = (APP_ROOT / "chain" / "workflow.py").read_text(encoding="utf-8")
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert chain_source.count("get_configured_workflow_execution()") == 1
assert "get_chain_workflow_port" not in chain_source
assert "configure_workflow_execution(workflow_execution)" in startup_source
assert "workflow=lambda:" not in startup_source
def test_chain_registry_has_no_dynamic_proxies_or_dead_context_injection():
"""Chain registry 不得恢复零消费者动态代理或失效 data_ports 伪注入。"""
data_path = APP_ROOT / "application" / "chain" / "data.py"
data_tree = ast.parse(
data_path.read_text(encoding="utf-8"),
filename=str(data_path),
)
proxy_classes = {
node.name
for node in data_tree.body
if isinstance(node, ast.ClassDef)
and (node.name.endswith("PortProxy") or node.name == "_PortProxyMeta")
}
dynamic_getters = {
node.name
for node in ast.walk(data_tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "__getattr__"
}
assert proxy_classes == set()
assert dynamic_getters == set()
context_source = (
APP_ROOT / "application" / "chain" / "context.py"
).read_text(encoding="utf-8")
chain_base_source = (APP_ROOT / "chain" / "__init__.py").read_text(
encoding="utf-8"
)
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "data_ports" not in context_source
assert "self.data_ports" not in chain_base_source
assert "data_ports=" not in startup_source
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
oper_tree = ast.parse(
oper_path.read_text(encoding="utf-8"),
filename=str(oper_path),
)
oper_class = next(
node
for node in oper_tree.body
if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper"
)
method_names = {
node.name
for node in oper_class.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
assert {"start", "success", "fail", "step", "reset"}.isdisjoint(method_names)
assert "legacy" not in oper_path.read_text(encoding="utf-8").lower()
package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text(
encoding="utf-8"
)
assert '"WorkflowOper"' not in package_source
def test_agent_data_ports_do_not_duplicate_workflow_query_capability():
"""Agent 数据聚合器不得重新暴露无类型工作流读取入口。"""
source = (APP_ROOT / "application" / "agentdata.py").read_text(
encoding="utf-8"
)
assert "WorkflowPort" not in source
assert "get_agent_workflow_port" not in source
def test_host_uses_canonical_workflow_manager_name():
"""宿主代码不得继续定义或导入旧 WorkFlowManager 拼写。"""
violations = []
for path in APP_ROOT.rglob("*.py"):
relative_path = path.relative_to(APP_ROOT)
if relative_path.parts[:2] == ("runtime", "compat"):
continue
if "WorkFlowManager" in path.read_text(encoding="utf-8"):
violations.append(str(path.relative_to(PROJECT_ROOT)))
assert violations == []
def test_startup_root_contains_only_composition_packages():
"""组合根顶层只保留稳定分区,禁止再次堆叠扁平实现文件。"""
startup_root = APP_ROOT / "startup"
+2 -2
View File
@@ -137,7 +137,7 @@ def test_current_event_consumer_policy_matches_exact_reviewed_set() -> None:
]
assert dynamic_entries == [{
"caller": "app.workflow",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"method": "add_event_listener",
"receiver_kind": "canonical_singleton",
"events": [],
@@ -147,7 +147,7 @@ def test_current_event_consumer_policy_matches_exact_reviewed_set() -> None:
"registration_kind": "listener",
"priority": "<default>",
"fingerprint": (
"042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188"
"b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09"
),
"classification": DYNAMIC_CLASSIFICATION,
"owner": "app.workflow",
+43 -1
View File
@@ -2,9 +2,12 @@
from unittest.mock import Mock
import pytest
from app.application.chain import context as chain_context
from app.application.chain import data as chain_data
from app.application.chain.context import ChainRuntimeContext
from app.application.configuration import ChainRuntimeConfig
from app.application.chain import context as chain_context
from app.chain import ChainBase
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
@@ -51,3 +54,42 @@ def test_no_arg_chain_uses_compatibility_context_provider(monkeypatch) -> None:
provider.assert_called_once_with()
assert chain.modulemanager is context.module_manager
assert chain.pluginmanager is context.plugin_manager
def test_chain_runtime_context_rejects_unconfigured_provider(monkeypatch) -> None:
"""未由组合根配置运行上下文时必须显式拒绝无参 Chain。"""
monkeypatch.setattr(
chain_context,
"_context_provider",
chain_context._unconfigured_chain_runtime_context,
)
with pytest.raises(RuntimeError, match="Chain 运行上下文尚未由启动组合根配置"):
chain_context.get_chain_runtime_context()
def test_chain_data_registry_rejects_unconfigured_and_returns_factories(
monkeypatch,
) -> None:
"""数据 registry 未配置时拒绝访问,配置后按字段返回工厂实例。"""
monkeypatch.setattr(chain_data, "_ports", None)
with pytest.raises(RuntimeError, match="Chain 数据端口尚未配置"):
chain_data.get_chain_data_ports()
media_server = Mock()
user = Mock()
chain_data.configure_chain_data_ports(
site=Mock,
subscribe=Mock,
download_history=Mock,
transfer_history=Mock,
transfer_pending=Mock,
transfer_execution=Mock,
media_server=lambda: media_server,
download_failure=Mock,
user=lambda: user,
)
assert chain_data.get_chain_media_server_port() is media_server
assert chain_data.get_chain_user_port() is user
+11 -9
View File
@@ -6,12 +6,11 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错
验证 Oper 的对外契约而不是验证它调了哪个模型方法
"""
import asyncio
import importlib
from unittest.mock import Mock
import pytest
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.models.mediaserver import MediaServerItem
from app.db.models.plugindata import PluginData
@@ -22,6 +21,8 @@ from app.db.models.siteuserdata import SiteUserData
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
from app.db.models.workflow import Workflow
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.plugindata import PluginDataOper
from app.db.oper.site import SiteOper
from app.db.oper.user import UserOper
@@ -374,7 +375,7 @@ def test_workflow_oper_add_rejects_duplicate_name(db):
assert oper.add(**_workflow_kwargs("op-wf")) == (False, "工作流已存在")
def test_workflow_oper_exposes_lists_and_lifecycle(db):
def test_workflow_oper_exposes_lists_and_staged_lifecycle(db):
"""
列表入口与生命周期方法都应透传到模型并落库
"""
@@ -387,23 +388,24 @@ def test_workflow_oper_exposes_lists_and_lifecycle(db):
assert {w.name for w in oper.list_enabled()} >= {"op-wf-life"}
assert {w.name for w in oper.get_timer_triggered_workflows()} >= {"op-wf-life"}
oper.start(flow.id)
oper.stage_start(flow.id)
assert oper.get(flow.id).state == "R"
oper.step(flow.id, "a1", {"n": 1})
oper.stage_step(flow.id, "a1", {"n": 1})
assert oper.get(flow.id).current_action == "a1"
oper.success(flow.id, "完成")
oper.stage_success(flow.id, "完成")
assert oper.get(flow.id).state == "S"
oper.fail(flow.id, "出错")
oper.stage_fail(flow.id, "出错")
assert oper.get(flow.id).state == "F"
oper.reset(flow.id, reset_count=True)
oper.stage_execution_reset(flow.id, reset_count=True)
assert (oper.get(flow.id).state, oper.get(flow.id).run_count) == ("W", 0)
def test_workflow_oper_no_session_uses_configured_uow_writer(db):
"""旧的无 Session Oper 写入口仍可用,但事务由组合根服务持有。"""
flow = db.add(Workflow(**_workflow_kwargs("op-wf-legacy")))
legacy = importlib.import_module("app.db.workflow_oper")
assert WorkflowOper().start(flow.id) is True
assert legacy.WorkflowOper().start(flow.id) is True
db.session.expire_all()
assert WorkflowOper(db=db.session).get(flow.id).state == "R"
+62 -1
View File
@@ -6,13 +6,17 @@
`run_count` 的自增必须留在 SQL 否则并发执行会丢计数
"""
import asyncio
from dataclasses import FrozenInstanceError
import pytest
from app.application.workflow import WorkflowSnapshot
from app.db import base as db_base
from app.db.adapters.workflow import TransactionalWorkflowQueryRepository
from app.db.models.workflow import Workflow
from app.db.oper.workflow import WorkflowOper
from app.db.session import async_session_scope
from app.db.session import SessionFactory, async_session_scope
from app.schemas.workflow import Workflow as WorkflowResponse
@pytest.fixture(autouse=True)
@@ -90,6 +94,63 @@ def test_workflow_oper_reuses_explicit_query_sessions(db, monkeypatch):
asyncio.run(check())
def test_query_repository_returns_detached_deep_copied_snapshot(db):
"""查询仓储必须在关闭短 Session 前投影,且 JSON 不与 ORM 记录共享。"""
workflow = _flow("wf-snapshot")
workflow.actions = [{"id": "action-1", "config": {"value": 1}}]
workflow.flows = [{"source": "action-1", "target": "end"}]
workflow.context = {"nested": {"value": 1}}
created = db.add(workflow)
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
snapshot = repository.get(created.id)
assert isinstance(snapshot, WorkflowSnapshot)
assert snapshot.name == "wf-snapshot"
with pytest.raises(FrozenInstanceError):
snapshot.name = "changed"
snapshot.actions[0]["config"]["value"] = 2
snapshot.context["nested"]["value"] = 2
refreshed = repository.get(created.id)
assert refreshed.actions[0]["config"]["value"] == 1
assert refreshed.context["nested"]["value"] == 1
def test_query_repository_async_projection_survives_session_close(db):
"""异步查询返回值在仓储退出 Session 作用域后仍可完整序列化。"""
created = db.add(_flow("wf-async-snapshot"))
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
snapshot = asyncio.run(repository.async_get(created.id))
listed = asyncio.run(repository.async_list())
assert isinstance(snapshot, WorkflowSnapshot)
assert snapshot.name == "wf-async-snapshot"
assert created.id in {item.id for item in listed}
def test_workflow_snapshot_validates_against_api_response_contract(db):
"""冻结快照可直接序列化为 API 合同且不会暴露内部执行上下文。"""
created = db.add(_flow("wf-api-snapshot"))
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
response = WorkflowResponse.model_validate(repository.get(created.id))
payload = response.model_dump()
assert payload["id"] == created.id
assert payload["name"] == "wf-api-snapshot"
assert "context" not in payload
def test_enabled_workflows_exclude_paused(db):
"""
启用列表排除暂停状态
+8 -8
View File
@@ -14,6 +14,13 @@ from app.api.context import (
get_agent_chat_transaction,
)
from app.api.dependencies.agent import get_agent_chat_persistence
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
RuntimeConfiguration,
RuntimeSettingsService,
SchedulerRuntimeConfig,
)
from app.startup import lifecycle
from app.startup.composition.context import (
AgentChatRuntime,
@@ -26,14 +33,6 @@ from app.startup.composition.context import (
SubscriptionRuntime,
WorkflowRuntime,
)
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
RuntimeConfiguration,
RuntimeSettingsService,
SchedulerRuntimeConfig,
)
PROJECT_ROOT = Path(__file__).parents[1]
@@ -153,6 +152,7 @@ def _runtime() -> HostRuntime:
outbox=_Outbox,
),
workflow=WorkflowRuntime(
query=SimpleNamespace(),
repository=_Repository,
system_config=lambda: _Repository(object()),
),
+56
View File
@@ -7,6 +7,62 @@ from app.db.models.transferhistory import TransferHistory
from app.schemas.file import FileItem
def test_legacy_workflow_writes_delegate_to_configured_execution_port(monkeypatch):
"""旧 WorkflowOper 无 Session 写入必须完整委托类型化事务端口。"""
legacy = importlib.import_module("app.db.workflow_oper")
calls = []
class ExecutionPort:
"""记录五种旧工作流写入调用。"""
def start(self, workflow_id):
"""记录启动。"""
calls.append(("start", workflow_id))
return True
def success(self, workflow_id, result=None):
"""记录成功。"""
calls.append(("success", workflow_id, result))
return True
def fail(self, workflow_id, result):
"""记录失败。"""
calls.append(("fail", workflow_id, result))
return True
def step(self, workflow_id, action_id, context, execution_state=None):
"""记录步骤。"""
calls.append(
("step", workflow_id, action_id, context, execution_state)
)
return True
def reset(self, workflow_id, reset_count=False):
"""记录重置。"""
calls.append(("reset", workflow_id, reset_count))
return True
monkeypatch.setattr(
legacy,
"get_configured_workflow_execution",
lambda: ExecutionPort(),
)
oper = legacy.WorkflowOper()
assert oper.start(7) is True
assert oper.success(7, "done") is True
assert oper.fail(7, "failed") is True
assert oper.step(7, "A", {"value": 1}, {"runtime": {}}) is True
assert oper.reset(7, reset_count=True) is True
assert calls == [
("start", 7),
("success", 7, "done"),
("fail", 7, "failed"),
("step", 7, "A", {"value": 1}, {"runtime": {}}),
("reset", 7, True),
]
def test_legacy_subscribe_add_delegates_to_application_service(monkeypatch):
"""旧 SubscribeOper.add 应保留 mediainfo 写入签名。"""
legacy = importlib.import_module("app.db.subscribe_oper")
+25
View File
@@ -314,6 +314,19 @@ def test_db_refactor_legacy_modules_are_all_registered():
assert expected <= set(MODULE_ALIASES)
def test_workflow_oper_compatibility_is_only_exposed_by_overlay():
"""旧工作流写入口只由 Legacy facade 和精确符号映射提供。"""
legacy = importlib.import_module("app.db.workflow_oper")
canonical = importlib.import_module("app.db.oper.workflow")
oper_package = importlib.import_module("app.db.oper")
assert MODULE_ALIASES["app.db.workflow_oper"].target == "app.sdk._legacy.workflow"
assert issubclass(legacy.WorkflowOper, canonical.WorkflowOper)
assert legacy.WorkflowOper is not canonical.WorkflowOper
assert oper_package.WorkflowOper is legacy.WorkflowOper
assert "WorkflowOper" not in oper_package.__all__
def test_split_user_oper_facade_exports_data_and_auth_contracts():
"""旧 user_oper 同时提供 UserOper 与八个认证依赖。"""
legacy = importlib.import_module("app.db.user_oper")
@@ -410,6 +423,16 @@ def test_chain_media_legacy_scraping_symbols_resolve_to_scraping_chain():
assert legacy_media.ScrapingConfig is canonical_scraping.ScrapingConfig
def test_workflow_manager_legacy_name_resolves_only_through_symbol_overlay():
"""旧 WorkFlowManager 仍可显式导入,但不进入 canonical 模块公开面。"""
install_legacy_import_hook()
workflow_module = importlib.import_module("app.workflow")
assert workflow_module.WorkFlowManager is workflow_module.WorkflowManager
assert "WorkFlowManager" not in vars(workflow_module)
assert "WorkFlowManager" not in workflow_module.__all__
def test_rules_domain_legacy_modules_resolve_to_rules():
"""规则域收敛后,filter/filter_rules 旧路径应复用 rules 模块。"""
canonical = importlib.import_module("app.application.rules")
@@ -459,6 +482,8 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path):
def test_symbol_alias_manifest_covers_all_moved_public_symbols():
"""符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。"""
assert set(SYMBOL_ALIASES["app.db.oper"]) == {"WorkflowOper"}
assert set(SYMBOL_ALIASES["app.workflow"]) == {"WorkFlowManager"}
assert set(SYMBOL_ALIASES["app.domain.media"]) == {
"MEDIA_SOURCE_ALIASES",
"MEDIA_SOURCE_PREFIXES",
+83 -1
View File
@@ -1,8 +1,34 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.application.server.share import ServerSharingService
from app.application.workflow import WorkflowSnapshot
def _workflow(*, actions=(), flows=()) -> WorkflowSnapshot:
"""构造中心服务分享使用的真实工作流快照。"""
return WorkflowSnapshot(
id=1,
name="Demo Workflow",
description="demo",
timer=None,
trigger_type="manual",
event_type=None,
event_conditions={},
state="W",
current_action=None,
result=None,
run_count=0,
actions=actions,
flows=flows,
context={"private": True},
execution_config={},
execution_state={},
add_time=None,
last_time=None,
)
def _service(**overrides) -> ServerSharingService:
@@ -66,7 +92,7 @@ def test_subscribe_share_builds_public_payload_and_clears_cache_after_success():
def test_workflow_validation_stops_before_transport():
"""缺少动作或流程的工作流不会进入中心服务传输。"""
sender = Mock()
workflow = SimpleNamespace(actions=[], flows=[{"id": 1}])
workflow = _workflow(flows=({"id": 1},))
service = _service(
workflow_provider=Mock(return_value=workflow),
workflow_sender=sender,
@@ -84,6 +110,36 @@ def test_workflow_validation_stops_before_transport():
sender.assert_not_called()
def test_workflow_share_serializes_snapshot_without_local_fields():
"""同步工作流分享从冻结快照生成兼容载荷并剔除本地上下文。"""
sender = Mock(return_value=SimpleNamespace(status_code=200))
workflow = _workflow(
actions=({"id": "action-1"},),
flows=({"source": "action-1", "target": "end"},),
)
service = _service(
workflow_provider=Mock(return_value=workflow),
workflow_sender=sender,
)
result = service.share_workflow(
enabled=True,
workflow_id=1,
share_title="Title",
share_comment="Comment",
share_user="User",
)
assert result == (True, "")
payload = sender.call_args.args[0]
assert "id" not in payload
assert "context" not in payload
assert json.loads(payload["actions"]) == [{"id": "action-1"}]
assert json.loads(payload["flows"]) == [
{"source": "action-1", "target": "end"}
]
def test_async_subscribe_share_uses_async_reader_and_transport():
"""异步分享路径不会回退到同步数据库或网络端口。"""
subscribe = SimpleNamespace(to_dict=lambda: {
@@ -110,3 +166,29 @@ def test_async_subscribe_share_uses_async_reader_and_transport():
assert result == (True, "")
reader.assert_awaited_once_with(1)
sender.assert_awaited_once()
def test_async_workflow_share_uses_snapshot_reader_and_transport():
"""异步工作流分享复用同一快照契约且不回退同步端口。"""
workflow = _workflow(
actions=({"id": "action-1"},),
flows=({"source": "action-1", "target": "end"},),
)
reader = AsyncMock(return_value=workflow)
sender = AsyncMock(return_value=SimpleNamespace(status_code=200))
service = _service(
async_workflow_provider=reader,
async_workflow_sender=sender,
)
result = asyncio.run(service.async_share_workflow(
enabled=True,
workflow_id=1,
share_title="Title",
share_comment="Comment",
share_user="User",
))
assert result == (True, "")
reader.assert_awaited_once_with(1)
sender.assert_awaited_once()
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from app.schemas.download import DownloadTask
from app.schemas.file import FileItem
from app.schemas.workflow import ActionContext, ActionResult
from app.workflow import WorkFlowManager
from app.workflow import WorkflowManager
from app.workflow.actions import BaseAction
from app.workflow.actions import fetch_downloads as fetch_downloads_module
from app.workflow.actions import fetch_torrents as fetch_torrents_module
@@ -239,7 +239,7 @@ def test_execute_with_inputs_maps_contract_inputs_outputs_and_runtime(monkeypatc
def test_workflow_manager_list_actions_exposes_contract():
"""动作列表应返回固定输入输出契约。"""
manager = object.__new__(WorkFlowManager)
manager = object.__new__(WorkflowManager)
manager._actions = {"FetchRssAction": FetchRssAction}
actions = manager.list_actions()
+45 -9
View File
@@ -6,11 +6,11 @@ from types import SimpleNamespace
import pytest
from app import workflow as workflow_package
from app.chain import workflow as workflow_module
from app.runtime.correlation import correlation_scope, get_correlation_id
from app.schemas.types import EventType
from app.schemas.workflow import Action, ActionContext, ActionResult
from app import workflow as workflow_package
def _build_workflow(current_action=None, context=None, actions=None, flows=None,
@@ -635,9 +635,24 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
flows=[{"id": "flow-end", "source": "A", "target": "END", "animated": True}],
)
fake_oper = _FakeWorkflowOper(workflow)
port_calls = []
def get_execution_port():
"""记录单次执行获取事务端口的次数。"""
port_calls.append(True)
return fake_oper
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: fake_manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: fake_oper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_execution",
get_execution_port,
)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(get_sync=lambda _workflow_id: workflow),
)
monkeypatch.setattr(workflow_module.runtime_stop_state, "resume_workflow", lambda workflow_id: None)
monkeypatch.setattr(workflow_module.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
@@ -645,6 +660,7 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
assert success is True
assert message == ""
assert port_calls == [True]
assert fake_oper.succeeded is True
saved_workflow_context = fake_oper.steps[-1]["context"]["workflow_context"]
saved_self = saved_workflow_context["self"]
@@ -825,7 +841,7 @@ def test_workflow_manager_shutdown_retains_blocked_execution_for_retry(monkeypat
release.wait()
return ActionResult(success=True, context=context)
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
manager._actions = {"BlockingAction": BlockingAction}
manager._event_workflows = {}
@@ -904,7 +920,7 @@ def test_workflow_manager_shutdown_continues_across_owner_failures():
self.manager.unregister_execution(self)
return True
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
action_marker = object()
manager._actions = {"FakeAction": action_marker}
@@ -940,7 +956,16 @@ def test_workflow_chain_rejects_execution_before_persisting_running_state(monkey
workflowoper = _FakeWorkflowOper(workflow)
manager = RejectingWorkflowManager([])
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: workflowoper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_execution",
lambda: workflowoper,
)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(get_sync=lambda _workflow_id: workflow),
)
def unexpected_resume(_workflow_id: int) -> None:
"""拒绝准入时若仍恢复停止标记则立即暴露回归。"""
@@ -970,7 +995,7 @@ def test_workflow_chain_releases_admitted_owner_when_start_fails(monkeypatch):
_ = wid
raise RuntimeError("start failed")
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
manager._actions = {"FakeAction": object()}
manager._event_workflows = {}
@@ -978,7 +1003,18 @@ def test_workflow_chain_releases_admitted_owner_when_start_fails(monkeypatch):
manager._executions = {}
workflowoper = FailingWorkflowOper(_build_workflow())
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: workflowoper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_execution",
lambda: workflowoper,
)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(
get_sync=lambda _workflow_id: workflowoper.workflow
),
)
monkeypatch.setattr(workflow_module.runtime_stop_state, "resume_workflow", lambda _workflow_id: None)
with pytest.raises(RuntimeError, match="start failed"):
@@ -1018,7 +1054,7 @@ class _FakeEventManager:
def test_workflow_event_listener_keeps_shared_handler_until_last_workflow(monkeypatch):
"""同一事件下移除单个工作流时不应断开其他工作流监听。"""
fake_eventmanager = _FakeEventManager()
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.Lock()
manager._event_workflows = {}
@@ -1057,7 +1093,7 @@ def test_workflow_manager_retries_action_until_success(monkeypatch):
return ActionResult(success=False, message="第一次失败", context=context)
return ActionResult(success=True, message="第二次成功", context=context, outputs={"ok": True})
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._actions = {"RetryAction": RetryAction}
monkeypatch.setattr(workflow_package.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
+60 -2
View File
@@ -3,11 +3,13 @@ from unittest.mock import AsyncMock, Mock
import pytest
import app.application.workflow as workflow_application
from app.application.workflow import (
WorkflowDefinitionCommand,
WorkflowExecutionCommand,
WorkflowMutationCommand,
WorkflowQueryService,
WorkflowSnapshot,
)
@@ -21,6 +23,30 @@ def _workflow(trigger_type="timer", timer="0 0 * * *", event_type="DownloadAdded
)
def _snapshot() -> WorkflowSnapshot:
"""构造查询服务返回的冻结工作流快照。"""
return WorkflowSnapshot(
id=7,
name="query",
description=None,
timer="0 0 * * *",
trigger_type="timer",
event_type=None,
event_conditions={},
state="W",
current_action=None,
result=None,
run_count=0,
actions=(),
flows=(),
context={},
execution_config={},
execution_state={},
add_time=None,
last_time=None,
)
def _command(workflow=None, commit_error=None):
"""构造可观察工作流事务与运行时副作用的命令。"""
repository = Mock()
@@ -62,6 +88,23 @@ def _execution_command(commit_error=None):
), repository, unit_of_work
def test_workflow_execution_port_requires_explicit_configuration(monkeypatch):
"""执行状态端口必须显式装配,并原样返回组合根登记的服务。"""
monkeypatch.setattr(
workflow_application,
"_configured_workflow_execution",
None,
)
with pytest.raises(RuntimeError, match="工作流执行状态事务服务尚未配置"):
workflow_application.get_configured_workflow_execution()
service = Mock()
workflow_application.configure_workflow_execution(service)
assert workflow_application.get_configured_workflow_execution() is service
def test_execution_step_is_staged_before_unit_of_work_commit():
"""工作流进度写入必须由应用命令暂存后统一提交。"""
command, repository, unit_of_work = _execution_command()
@@ -96,8 +139,9 @@ def test_execution_commit_failure_rolls_back():
async def test_workflow_query_service_delegates_list_and_get_to_repository():
"""工作流查询服务只调用读取端口,不持有数据库会话或事务。"""
repository = Mock()
repository.async_list = AsyncMock(return_value=[_workflow()])
repository.async_get = AsyncMock(return_value=_workflow())
snapshot = _snapshot()
repository.async_list = AsyncMock(return_value=[snapshot])
repository.async_get = AsyncMock(return_value=snapshot)
service = WorkflowQueryService(repository)
listed = await service.list()
@@ -105,6 +149,8 @@ async def test_workflow_query_service_delegates_list_and_get_to_repository():
assert listed == repository.async_list.return_value
assert fetched == repository.async_get.return_value
assert all(isinstance(item, WorkflowSnapshot) for item in listed)
assert isinstance(fetched, WorkflowSnapshot)
repository.async_list.assert_awaited_once_with()
repository.async_get.assert_awaited_once_with(7)
@@ -123,6 +169,18 @@ def test_start_timer_workflow_commits_before_registering_job():
dependencies["repository"].stage_state.assert_called_once_with(7, "W")
def test_start_rejects_missing_workflow_without_transaction():
"""工作流不存在时不得暂存状态或触发事务。"""
command, dependencies = _command()
result = command.start(7)
assert result.success is False
assert result.message == "工作流不存在"
dependencies["repository"].stage_state.assert_not_called()
dependencies["unit_of_work"].commit.assert_not_called()
def test_start_rejects_invalid_trigger_without_transaction():
"""未知触发类型不得更新数据库或注册运行时触发器。"""
command, dependencies = _command(_workflow(trigger_type="unknown"))
+1 -1
View File
@@ -18,7 +18,7 @@ def test_workflow_runtime_facade_preserves_registered_identity(monkeypatch) -> N
def test_workflow_runtime_facade_fails_before_composition(monkeypatch) -> None:
"""未装配时不得隐式创建第二个 WorkFlowManager Singleton。"""
"""未装配时不得隐式创建第二个 WorkflowManager Singleton。"""
monkeypatch.setattr(
workflow_application,
"_workflow_runtime_provider",