mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 15:34:31 +08:00
274 lines
8.8 KiB
Python
274 lines
8.8 KiB
Python
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
from app.monitor import LocalDirectoryWatcher, Monitor
|
|
|
|
|
|
def _build_watcher(tmp_path, force_polling):
|
|
"""
|
|
构造测试用目录监控。
|
|
:param tmp_path: 监控目录
|
|
:param force_polling: 是否强制轮询
|
|
:return: 目录监控
|
|
"""
|
|
return LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=force_polling)
|
|
|
|
|
|
def test_run_retries_with_backoff_in_compatibility_mode(tmp_path, monkeypatch):
|
|
"""
|
|
兼容模式下监控循环抛异常后应退避重启,而不是直接结束线程。
|
|
"""
|
|
monkeypatch.setattr(LocalDirectoryWatcher, "RESTART_BACKOFF", (0,))
|
|
watcher = _build_watcher(tmp_path, force_polling=True)
|
|
calls = []
|
|
|
|
def fake_run_watch(force_polling):
|
|
"""
|
|
模拟底层监控循环持续抛出 FUSE 错误。
|
|
"""
|
|
calls.append(force_polling)
|
|
if len(calls) >= 3:
|
|
watcher.stop()
|
|
raise OSError(131, "State not recoverable")
|
|
|
|
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
|
|
|
|
watcher._run()
|
|
|
|
assert calls == [True, True, True]
|
|
assert watcher.restart_count == 2
|
|
|
|
|
|
def test_run_falls_back_to_polling_before_backoff(tmp_path, monkeypatch):
|
|
"""
|
|
快速模式失败应先降级为兼容模式重试,且降级不计入退避重启次数。
|
|
"""
|
|
monkeypatch.setattr(LocalDirectoryWatcher, "RESTART_BACKOFF", (0,))
|
|
watcher = _build_watcher(tmp_path, force_polling=None)
|
|
calls = []
|
|
|
|
def fake_run_watch(force_polling):
|
|
"""
|
|
模拟快速模式与兼容模式先后失败。
|
|
"""
|
|
calls.append(force_polling)
|
|
if len(calls) >= 2:
|
|
watcher.stop()
|
|
raise OSError("inotify watch limit reached")
|
|
|
|
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
|
|
|
|
watcher._run()
|
|
|
|
assert calls == [None, True]
|
|
assert watcher.restart_count == 0
|
|
|
|
|
|
def test_run_returns_when_stop_requested(tmp_path, monkeypatch):
|
|
"""
|
|
收到停止信号后监控循环正常返回,不应触发重启。
|
|
"""
|
|
watcher = _build_watcher(tmp_path, force_polling=True)
|
|
calls = []
|
|
|
|
def fake_run_watch(force_polling):
|
|
"""
|
|
模拟收到停止信号后正常退出的监控循环。
|
|
"""
|
|
calls.append(force_polling)
|
|
|
|
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
|
|
|
|
watcher._run()
|
|
|
|
assert calls == [True]
|
|
assert watcher.restart_count == 0
|
|
|
|
|
|
def test_is_stalled_detects_silent_failure(tmp_path):
|
|
"""
|
|
监控线程存活但长时间无活动时应判定为静默失效。
|
|
"""
|
|
watcher = _build_watcher(tmp_path, force_polling=True)
|
|
|
|
# 线程未启动时不做判定
|
|
assert watcher.is_stalled() is False
|
|
|
|
thread = MagicMock()
|
|
thread.is_alive.return_value = True
|
|
watcher._thread = thread
|
|
watcher._mark_activity()
|
|
assert watcher.is_stalled() is False
|
|
|
|
watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
|
|
assert watcher.is_stalled() is True
|
|
|
|
|
|
def test_is_stalled_ignores_stopped_watcher(tmp_path):
|
|
"""
|
|
已请求停止的监控不应再被判定为静默失效。
|
|
"""
|
|
watcher = _build_watcher(tmp_path, force_polling=True)
|
|
thread = MagicMock()
|
|
thread.is_alive.return_value = True
|
|
watcher._thread = thread
|
|
watcher._mark_activity()
|
|
watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
|
|
|
|
watcher.stop()
|
|
|
|
assert watcher.is_stalled() is False
|
|
|
|
|
|
def _build_monitor(monkeypatch, put_recorder):
|
|
"""
|
|
构造测试用 Monitor 骨架,绕过单例初始化。
|
|
:param monkeypatch: pytest monkeypatch
|
|
:param put_recorder: 消息推送记录器
|
|
:return: Monitor 骨架
|
|
"""
|
|
from threading import Lock
|
|
monkeypatch.setattr("app.monitor.monitor.MessageHelper", MagicMock(return_value=put_recorder))
|
|
monitor = object.__new__(Monitor)
|
|
monitor._watchers = []
|
|
monitor._watcher_lock = Lock()
|
|
monitor._pending_locals = []
|
|
monitor._alerted_paths = set()
|
|
monitor._restart_marks = {}
|
|
monitor._stable_cycles = {}
|
|
return monitor
|
|
|
|
|
|
def _fake_watcher(mon_path, alive=True, stalled=False, restart_count=0):
|
|
"""
|
|
构造测试用监控线程替身。
|
|
:param mon_path: 监控目录
|
|
:param alive: 线程是否存活
|
|
:param stalled: 是否静默失效
|
|
:param restart_count: 自动重启次数
|
|
:return: 监控线程替身
|
|
"""
|
|
watcher = MagicMock()
|
|
watcher.watch_path = mon_path
|
|
watcher.is_alive.return_value = alive
|
|
watcher.is_stalled.return_value = stalled
|
|
watcher.restart_count = restart_count
|
|
return watcher
|
|
|
|
|
|
def test_watchdog_rebuilds_dead_watcher(tmp_path, monkeypatch):
|
|
"""
|
|
监控线程退出后健康检查应重建线程并告警。
|
|
"""
|
|
put_recorder = MagicMock()
|
|
monitor = _build_monitor(monkeypatch, put_recorder)
|
|
watcher = _fake_watcher(tmp_path, alive=False)
|
|
monitor._watchers = [watcher]
|
|
rebuild = MagicMock()
|
|
setattr(monitor, "_Monitor__rebuild_watcher", rebuild)
|
|
|
|
monitor._Monitor__check_watchers()
|
|
|
|
rebuild.assert_called_once_with(watcher)
|
|
put_recorder.put.assert_called_once()
|
|
|
|
|
|
def test_watchdog_rebuilds_stalled_watcher(tmp_path, monkeypatch):
|
|
"""
|
|
静默失效的监控线程也应被健康检查重建。
|
|
"""
|
|
put_recorder = MagicMock()
|
|
monitor = _build_monitor(monkeypatch, put_recorder)
|
|
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
|
|
monitor._watchers = [watcher]
|
|
rebuild = MagicMock()
|
|
setattr(monitor, "_Monitor__rebuild_watcher", rebuild)
|
|
|
|
monitor._Monitor__check_watchers()
|
|
|
|
rebuild.assert_called_once_with(watcher)
|
|
|
|
|
|
def test_watchdog_alerts_on_restart_and_recovers_after_stable_window(tmp_path, monkeypatch):
|
|
"""
|
|
自动重启应触发一次告警,恢复消息需等满稳定窗口,避免来回刷屏。
|
|
"""
|
|
put_recorder = MagicMock()
|
|
monitor = _build_monitor(monkeypatch, put_recorder)
|
|
watcher = _fake_watcher(tmp_path, alive=True, stalled=False, restart_count=1)
|
|
monitor._watchers = [watcher]
|
|
|
|
monitor._Monitor__check_watchers()
|
|
assert str(tmp_path) in monitor._alerted_paths
|
|
assert put_recorder.put.call_count == 1
|
|
|
|
for _ in range(Monitor.RECOVERY_STABLE_CYCLES - 1):
|
|
monitor._Monitor__check_watchers()
|
|
assert str(tmp_path) in monitor._alerted_paths
|
|
|
|
monitor._Monitor__check_watchers()
|
|
assert str(tmp_path) not in monitor._alerted_paths
|
|
assert put_recorder.put.call_count == 2
|
|
|
|
|
|
def test_retry_pending_locals_backs_off(tmp_path, monkeypatch):
|
|
"""
|
|
启动失败的监控重试应按失败次数退避,避免持续故障时刷屏。
|
|
"""
|
|
put_recorder = MagicMock()
|
|
monitor = _build_monitor(monkeypatch, put_recorder)
|
|
monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
|
|
start = MagicMock(return_value=False)
|
|
setattr(monitor, "_Monitor__start_local_monitor", start)
|
|
|
|
for _ in range(6):
|
|
monitor._Monitor__retry_pending_locals()
|
|
|
|
assert start.call_count == 3
|
|
|
|
|
|
def test_dispatcher_retries_after_history_query_failure(monkeypatch):
|
|
"""
|
|
整理历史查询失败应登记待重试,重试成功后进入整理链并清除登记。
|
|
"""
|
|
from app.monitor.dispatcher import TransferDispatcher
|
|
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
|
|
event_path = Path("/downloads/movie.mkv")
|
|
history = MagicMock(side_effect=[None, False])
|
|
monkeypatch.setattr(dispatcher, "_has_transfer_history", history)
|
|
transfer_chain_instance = MagicMock()
|
|
monkeypatch.setattr("app.monitor.dispatcher.TransferChain",
|
|
MagicMock(return_value=transfer_chain_instance))
|
|
|
|
# 首次查询失败:不整理,登记待重试
|
|
assert dispatcher.handle_file(storage="local", event_path=event_path, file_size=1) is False
|
|
assert len(dispatcher._pending_retries) == 1
|
|
transfer_chain_instance.do_transfer.assert_not_called()
|
|
|
|
# 模拟 TTL 缓存过期后由健康检查驱动重试
|
|
dispatcher._cache.clear()
|
|
dispatcher.retry_pending()
|
|
|
|
transfer_chain_instance.do_transfer.assert_called_once()
|
|
assert dispatcher._pending_retries == {}
|
|
|
|
|
|
def test_dispatcher_drops_pending_after_max_attempts(monkeypatch):
|
|
"""
|
|
历史查询持续失败达到上限后应放弃重试,避免队列无限累积。
|
|
"""
|
|
from app.monitor.dispatcher import TransferDispatcher
|
|
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
|
|
event_path = Path("/downloads/movie.mkv")
|
|
monkeypatch.setattr(dispatcher, "_has_transfer_history", MagicMock(return_value=None))
|
|
|
|
dispatcher.handle_file(storage="local", event_path=event_path, file_size=1)
|
|
key = f"local:{event_path.as_posix()}"
|
|
assert key in dispatcher._pending_retries
|
|
dispatcher._pending_retries[key]["attempts"] = TransferDispatcher.MAX_RETRY_ATTEMPTS - 1
|
|
|
|
dispatcher._cache.clear()
|
|
dispatcher.retry_pending()
|
|
|
|
assert dispatcher._pending_retries == {}
|