Files
MoviePilot/tests/test_subscribe_delete_command.py

552 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""订阅删除应用用例的事务、权限与副作用时序测试。"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.orm import Session
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SyncDeleteSubscribeCommand,
SubscribeDeletionActor,
SubscribeDeletionCandidate,
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
class _Repository:
"""记录订阅删除用例数据访问顺序的仓储替身。"""
def __init__(self, candidate, calls, delete_error=None):
"""保存候选订阅、共享调用序列和可选删除异常。"""
self.candidate = candidate
self.calls = calls
self.delete_error = delete_error
async def get_candidate(self, subscribe_id):
"""返回预设候选订阅。"""
self.calls.append(("get", subscribe_id))
return self.candidate
async def stage_delete(self, subscribe_id):
"""记录待删除的订阅编号。"""
self.calls.append(("delete", subscribe_id))
if self.delete_error:
raise self.delete_error
class _UnitOfWork:
"""可注入提交异常的事务替身。"""
def __init__(self, calls, commit_error=None):
"""保存共享调用序列与可选提交异常。"""
self.calls = calls
self.commit_error = commit_error
async def commit(self):
"""记录提交并按需抛出异常。"""
self.calls.append(("commit",))
if self.commit_error:
raise self.commit_error
async def rollback(self):
"""记录回滚。"""
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))
class _SyncRepository:
"""记录同步订阅删除的数据访问顺序。"""
def __init__(self, candidate, calls, delete_error=None):
"""保存候选订阅、共享调用序列和可选删除异常。"""
self.candidate = candidate
self.calls = calls
self.delete_error = delete_error
def get_candidate_sync(self, subscribe_id):
"""返回预设候选订阅。"""
self.calls.append(("get", subscribe_id))
return self.candidate
def stage_delete_sync(self, subscribe_id):
"""记录同步待删除编号并按需失败。"""
self.calls.append(("delete", subscribe_id))
if self.delete_error:
raise self.delete_error
class _SyncUnitOfWork:
"""记录同步删除命令的提交与回滚。"""
def __init__(self, calls, commit_error=None):
"""保存共享调用序列与可选提交异常。"""
self.calls = calls
self.commit_error = commit_error
def commit(self):
"""记录提交并按需抛出异常。"""
self.calls.append(("commit",))
if self.commit_error:
raise self.commit_error
def rollback(self):
"""记录回滚。"""
self.calls.append(("rollback",))
class _SyncOutbox:
"""记录同步删除 intent 的暂存与完成顺序。"""
def __init__(self, calls):
"""保存共享调用序列。"""
self.calls = calls
def stage(self, intent, _now):
"""记录同步暂存的 intent。"""
self.calls.append(("outbox_stage", intent))
def complete_by_event_key(self, event_key, _completed_at):
"""记录同步完成的 intent。"""
self.calls.append(("outbox_complete", event_key))
def _candidate(username="alice"):
"""构造带完整事件身份字段的订阅删除候选。"""
return SubscribeDeletionCandidate(
subscribe_id=7,
username=username,
event_payload={
"id": 7,
"username": username,
"media_source": "tmdb",
"media_id": "123",
"season": 2,
"name": "测试订阅",
},
)
def _command(
candidate,
calls,
commit_error=None,
event_error=None,
report_error=None,
outbox=None,
delete_error=None,
):
"""构造可观察事件与上报失败的订阅删除用例。"""
async def publish(payload):
"""记录删除事件并按需失败。"""
calls.append(("event", payload["subscribe_id"], payload))
if event_error:
raise event_error
def report(payload):
"""记录删除统计并按需失败。"""
calls.append(("report", payload))
if report_error:
raise report_error
return True
return DeleteSubscribeCommand(
repository=_Repository(candidate, calls, delete_error),
unit_of_work=_UnitOfWork(calls, commit_error),
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
)
def _async_report_command(candidate, calls, result=True, error=None, outbox=None):
"""构造异步统计 reporter验证命令可等待真实远端确认。"""
async def publish(payload):
"""记录删除事件。"""
calls.append(("event", payload["subscribe_id"], payload))
async def report(payload):
"""记录异步统计并按需返回未确认或抛错。"""
calls.append(("report", payload))
if error:
raise error
return result
return DeleteSubscribeCommand(
repository=_Repository(candidate, calls),
unit_of_work=_UnitOfWork(calls),
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
)
def _sync_command(candidate, calls, commit_error=None, delete_error=None, outbox=None):
"""构造可观察事务和副作用顺序的同步订阅删除命令。"""
def publish(payload):
"""记录同步删除事件。"""
calls.append(("event", payload["subscribe_id"], payload))
def report(payload):
"""记录同步删除统计。"""
calls.append(("report", payload))
return True
return SyncDeleteSubscribeCommand(
repository=_SyncRepository(candidate, calls, delete_error),
unit_of_work=_SyncUnitOfWork(calls, commit_error),
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
)
@pytest.mark.asyncio
async def test_owner_delete_commits_before_event_and_report():
"""owner 删除成功时必须先提交,再按原顺序发送事件和上报。"""
calls = []
command = _command(_candidate(), calls)
deleted = await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert deleted is True
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
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
@pytest.mark.parametrize("candidate", [None, _candidate("bob"), _candidate(None)])
async def test_regular_user_cannot_delete_missing_other_or_legacy_subscribe(candidate):
"""普通用户对不存在、他人和 legacy 订阅保持无痕成功语义。"""
calls = []
command = _command(candidate, calls)
deleted = await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert deleted is False
assert calls == [("get", 7)]
@pytest.mark.asyncio
async def test_superuser_can_delete_other_users_subscribe():
"""超级用户保留全局订阅删除权限。"""
calls = []
command = _command(_candidate("bob"), calls)
deleted = await command.execute(
7,
SubscribeDeletionActor(username="admin", is_superuser=True),
)
assert deleted is True
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
@pytest.mark.asyncio
async def test_commit_failure_rolls_back_without_event_or_report():
"""提交失败必须回滚,且不得发送成功事件或统计上报。"""
calls = []
command = _command(_candidate(), calls, commit_error=RuntimeError("commit failed"))
with pytest.raises(RuntimeError, match="commit failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == ["get", "delete", "commit", "rollback"]
@pytest.mark.asyncio
async def test_delete_stage_failure_rolls_back_without_effects():
"""异步暂存失败必须显式回滚,且不得写 intent 或发送成功副作用。"""
calls = []
command = _command(
_candidate(),
calls,
delete_error=RuntimeError("delete failed"),
outbox=_Outbox(calls),
)
with pytest.raises(RuntimeError, match="delete failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == ["get", "delete", "rollback"]
@pytest.mark.asyncio
async def test_event_failure_happens_after_commit_and_stops_report():
"""事件失败保持原有传播语义,但事务必须已经提交且不得继续上报。"""
calls = []
command = _command(_candidate(), calls, event_error=RuntimeError("event failed"))
with pytest.raises(RuntimeError, match="event failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == ["get", "delete", "commit", "event"]
@pytest.mark.asyncio
async def test_report_failure_happens_after_commit_and_event():
"""上报失败保持原有传播语义,且不得改变已经提交和发出的事件。"""
calls = []
command = _command(_candidate(), calls, report_error=RuntimeError("report failed"))
with pytest.raises(RuntimeError, match="report failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
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",
"outbox_stage",
"commit",
"event",
"outbox_complete",
"report",
"outbox_complete",
]
intent = calls[2][1]
assert intent.topic == "subscribe.deleted"
report_intent = calls[3][1]
assert report_intent.topic == "subscribe.deleted.report"
assert intent.event_key == calls[5][2]["idempotency_key"]
assert calls[6][1] == intent.event_key
assert calls[8][1] == report_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_async_reporter_completes_report_intent_only_after_confirmation():
"""异步 reporter 确认成功后才允许收口统计 intent。"""
calls = []
command = _async_report_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", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
]
@pytest.mark.asyncio
async def test_async_reporter_false_keeps_report_intent_pending():
"""异步 reporter 未确认时必须保留待重试统计 intent。"""
calls = []
command = _async_report_command(_candidate(), calls, result=False, outbox=_Outbox(calls))
with pytest.raises(RuntimeError, match="未确认"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
]
@pytest.mark.asyncio
async def test_async_reporter_error_keeps_report_intent_pending():
"""异步 reporter 异常时必须保留待重试统计 intent。"""
calls = []
command = _async_report_command(
_candidate(),
calls,
error=RuntimeError("remote failed"),
outbox=_Outbox(calls),
)
with pytest.raises(RuntimeError, match="remote failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
]
@pytest.mark.asyncio
async def test_repository_candidate_uses_loaded_orm_snapshot(monkeypatch):
"""DB 适配器只向应用层暴露权限字段和完整列快照。"""
subscribe = Subscribe(
id=7,
username="alice",
name="测试订阅",
media_source="tmdb",
media_id="123",
season=2,
)
async def async_get(_self, subscribe_id):
"""返回无需真实数据库的订阅模型。"""
assert subscribe_id == 7
return subscribe
monkeypatch.setattr(SubscribeOper, "async_get", async_get)
candidate = await SubscribeOper(object()).get_candidate(7)
assert candidate is not None
assert candidate.subscribe_id == 7
assert candidate.username == "alice"
assert candidate.event_payload["id"] == 7
assert candidate.event_payload["media_source"] == "tmdb"
assert candidate.event_payload["media_id"] == "123"
@pytest.mark.asyncio
async def test_repository_stage_delete_does_not_commit():
"""真实仓储只登记删除,提交必须由请求级 UnitOfWork 执行。"""
session = type("SessionStub", (), {})()
session.execute = AsyncMock()
session.commit = AsyncMock()
await SubscribeOper(session).stage_delete(7)
session.execute.assert_awaited_once()
session.commit.assert_not_awaited()
def test_sync_delete_uses_same_durable_effect_order():
"""同步消息入口必须复用异步删除命令的事务、事件和统计顺序。"""
calls = []
command = _sync_command(_candidate(), calls, outbox=_SyncOutbox(calls))
assert command.execute(
7,
SubscribeDeletionActor(username="", is_superuser=True),
) is True
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
]
assert calls[2][1].topic == "subscribe.deleted"
assert calls[3][1].topic == "subscribe.deleted.report"
assert calls[7][1] == _candidate().event_payload
@pytest.mark.parametrize("failure", ["delete", "commit"])
def test_sync_delete_rolls_back_transaction_failures(failure):
"""同步暂存或提交失败时必须回滚,且不得发送删除成功副作用。"""
calls = []
error = RuntimeError(f"{failure} failed")
command = _sync_command(
_candidate(),
calls,
delete_error=error if failure == "delete" else None,
commit_error=error if failure == "commit" else None,
)
with pytest.raises(RuntimeError, match=f"{failure} failed"):
command.execute(
7,
SubscribeDeletionActor(username="", is_superuser=True),
)
assert calls[-1] == ("rollback",)
assert all(call[0] not in {"event", "report"} for call in calls)
def test_sync_repository_candidate_and_delete_share_caller_session(monkeypatch):
"""同步仓储投影和删除都使用组合根传入的同一个 Session 且不提交。"""
subscribe = Subscribe(
id=7,
username="alice",
name="测试订阅",
media_source="tmdb",
media_id="123",
season=2,
)
session = MagicMock(spec=Session)
oper = SubscribeOper(session)
monkeypatch.setattr(oper, "get", lambda subscribe_id: subscribe)
candidate = oper.get_candidate_sync(7)
oper.stage_delete_sync(7)
assert candidate is not None
assert candidate.event_payload["media_id"] == "123"