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:
@@ -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