mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
161 lines
5.4 KiB
Python
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="订阅匹配锁等待超时,已跳过本轮")
|