mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
282 lines
7.6 KiB
Python
282 lines
7.6 KiB
Python
"""
|
||
订阅修改 UoW 与 durable outbox 边界测试。
|
||
"""
|
||
|
||
from dataclasses import replace
|
||
|
||
import pytest
|
||
|
||
from app.application.outbox import ClaimedOutboxMessage
|
||
from app.application.subscription.contract import SubscriptionPatch, SubscriptionSnapshot
|
||
from app.application.subscription.mutation import (
|
||
SubscriptionActor,
|
||
SubscriptionMutationService,
|
||
)
|
||
|
||
|
||
class _Repository:
|
||
"""
|
||
记录订阅读取和事务内暂存顺序。
|
||
"""
|
||
|
||
def __init__(self, subscribe: SubscriptionSnapshot, 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_stage_update(self, subscribe_id: int, payload: SubscriptionPatch) -> SubscriptionSnapshot:
|
||
"""
|
||
模拟调用方事务内的更新暂存。
|
||
"""
|
||
self.calls.append(("stage_update", subscribe_id, payload))
|
||
self.subscribe = replace(self.subscribe, **payload.to_payload())
|
||
return self.subscribe
|
||
|
||
def get(self, subscribe_id: int):
|
||
"""
|
||
提供协议要求的同步读取。
|
||
"""
|
||
return self.subscribe if subscribe_id == self.subscribe.id else None
|
||
|
||
def stage_update(self, subscribe_id: int, payload: SubscriptionPatch) -> SubscriptionSnapshot:
|
||
"""
|
||
模拟同步调用方事务内的更新暂存。
|
||
"""
|
||
self.calls.append(("stage_update", subscribe_id, payload))
|
||
self.subscribe = replace(self.subscribe, **payload.to_payload())
|
||
return self.subscribe
|
||
|
||
|
||
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,
|
||
claim: bool = True,
|
||
) -> None:
|
||
"""
|
||
保存共享调用序列与可选暂存异常。
|
||
"""
|
||
self.calls = calls
|
||
self.stage_error = stage_error
|
||
self.claim = claim
|
||
|
||
async def stage(self, intent, _now) -> None:
|
||
"""
|
||
记录 intent 并按需失败。
|
||
"""
|
||
self.calls.append(("outbox_stage", intent))
|
||
if self.stage_error:
|
||
raise self.stage_error
|
||
|
||
async def claim_by_event_key(self, event_key, _now, _lease_until):
|
||
"""
|
||
记录并返回当前测试拥有的派发 lease。
|
||
"""
|
||
self.calls.append(("outbox_claim", event_key))
|
||
if not self.claim:
|
||
return None
|
||
return ClaimedOutboxMessage(
|
||
message_id=7,
|
||
event_key=event_key,
|
||
topic="subscribe.modified",
|
||
payload={},
|
||
payload_version=1,
|
||
attempt=1,
|
||
)
|
||
|
||
async def complete(self, message_id, attempt, _completed_at):
|
||
"""
|
||
记录带 attempt fencing 的完成结算。
|
||
"""
|
||
self.calls.append(("outbox_complete", message_id, attempt))
|
||
return True
|
||
|
||
async def retry(self, message_id, attempt, **_kwargs):
|
||
"""
|
||
记录带 attempt fencing 的失败释放。
|
||
"""
|
||
self.calls.append(("outbox_retry", message_id, attempt))
|
||
return True
|
||
|
||
|
||
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
|
||
"""
|
||
构造拥有请求级 UoW 和 outbox 的订阅修改服务。
|
||
"""
|
||
subscribe = SubscriptionSnapshot(
|
||
id=7,
|
||
username="alice",
|
||
name="旧标题",
|
||
)
|
||
|
||
async def publish(payload: dict) -> None:
|
||
"""
|
||
记录公开事件并按需失败。
|
||
"""
|
||
calls.append(("event", payload))
|
||
if event_error:
|
||
raise event_error
|
||
|
||
outbox = outbox or _Outbox(calls)
|
||
return SubscriptionMutationService(
|
||
repository=_Repository(subscribe, calls),
|
||
unit_of_work=_UnitOfWork(calls),
|
||
outbox=outbox,
|
||
dispatch_store=outbox,
|
||
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.snapshot.name == "新标题"
|
||
assert change.old["name"] == "旧标题"
|
||
assert change.new["name"] == "新标题"
|
||
assert [call[0] for call in calls] == [
|
||
"get",
|
||
"stage_update",
|
||
"outbox_stage",
|
||
"commit",
|
||
"outbox_claim",
|
||
"event",
|
||
"outbox_complete",
|
||
]
|
||
intent = calls[2][1]
|
||
assert intent.topic == "subscribe.modified"
|
||
assert intent.event_key.startswith("subscribe.modified:7:update:")
|
||
assert calls[5][1]["idempotency_key"] == intent.event_key
|
||
assert calls[6][1:] == (7, 1)
|
||
|
||
|
||
@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",
|
||
"outbox_claim",
|
||
"event",
|
||
"outbox_retry",
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unclaimed_committed_intent_stays_pending_without_second_publish():
|
||
"""
|
||
即时派发未取得 lease 时只返回 pending,调用方不得绕过 outbox 再发布。
|
||
"""
|
||
calls = []
|
||
service = _service(calls, outbox=_Outbox(calls, claim=False))
|
||
|
||
change = await service.update(
|
||
7,
|
||
{"name": "新标题"},
|
||
SubscriptionActor(name="alice", is_superuser=False),
|
||
)
|
||
|
||
assert change is not None
|
||
assert change.snapshot.name == "新标题"
|
||
assert change.business_committed is True
|
||
assert change.event_published is False
|
||
assert change.pending_effects[0].startswith("subscribe.modified:7:update:")
|
||
assert [call[0] for call in calls] == [
|
||
"get",
|
||
"stage_update",
|
||
"outbox_stage",
|
||
"commit",
|
||
"outbox_claim",
|
||
]
|