mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: make subscription lifecycle events durable
This commit is contained in:
+39
-12
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6244,
|
||||
"edge_sha256": "f187bdbb5e88ce9a6b2ff559b10e5e6cd5a60d14693484ea17663cebf438cc92",
|
||||
"edge_count": 6271,
|
||||
"edge_sha256": "0119519add8e4499684044fc69f3003c54cfca20bf04f4614a419374692d5a94",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -715,20 +715,16 @@
|
||||
"app.agent.tools.impl.delete_rule_group -> app.runtime.log",
|
||||
"app.agent.tools.impl.delete_rule_group -> app.schemas",
|
||||
"app.agent.tools.impl.delete_rule_group -> app.schemas.types",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.adapters",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.adapters.external",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.adapters.external.server",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.agent",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.agent.tools",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.application",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.application.agentdata",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.application.subscription",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.application.subscription.delete",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.application.subscription.mutation",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.runtime",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.runtime.events",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.runtime.log",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.schemas",
|
||||
"app.agent.tools.impl.delete_subscribe -> app.schemas.types",
|
||||
"app.agent.tools.impl.delete_transfer_history -> app.agent",
|
||||
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools",
|
||||
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools.base",
|
||||
@@ -1440,12 +1436,11 @@
|
||||
"app.agent.tools.impl.update_subscribe -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.update_subscribe -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.update_subscribe -> app.application",
|
||||
"app.agent.tools.impl.update_subscribe -> app.application.agentdata",
|
||||
"app.agent.tools.impl.update_subscribe -> app.application.subscription",
|
||||
"app.agent.tools.impl.update_subscribe -> app.application.subscription.mutation",
|
||||
"app.agent.tools.impl.update_subscribe -> app.runtime",
|
||||
"app.agent.tools.impl.update_subscribe -> app.runtime.events",
|
||||
"app.agent.tools.impl.update_subscribe -> app.runtime.log",
|
||||
"app.agent.tools.impl.update_subscribe -> app.schemas",
|
||||
"app.agent.tools.impl.update_subscribe -> app.schemas.event",
|
||||
"app.agent.tools.impl.update_subscribe -> app.schemas.types",
|
||||
"app.agent.tools.impl.update_system_settings -> app.agent",
|
||||
"app.agent.tools.impl.update_system_settings -> app.agent.tools",
|
||||
@@ -1486,6 +1481,11 @@
|
||||
"app.api.context -> app.application",
|
||||
"app.api.context -> app.application.messaging",
|
||||
"app.api.context -> app.application.messaging.chat",
|
||||
"app.api.context -> app.application.outbox",
|
||||
"app.api.context -> app.application.subscription",
|
||||
"app.api.context -> app.application.subscription.delete",
|
||||
"app.api.context -> app.application.subscription.identity",
|
||||
"app.api.context -> app.application.subscription.mutation",
|
||||
"app.api.context -> app.startup",
|
||||
"app.api.context -> app.startup.context",
|
||||
"app.api.dependencies.agent -> app.api",
|
||||
@@ -1562,10 +1562,12 @@
|
||||
"app.api.dependencies.subscription -> app.adapters.external",
|
||||
"app.api.dependencies.subscription -> app.adapters.external.server",
|
||||
"app.api.dependencies.subscription -> app.api",
|
||||
"app.api.dependencies.subscription -> app.api.context",
|
||||
"app.api.dependencies.subscription -> app.api.data",
|
||||
"app.api.dependencies.subscription -> app.api.dependencies",
|
||||
"app.api.dependencies.subscription -> app.api.dependencies.data",
|
||||
"app.api.dependencies.subscription -> app.application",
|
||||
"app.api.dependencies.subscription -> app.application.outbox",
|
||||
"app.api.dependencies.subscription -> app.application.scheduling",
|
||||
"app.api.dependencies.subscription -> app.application.servarr",
|
||||
"app.api.dependencies.subscription -> app.application.subscription",
|
||||
@@ -2703,11 +2705,20 @@
|
||||
"app.application.subscription.contract -> app.schemas",
|
||||
"app.application.subscription.contract -> app.schemas.media",
|
||||
"app.application.subscription.contract -> app.schemas.types",
|
||||
"app.application.subscription.delete -> app.application",
|
||||
"app.application.subscription.delete -> app.application.outbox",
|
||||
"app.application.subscription.delete -> app.schemas",
|
||||
"app.application.subscription.delete -> app.schemas.event",
|
||||
"app.application.subscription.identity -> app.application",
|
||||
"app.application.subscription.identity -> app.application.outbox",
|
||||
"app.application.subscription.identity -> app.application.subscription",
|
||||
"app.application.subscription.identity -> app.application.subscription.delete",
|
||||
"app.application.subscription.identity -> app.schemas",
|
||||
"app.application.subscription.identity -> app.schemas.types",
|
||||
"app.application.subscription.mutation -> app.application",
|
||||
"app.application.subscription.mutation -> app.application.outbox",
|
||||
"app.application.subscription.mutation -> app.schemas",
|
||||
"app.application.subscription.mutation -> app.schemas.event",
|
||||
"app.application.subscription.query -> app.domain",
|
||||
"app.application.subscription.query -> app.domain.context",
|
||||
"app.application.subscription.query -> app.domain.meta",
|
||||
@@ -5836,6 +5847,11 @@
|
||||
"app.startup.context -> app.application",
|
||||
"app.startup.context -> app.application.messaging",
|
||||
"app.startup.context -> app.application.messaging.chat",
|
||||
"app.startup.context -> app.application.outbox",
|
||||
"app.startup.context -> app.application.subscription",
|
||||
"app.startup.context -> app.application.subscription.delete",
|
||||
"app.startup.context -> app.application.subscription.identity",
|
||||
"app.startup.context -> app.application.subscription.mutation",
|
||||
"app.startup.database -> app.adapters",
|
||||
"app.startup.database -> app.adapters.system",
|
||||
"app.startup.database -> app.adapters.system.backup",
|
||||
@@ -6073,13 +6089,24 @@
|
||||
"app.startup.scheduler_initializer -> app.application",
|
||||
"app.startup.scheduler_initializer -> app.application.scheduling",
|
||||
"app.startup.scheduler_initializer -> app.scheduler",
|
||||
"app.startup.subscription -> app.adapters",
|
||||
"app.startup.subscription -> app.adapters.external",
|
||||
"app.startup.subscription -> app.adapters.external.server",
|
||||
"app.startup.subscription -> app.application",
|
||||
"app.startup.subscription -> app.application.subscription",
|
||||
"app.startup.subscription -> app.application.subscription.delete",
|
||||
"app.startup.subscription -> app.application.subscription.mutation",
|
||||
"app.startup.subscription -> app.application.subscription.write",
|
||||
"app.startup.subscription -> app.db",
|
||||
"app.startup.subscription -> app.db.oper",
|
||||
"app.startup.subscription -> app.db.oper.subscribe",
|
||||
"app.startup.subscription -> app.db.oper.subscribehistory",
|
||||
"app.startup.subscription -> app.db.session",
|
||||
"app.startup.subscription -> app.db.uow",
|
||||
"app.startup.subscription -> app.runtime",
|
||||
"app.startup.subscription -> app.runtime.events",
|
||||
"app.startup.subscription -> app.schemas",
|
||||
"app.startup.subscription -> app.schemas.types",
|
||||
"app.startup.subscription -> app.startup",
|
||||
"app.startup.subscription -> app.startup.outbox",
|
||||
"app.startup.transfer_initializer -> app.chain",
|
||||
|
||||
+25
-13
@@ -1581,10 +1581,10 @@
|
||||
"EventType.SubscribeAdded": {
|
||||
"delivery": "durable_required",
|
||||
"error_behavior": "notify",
|
||||
"legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI",
|
||||
"legacy_reason": null,
|
||||
"mode": "broadcast",
|
||||
"ordering": "priority_queue",
|
||||
"payload_contract": "legacy_dict",
|
||||
"payload_contract": "SubscribeAddedEventData",
|
||||
"sensitive_fields": [],
|
||||
"visibility": "plugin_public"
|
||||
},
|
||||
@@ -1601,10 +1601,10 @@
|
||||
"EventType.SubscribeDeleted": {
|
||||
"delivery": "durable_required",
|
||||
"error_behavior": "notify",
|
||||
"legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI",
|
||||
"legacy_reason": null,
|
||||
"mode": "broadcast",
|
||||
"ordering": "priority_queue",
|
||||
"payload_contract": "legacy_dict",
|
||||
"payload_contract": "SubscribeDeletedEventData",
|
||||
"sensitive_fields": [],
|
||||
"visibility": "plugin_public"
|
||||
},
|
||||
@@ -1958,11 +1958,11 @@
|
||||
},
|
||||
"ChainEventType.WorkflowExecution": {
|
||||
"consumers": [],
|
||||
"producers": [
|
||||
{
|
||||
"caller": "app.workflow.actions.send_event",
|
||||
"count": 1
|
||||
}
|
||||
"producers": [
|
||||
{
|
||||
"caller": "app.workflow.actions.send_event",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"EventType.AgentTokensUsage": {
|
||||
@@ -2200,11 +2200,15 @@
|
||||
"consumers": [],
|
||||
"producers": [
|
||||
{
|
||||
"caller": "app.agent.tools.impl.delete_subscribe",
|
||||
"caller": "app.api.dependencies.subscription",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"caller": "app.api.dependencies.subscription",
|
||||
"caller": "app.startup.modules_initializer",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.subscription",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
@@ -2213,12 +2217,20 @@
|
||||
"consumers": [],
|
||||
"producers": [
|
||||
{
|
||||
"caller": "app.agent.tools.impl.update_subscribe",
|
||||
"caller": "app.api.dependencies.subscription",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"caller": "app.api.endpoints.subscribe",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.modules_initializer",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.subscription",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2308,7 +2320,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"producer_count": 67
|
||||
"producer_count": 70
|
||||
},
|
||||
"module_method_specs": {
|
||||
"download_file": {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Agent 删除订阅工具的事务作用域委托测试。"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _scope(value):
|
||||
"""把测试替身包装成工具使用的异步作用域。"""
|
||||
yield value
|
||||
|
||||
|
||||
def test_agent_delete_subscribe_uses_transactional_delete_command():
|
||||
"""Agent 删除必须委托带 UoW/outbox 的应用命令,不能直接调用 Oper。"""
|
||||
subscribe = SimpleNamespace(id=7, name="测试订阅", year="2026")
|
||||
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=subscribe))
|
||||
command = SimpleNamespace(execute=AsyncMock(return_value=True))
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _scope(mutation),
|
||||
), patch(
|
||||
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
|
||||
side_effect=lambda: _scope(command),
|
||||
):
|
||||
result = asyncio.run(
|
||||
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
subscribe_id=7
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "成功删除订阅:测试订阅 (2026)"
|
||||
mutation.get_accessible.assert_awaited_once()
|
||||
command.execute.assert_awaited_once()
|
||||
subscribe_id, actor = command.execute.await_args.args
|
||||
assert subscribe_id == 7
|
||||
assert actor.is_superuser is True
|
||||
|
||||
|
||||
def test_agent_delete_subscribe_skips_command_when_record_is_missing():
|
||||
"""预读未命中时保持原有不存在提示,且不创建删除副作用。"""
|
||||
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=None))
|
||||
delete_scope = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _scope(mutation),
|
||||
), patch(
|
||||
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
|
||||
delete_scope,
|
||||
):
|
||||
result = asyncio.run(
|
||||
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
subscribe_id=404
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "订阅 ID 404 不存在"
|
||||
delete_scope.assert_not_called()
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.application.subscription.mutation import SubscriptionMutation
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
|
||||
@@ -13,13 +15,11 @@ def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
|
||||
subscribe = _AgentSubscribe(id=9, name="旧标题", state="R", total_episode=8)
|
||||
oper = _SubscribeOperStub(subscribe)
|
||||
|
||||
mutation = _MutationServiceStub(oper)
|
||||
with patch(
|
||||
"app.agent.tools.impl.update_subscribe.SubscribeOper",
|
||||
return_value=oper,
|
||||
), patch(
|
||||
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
) as send_event:
|
||||
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _mutation_scope(mutation),
|
||||
):
|
||||
result = asyncio.run(
|
||||
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
subscribe_id=9,
|
||||
@@ -31,14 +31,7 @@ def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
|
||||
payload = json.loads(result)
|
||||
assert payload["success"] is True
|
||||
assert oper.updates == [(9, {"name": "新标题", "state": "S"})]
|
||||
send_event.assert_awaited_once()
|
||||
event_type, event_payload = send_event.await_args.args
|
||||
assert event_type == EventType.SubscribeModified
|
||||
assert event_payload["subscribe_id"] == 9
|
||||
assert event_payload["scene"] == "agent_update"
|
||||
assert event_payload["fields"] == ["name", "state"]
|
||||
assert event_payload["old_subscribe_info"]["name"] == "旧标题"
|
||||
assert event_payload["subscribe_info"]["name"] == "新标题"
|
||||
assert mutation.calls == [(9, {"name": "新标题", "state": "S"}, "agent_update")]
|
||||
|
||||
|
||||
def test_agent_update_subscribe_ignores_unchanged_total_episode():
|
||||
@@ -54,13 +47,11 @@ def test_agent_update_subscribe_ignores_unchanged_total_episode():
|
||||
)
|
||||
oper = _SubscribeOperStub(subscribe)
|
||||
|
||||
mutation = _MutationServiceStub(oper)
|
||||
with patch(
|
||||
"app.agent.tools.impl.update_subscribe.SubscribeOper",
|
||||
return_value=oper,
|
||||
), patch(
|
||||
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
) as send_event:
|
||||
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _mutation_scope(mutation),
|
||||
):
|
||||
result = asyncio.run(
|
||||
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
subscribe_id=160,
|
||||
@@ -71,7 +62,7 @@ def test_agent_update_subscribe_ignores_unchanged_total_episode():
|
||||
payload = json.loads(result)
|
||||
assert payload == {"success": False, "message": "没有提供要更新的字段"}
|
||||
assert oper.updates == []
|
||||
send_event.assert_not_awaited()
|
||||
assert mutation.calls == []
|
||||
|
||||
|
||||
def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_episode():
|
||||
@@ -88,13 +79,11 @@ def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_e
|
||||
)
|
||||
oper = _SubscribeOperStub(subscribe)
|
||||
|
||||
mutation = _MutationServiceStub(oper)
|
||||
with patch(
|
||||
"app.agent.tools.impl.update_subscribe.SubscribeOper",
|
||||
return_value=oper,
|
||||
), patch(
|
||||
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
) as send_event:
|
||||
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _mutation_scope(mutation),
|
||||
):
|
||||
result = asyncio.run(
|
||||
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
subscribe_id=160,
|
||||
@@ -108,9 +97,7 @@ def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_e
|
||||
assert payload["updated_fields"] == ["best_version"]
|
||||
assert payload["subscribe"]["manual_total_episode"] == 0
|
||||
assert oper.updates == [(160, {"best_version": 1})]
|
||||
send_event.assert_awaited_once()
|
||||
_, event_payload = send_event.await_args.args
|
||||
assert event_payload["fields"] == ["best_version"]
|
||||
assert mutation.calls == [(160, {"best_version": 1}, "agent_update")]
|
||||
|
||||
|
||||
def test_agent_update_subscribe_marks_changed_total_episode_as_manual():
|
||||
@@ -126,12 +113,10 @@ def test_agent_update_subscribe_marks_changed_total_episode_as_manual():
|
||||
)
|
||||
oper = _SubscribeOperStub(subscribe)
|
||||
|
||||
mutation = _MutationServiceStub(oper)
|
||||
with patch(
|
||||
"app.agent.tools.impl.update_subscribe.SubscribeOper",
|
||||
return_value=oper,
|
||||
), patch(
|
||||
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
|
||||
side_effect=lambda: _mutation_scope(mutation),
|
||||
):
|
||||
result = asyncio.run(
|
||||
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
|
||||
@@ -186,3 +171,33 @@ class _SubscribeOperStub:
|
||||
self.updates.append((subscribe_id, dict(payload)))
|
||||
self.subscribe.__dict__.update(payload)
|
||||
return self.subscribe
|
||||
|
||||
|
||||
class _MutationServiceStub:
|
||||
"""让 Agent 工具测试观察事务化修改服务收到的最终 payload。"""
|
||||
|
||||
def __init__(self, oper):
|
||||
"""保存内存 Oper 与调用记录。"""
|
||||
self.oper = oper
|
||||
self.calls = []
|
||||
|
||||
async def get_accessible(self, subscribe_id, _actor):
|
||||
"""模拟事务作用域内的权限读取。"""
|
||||
return await self.oper.async_get(subscribe_id)
|
||||
|
||||
async def update(self, subscribe_id, payload, _actor, scene="update"):
|
||||
"""模拟事务化更新并返回稳定快照。"""
|
||||
old = self.oper.subscribe.to_dict()
|
||||
updated = await self.oper.async_update(subscribe_id, payload)
|
||||
self.calls.append((subscribe_id, dict(payload), scene))
|
||||
return SubscriptionMutation(
|
||||
old=old,
|
||||
new=updated.to_dict(),
|
||||
event_published=True,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _mutation_scope(service):
|
||||
"""把测试修改服务包装成 Agent 使用的异步事务作用域。"""
|
||||
yield service
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.api.data import (
|
||||
get_api_data_ports,
|
||||
)
|
||||
from app.startup import lifecycle
|
||||
from app.startup.context import AgentChatRuntime, HostRuntime
|
||||
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
|
||||
|
||||
|
||||
class _Repository:
|
||||
@@ -42,6 +42,20 @@ class _UnitOfWork:
|
||||
"""模拟回滚。"""
|
||||
|
||||
|
||||
class _Outbox:
|
||||
"""记录绑定会话的异步 outbox 替身。"""
|
||||
|
||||
def __init__(self, session: object) -> None:
|
||||
"""保存与订阅仓储相同的请求会话。"""
|
||||
self.session = session
|
||||
|
||||
async def stage(self, intent, now) -> None:
|
||||
"""模拟暂存 durable intent。"""
|
||||
|
||||
async def complete_by_event_key(self, event_key, completed_at) -> None:
|
||||
"""模拟收口 durable intent。"""
|
||||
|
||||
|
||||
def _runtime() -> HostRuntime:
|
||||
"""构造不加载数据库引擎或 PluginManager 的假宿主运行时。"""
|
||||
async def async_session():
|
||||
@@ -66,6 +80,13 @@ def _runtime() -> HostRuntime:
|
||||
repository=_Repository,
|
||||
transaction=_UnitOfWork,
|
||||
),
|
||||
subscription=SubscriptionRuntime(
|
||||
async_session=async_session,
|
||||
repository=_Repository,
|
||||
history_repository=_Repository,
|
||||
transaction=_UnitOfWork,
|
||||
outbox=_Outbox,
|
||||
),
|
||||
compatibility_api_data=compatibility,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,8 +65,9 @@ def _candidate(subscribe_id, username):
|
||||
|
||||
def _command(candidates, calls, commit_error=None, failing_event_id=None):
|
||||
"""构造带可观察事件错误处理的批量删除用例。"""
|
||||
async def publish(subscribe_id, payload):
|
||||
async def publish(payload):
|
||||
"""记录事件并按订阅编号注入失败。"""
|
||||
subscribe_id = payload["subscribe_id"]
|
||||
calls.append(("event", subscribe_id, payload))
|
||||
if subscribe_id == failing_event_id:
|
||||
raise RuntimeError("event failed")
|
||||
|
||||
@@ -50,6 +50,25 @@ class _UnitOfWork:
|
||||
self.calls.append(("rollback",))
|
||||
|
||||
|
||||
class _Outbox:
|
||||
"""记录订阅删除 intent 暂存和收口顺序的 outbox 替身。"""
|
||||
|
||||
def __init__(self, calls, stage_error=None):
|
||||
"""保存共享调用序列与可选暂存异常。"""
|
||||
self.calls = calls
|
||||
self.stage_error = stage_error
|
||||
|
||||
async def stage(self, intent, _now):
|
||||
"""记录 intent,并按需模拟持久化失败。"""
|
||||
self.calls.append(("outbox_stage", intent))
|
||||
if self.stage_error:
|
||||
raise self.stage_error
|
||||
|
||||
async def complete_by_event_key(self, event_key, _completed_at):
|
||||
"""记录即时事件成功后的 intent 收口。"""
|
||||
self.calls.append(("outbox_complete", event_key))
|
||||
|
||||
|
||||
def _candidate(username="alice"):
|
||||
"""构造带完整事件身份字段的订阅删除候选。"""
|
||||
return SubscribeDeletionCandidate(
|
||||
@@ -66,11 +85,18 @@ def _candidate(username="alice"):
|
||||
)
|
||||
|
||||
|
||||
def _command(candidate, calls, commit_error=None, event_error=None, report_error=None):
|
||||
def _command(
|
||||
candidate,
|
||||
calls,
|
||||
commit_error=None,
|
||||
event_error=None,
|
||||
report_error=None,
|
||||
outbox=None,
|
||||
):
|
||||
"""构造可观察事件与上报失败的订阅删除用例。"""
|
||||
async def publish(subscribe_id, subscribe_info):
|
||||
async def publish(payload):
|
||||
"""记录删除事件并按需失败。"""
|
||||
calls.append(("event", subscribe_id, subscribe_info))
|
||||
calls.append(("event", payload["subscribe_id"], payload))
|
||||
if event_error:
|
||||
raise event_error
|
||||
|
||||
@@ -85,6 +111,7 @@ def _command(candidate, calls, commit_error=None, event_error=None, report_error
|
||||
unit_of_work=_UnitOfWork(calls, commit_error),
|
||||
publish_deleted=publish,
|
||||
report_deleted=report,
|
||||
outbox=outbox,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,12 +128,9 @@ async def test_owner_delete_commits_before_event_and_report():
|
||||
|
||||
assert deleted is True
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
|
||||
assert calls[3][2] == _candidate().event_payload
|
||||
assert calls[4][1] == {
|
||||
"media_source": "tmdb",
|
||||
"media_id": "123",
|
||||
"season": 2,
|
||||
}
|
||||
assert calls[3][2]["subscribe_info"] == _candidate().event_payload
|
||||
assert calls[3][2]["idempotency_key"].startswith("subscribe.deleted:7:")
|
||||
assert calls[4][1] == _candidate().event_payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -185,6 +209,56 @@ async def test_report_failure_happens_after_commit_and_event():
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_stages_outbox_before_commit_and_completes_after_event():
|
||||
"""订阅删除、intent 与即时事件必须按原子提交和成功收口顺序执行。"""
|
||||
calls = []
|
||||
command = _command(_candidate(), calls, outbox=_Outbox(calls))
|
||||
|
||||
assert await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
) is True
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
"get",
|
||||
"delete",
|
||||
"outbox_stage",
|
||||
"commit",
|
||||
"event",
|
||||
"outbox_complete",
|
||||
"report",
|
||||
]
|
||||
intent = calls[2][1]
|
||||
assert intent.topic == "subscribe.deleted"
|
||||
assert intent.event_key == calls[4][2]["idempotency_key"]
|
||||
assert calls[5][1] == intent.event_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_outbox_stage_failure_rolls_back_business_delete():
|
||||
"""订阅删除 intent 无法暂存时不得提交业务删除。"""
|
||||
calls = []
|
||||
command = _command(
|
||||
_candidate(),
|
||||
calls,
|
||||
outbox=_Outbox(calls, stage_error=RuntimeError("outbox failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="outbox failed"):
|
||||
await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
"get",
|
||||
"delete",
|
||||
"outbox_stage",
|
||||
"rollback",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_candidate_uses_loaded_orm_snapshot(monkeypatch):
|
||||
"""DB 适配器只向应用层暴露权限字段和完整列快照。"""
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""订阅修改 UoW 与 durable outbox 边界测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.mutation import (
|
||||
SubscriptionActor,
|
||||
SubscriptionMutationService,
|
||||
)
|
||||
|
||||
|
||||
class _Subscribe:
|
||||
"""提供稳定前后快照的订阅替身。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化可修改字段与 owner。"""
|
||||
self.id = 7
|
||||
self.username = "alice"
|
||||
self.name = "旧标题"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""返回当前订阅快照。"""
|
||||
return {"id": self.id, "username": self.username, "name": self.name}
|
||||
|
||||
|
||||
class _Repository:
|
||||
"""记录订阅读取、兼容更新和事务内暂存顺序。"""
|
||||
|
||||
def __init__(self, subscribe: _Subscribe, calls: list) -> None:
|
||||
"""保存订阅对象与共享调用序列。"""
|
||||
self.subscribe = subscribe
|
||||
self.calls = calls
|
||||
|
||||
async def async_get(self, subscribe_id: int):
|
||||
"""返回指定订阅。"""
|
||||
self.calls.append(("get", subscribe_id))
|
||||
return self.subscribe
|
||||
|
||||
async def async_update(self, subscribe_id: int, payload: dict):
|
||||
"""模拟旧兼容自动提交路径。"""
|
||||
self.calls.append(("legacy_update", subscribe_id, payload))
|
||||
for key, value in payload.items():
|
||||
setattr(self.subscribe, key, value)
|
||||
return self.subscribe
|
||||
|
||||
async def async_stage_update(self, subscribe_id: int, payload: dict):
|
||||
"""模拟调用方事务内的更新暂存。"""
|
||||
self.calls.append(("stage_update", subscribe_id, payload))
|
||||
for key, value in payload.items():
|
||||
setattr(self.subscribe, key, value)
|
||||
return self.subscribe
|
||||
|
||||
def get(self, subscribe_id: int):
|
||||
"""提供协议要求的同步读取。"""
|
||||
return self.subscribe if subscribe_id == self.subscribe.id else None
|
||||
|
||||
|
||||
class _UnitOfWork:
|
||||
"""记录订阅修改事务提交和回滚。"""
|
||||
|
||||
def __init__(self, calls: list) -> None:
|
||||
"""保存共享调用序列。"""
|
||||
self.calls = calls
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""记录提交。"""
|
||||
self.calls.append(("commit",))
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""记录回滚。"""
|
||||
self.calls.append(("rollback",))
|
||||
|
||||
|
||||
class _Outbox:
|
||||
"""记录修改事件 intent 暂存和完成。"""
|
||||
|
||||
def __init__(self, calls: list, stage_error: Exception | None = None) -> None:
|
||||
"""保存共享调用序列与可选暂存异常。"""
|
||||
self.calls = calls
|
||||
self.stage_error = stage_error
|
||||
|
||||
async def stage(self, intent, _now) -> None:
|
||||
"""记录 intent 并按需失败。"""
|
||||
self.calls.append(("outbox_stage", intent))
|
||||
if self.stage_error:
|
||||
raise self.stage_error
|
||||
|
||||
async def complete_by_event_key(self, event_key: str, _completed_at) -> None:
|
||||
"""记录即时事件成功后的完成键。"""
|
||||
self.calls.append(("outbox_complete", event_key))
|
||||
|
||||
|
||||
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
|
||||
"""构造拥有请求级 UoW 和 outbox 的订阅修改服务。"""
|
||||
subscribe = _Subscribe()
|
||||
|
||||
async def publish(payload: dict) -> None:
|
||||
"""记录公开事件并按需失败。"""
|
||||
calls.append(("event", payload))
|
||||
if event_error:
|
||||
raise event_error
|
||||
|
||||
return SubscriptionMutationService(
|
||||
repository=_Repository(subscribe, calls),
|
||||
unit_of_work=_UnitOfWork(calls),
|
||||
outbox=outbox or _Outbox(calls),
|
||||
publish_modified=publish,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modified_event_is_staged_with_update_and_completed_after_publish():
|
||||
"""订阅修改与 intent 同事务提交,事件成功后才标记完成。"""
|
||||
calls = []
|
||||
service = _service(calls)
|
||||
|
||||
change = await service.update(
|
||||
7,
|
||||
{"name": "新标题"},
|
||||
SubscriptionActor(name="alice", is_superuser=False),
|
||||
scene="update",
|
||||
)
|
||||
|
||||
assert change is not None
|
||||
assert change.event_published is True
|
||||
assert change.old["name"] == "旧标题"
|
||||
assert change.new["name"] == "新标题"
|
||||
assert [call[0] for call in calls] == [
|
||||
"get",
|
||||
"stage_update",
|
||||
"outbox_stage",
|
||||
"commit",
|
||||
"event",
|
||||
"outbox_complete",
|
||||
]
|
||||
intent = calls[2][1]
|
||||
assert intent.topic == "subscribe.modified"
|
||||
assert intent.event_key.startswith("subscribe.modified:7:update:")
|
||||
assert calls[4][1]["idempotency_key"] == intent.event_key
|
||||
assert calls[5][1] == intent.event_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modified_outbox_stage_failure_rolls_back_update():
|
||||
"""修改事件 intent 无法暂存时业务更新不得提交。"""
|
||||
calls = []
|
||||
service = _service(
|
||||
calls,
|
||||
outbox=_Outbox(calls, stage_error=RuntimeError("outbox failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="outbox failed"):
|
||||
await service.update(
|
||||
7,
|
||||
{"name": "新标题"},
|
||||
SubscriptionActor(name="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
"get",
|
||||
"stage_update",
|
||||
"outbox_stage",
|
||||
"rollback",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modified_event_failure_keeps_committed_intent_pending():
|
||||
"""提交后的事件失败向调用方传播,且不得错误收口待恢复 intent。"""
|
||||
calls = []
|
||||
service = _service(calls, event_error=RuntimeError("event failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="event failed"):
|
||||
await service.update(
|
||||
7,
|
||||
{"name": "新标题"},
|
||||
SubscriptionActor(name="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
"get",
|
||||
"stage_update",
|
||||
"outbox_stage",
|
||||
"commit",
|
||||
"event",
|
||||
]
|
||||
Reference in New Issue
Block a user