refactor: make subscription lifecycle events durable

This commit is contained in:
jxxghp
2026-08-22 07:43:41 +08:00
parent 8f94fd620d
commit c5de1c7b1b
27 changed files with 1106 additions and 230 deletions
+39 -12
View File
@@ -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
View File
@@ -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": {
+63
View 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()
+52 -37
View File
@@ -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
+22 -1
View File
@@ -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")
+83 -9
View File
@@ -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 适配器只向应用层暴露权限字段和完整列快照。"""
+185
View File
@@ -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",
]