Files
MoviePilot/tests/test_subscribe_search_state.py
T

161 lines
5.4 KiB
Python

from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.schemas.types import MediaType
class _SubscribeOper:
"""
最小订阅 Oper 替身,隔离订阅搜索状态流转测试的数据库访问。
"""
subscribe = None
updates = []
def get(self, sid: int):
"""
按 ID 返回测试订阅对象。
"""
return self.subscribe if self.subscribe and self.subscribe.id == sid else None
def list(self, _state: str):
"""
返回批量搜索需要的测试订阅列表。
"""
return [self.subscribe] if self.subscribe else []
def update(self, sid: int, payload: dict) -> None:
"""
记录订阅状态更新请求。
"""
self.updates.append((sid, payload))
class _TimedOutLock:
"""模拟订阅搜索锁在等待窗口内始终无法取得。"""
def acquire(self, **_kwargs):
"""返回未取得锁,验证调用方不会越过互斥边界继续执行。"""
return False
def release(self):
"""超时路径不应释放未持有的锁。"""
raise AssertionError("未持有的订阅锁不应被释放")
def _new_subscribe(created_at: datetime) -> SimpleNamespace:
"""
构造一个新建电影订阅。
"""
return SimpleNamespace(
id=31,
name="测试电影",
year="2026",
type=MediaType.MOVIE.value,
tmdbid=12345,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="12345",
season=None,
custom_words=None,
date=created_at.strftime("%Y-%m-%d %H:%M:%S"),
state="N",
episode_group=None,
)
def test_new_subscribe_search_keeps_state_when_recently_created(monkeypatch) -> None:
"""
新增 60 秒保护期内跳过搜索时,应保留 N 状态等待下一轮新增订阅搜索。
"""
_SubscribeOper.subscribe = _new_subscribe(datetime.now())
_SubscribeOper.updates = []
monkeypatch.setattr(subscribe_module, "SubscribeOper", _SubscribeOper)
media_chain_class = Mock()
with patch.object(subscribe_module, "MediaChain", media_chain_class):
chain = object.__new__(SubscribeChain)
chain.search(state="N", manual=False)
media_chain_class.assert_not_called()
assert _SubscribeOper.updates == []
def test_new_subscribe_search_marks_state_after_attempt(monkeypatch) -> None:
"""
新增订阅越过保护期并实际尝试搜索后,应从 N 状态收敛为 R。
"""
_SubscribeOper.subscribe = _new_subscribe(datetime.now() - timedelta(minutes=2))
_SubscribeOper.updates = []
monkeypatch.setattr(subscribe_module, "SubscribeOper", _SubscribeOper)
media_chain = Mock()
media_chain.recognize_media.return_value = None
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
chain = object.__new__(SubscribeChain)
chain.search(state="N", manual=False)
media_chain.recognize_media.assert_called_once()
assert _SubscribeOper.updates == [(31, {"state": "R"})]
def test_targeted_batch_searches_all_ids_without_state_scan(monkeypatch) -> None:
"""用户归属订阅批次只按指定 ID 顺序读取,不扩大为全局状态搜索。"""
first = _new_subscribe(datetime.now() - timedelta(minutes=2))
first.state = "R"
second = _new_subscribe(datetime.now() - timedelta(minutes=2))
second.id = 32
second.name = "测试电影 2"
second.state = "R"
subscribes = {first.id: first, second.id: second}
subscribe_oper = Mock()
subscribe_oper.get.side_effect = subscribes.get
monkeypatch.setattr(
subscribe_module,
"SubscribeOper",
lambda: subscribe_oper,
)
media_chain = Mock()
media_chain.recognize_media.return_value = None
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
chain = object.__new__(SubscribeChain)
chain.search(sids=(31, 32), state=None, manual=False)
assert [item.args for item in subscribe_oper.get.call_args_list] == [(31,), (32,)]
subscribe_oper.list.assert_not_called()
assert media_chain.recognize_media.call_count == 2
def test_subscribe_search_aborts_when_lock_times_out(monkeypatch) -> None:
"""订阅搜索锁超时后必须中止,不能在无锁状态下继续访问订阅。"""
monkeypatch.setattr(SubscribeChain, "_rlock", _TimedOutLock())
subscribe_oper = Mock()
monkeypatch.setattr(subscribe_module, "SubscribeOper", subscribe_oper)
progress = Mock()
chain = object.__new__(SubscribeChain)
chain.search(state="N", progress_callback=progress)
subscribe_oper.assert_not_called()
progress.assert_called_once_with(
value=100,
text="订阅搜索锁等待超时,已跳过本轮",
)
def test_subscribe_match_aborts_when_lock_times_out(monkeypatch) -> None:
"""订阅匹配锁超时后必须中止,不能绕过防重复下载边界。"""
monkeypatch.setattr(SubscribeChain, "_rlock", _TimedOutLock())
progress = Mock()
chain = object.__new__(SubscribeChain)
chain.match({"example.org": []}, progress_callback=progress)
progress.assert_any_call(value=100, text="订阅匹配锁等待超时,已跳过本轮")