mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-10 07:54:14 +08:00
fix(monitor): 目录监控自愈、快照语义修正与覆盖保护闭环 (#6210)
This commit is contained in:
273
tests/test_monitor_resilience.py
Normal file
273
tests/test_monitor_resilience.py
Normal file
@@ -0,0 +1,273 @@
|
||||
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 == {}
|
||||
177
tests/test_monitor_snapshot_semantics.py
Normal file
177
tests/test_monitor_snapshot_semantics.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.monitor.poller import RemotePoller
|
||||
from app.monitor.watcher import LocalDirectoryWatcher
|
||||
|
||||
|
||||
def _build_poller(alert_cb=None):
|
||||
"""
|
||||
构造测试用远程轮询监控。
|
||||
:param alert_cb: 告警回调替身
|
||||
:return: (poller, store, dispatcher)
|
||||
"""
|
||||
store = MagicMock()
|
||||
store.save.return_value = True
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.is_transfer_candidate_path.return_value = True
|
||||
dispatcher.handle_file.return_value = True
|
||||
poller = RemotePoller(store=store, dispatcher=dispatcher, alert_cb=alert_cb)
|
||||
return poller, store, dispatcher
|
||||
|
||||
|
||||
def _mock_storage_chain(monkeypatch, side_effect):
|
||||
"""
|
||||
替换 StorageChain 的快照返回。
|
||||
:param monkeypatch: pytest monkeypatch
|
||||
:param side_effect: snapshot_storage 的返回序列
|
||||
:return: StorageChain 实例替身
|
||||
"""
|
||||
chain_instance = MagicMock()
|
||||
chain_instance.snapshot_storage.side_effect = side_effect
|
||||
monkeypatch.setattr("app.monitor.poller.StorageChain", MagicMock(return_value=chain_instance))
|
||||
return chain_instance
|
||||
|
||||
|
||||
BASELINE = {
|
||||
'timestamp': 100,
|
||||
'file_count': 1,
|
||||
'snapshot': {'/mon/a.mkv': {'size': 1, 'modify_time': 100}}
|
||||
}
|
||||
|
||||
|
||||
def test_poll_merges_incremental_into_baseline(monkeypatch):
|
||||
"""
|
||||
增量快照应与基线合并落盘,未扫到的旧文件不能从基线消失。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (dict(BASELINE), True)
|
||||
_mock_storage_chain(monkeypatch, [{'/mon/b.mkv': {'size': 2, 'modify_time': 200}}])
|
||||
|
||||
file_count = poller.poll("u115", [Path("/mon")])
|
||||
|
||||
assert file_count == 2
|
||||
saved_snapshot = store.save.call_args.args[1]
|
||||
assert set(saved_snapshot.keys()) == {'/mon/a.mkv', '/mon/b.mkv'}
|
||||
dispatcher.handle_file.assert_called_once()
|
||||
assert dispatcher.handle_file.call_args.kwargs["event_path"] == Path('/mon/b.mkv')
|
||||
|
||||
|
||||
def test_poll_detects_modified_files(monkeypatch):
|
||||
"""
|
||||
增量中已有文件的大小变化应作为修改事件分发,并更新基线。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (dict(BASELINE), True)
|
||||
_mock_storage_chain(monkeypatch, [{'/mon/a.mkv': {'size': 5, 'modify_time': 300}}])
|
||||
|
||||
file_count = poller.poll("u115", [Path("/mon")])
|
||||
|
||||
assert file_count == 1
|
||||
saved_snapshot = store.save.call_args.args[1]
|
||||
assert saved_snapshot['/mon/a.mkv']['size'] == 5
|
||||
dispatcher.handle_file.assert_called_once()
|
||||
|
||||
|
||||
def test_poll_partial_failure_merges_success_and_keeps_baseline(monkeypatch):
|
||||
"""
|
||||
部分路径快照失败时,成功路径合并落盘,失败路径保留旧基线。
|
||||
"""
|
||||
alert_cb = MagicMock()
|
||||
poller, store, dispatcher = _build_poller(alert_cb)
|
||||
store.load_checked.return_value = (dict(BASELINE), True)
|
||||
_mock_storage_chain(monkeypatch, [None, {'/mon2/b.mkv': {'size': 2, 'modify_time': 200}}])
|
||||
|
||||
file_count = poller.poll("u115", [Path("/mon"), Path("/mon2")])
|
||||
|
||||
assert file_count == 2
|
||||
saved_snapshot = store.save.call_args.args[1]
|
||||
assert set(saved_snapshot.keys()) == {'/mon/a.mkv', '/mon2/b.mkv'}
|
||||
# 单次失败未达告警阈值
|
||||
alert_cb.assert_not_called()
|
||||
|
||||
|
||||
def test_poll_all_paths_failed_skips_save(monkeypatch):
|
||||
"""
|
||||
全部路径快照失败时本轮不落盘,基线保持不变。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (dict(BASELINE), True)
|
||||
_mock_storage_chain(monkeypatch, [None])
|
||||
|
||||
assert poller.poll("u115", [Path("/mon")]) is None
|
||||
store.save.assert_not_called()
|
||||
dispatcher.handle_file.assert_not_called()
|
||||
|
||||
|
||||
def test_poll_first_snapshot_failure_builds_no_empty_baseline(monkeypatch):
|
||||
"""
|
||||
首次快照失败时不得落盘空基线,否则下一轮会把全部存量当作新增。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (None, True)
|
||||
_mock_storage_chain(monkeypatch, [None])
|
||||
|
||||
assert poller.poll("u115", [Path("/mon")]) is None
|
||||
store.save.assert_not_called()
|
||||
|
||||
|
||||
def test_poll_first_snapshot_success_saves_baseline_without_dispatch(monkeypatch):
|
||||
"""
|
||||
首次快照成功仅建立基准,不应处理存量文件。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (None, True)
|
||||
_mock_storage_chain(monkeypatch, [{'/mon/a.mkv': {'size': 1, 'modify_time': 100}}])
|
||||
|
||||
assert poller.poll("u115", [Path("/mon")]) == 1
|
||||
store.save.assert_called_once()
|
||||
dispatcher.handle_file.assert_not_called()
|
||||
|
||||
|
||||
def test_poll_load_error_skips_round(monkeypatch):
|
||||
"""
|
||||
基线读取失败不能当作首次快照,应跳过本轮避免丢弃已有基线。
|
||||
"""
|
||||
poller, store, dispatcher = _build_poller()
|
||||
store.load_checked.return_value = (None, False)
|
||||
chain = _mock_storage_chain(monkeypatch, [{'/mon/a.mkv': {'size': 1, 'modify_time': 100}}])
|
||||
|
||||
assert poller.poll("u115", [Path("/mon")]) is None
|
||||
chain.snapshot_storage.assert_not_called()
|
||||
store.save.assert_not_called()
|
||||
|
||||
|
||||
def test_poll_failure_alert_threshold_and_recovery(monkeypatch):
|
||||
"""
|
||||
连续异常达到阈值只告警一次,恢复后推送恢复消息。
|
||||
"""
|
||||
alert_cb = MagicMock()
|
||||
poller, store, dispatcher = _build_poller(alert_cb)
|
||||
store.load_checked.return_value = (dict(BASELINE), True)
|
||||
_mock_storage_chain(
|
||||
monkeypatch,
|
||||
[None] * RemotePoller.FAILURE_ALERT_THRESHOLD + [{'/mon/b.mkv': {'size': 2, 'modify_time': 200}}]
|
||||
)
|
||||
|
||||
for _ in range(RemotePoller.FAILURE_ALERT_THRESHOLD):
|
||||
poller.poll("u115", [Path("/mon")])
|
||||
assert alert_cb.call_count == 1
|
||||
|
||||
poller.poll("u115", [Path("/mon")])
|
||||
assert alert_cb.call_count == 2
|
||||
assert "已恢复" in alert_cb.call_args.args[1]
|
||||
|
||||
|
||||
def test_watcher_poll_delay_defaults_and_override(tmp_path):
|
||||
"""
|
||||
轮询扫描间隔默认取本地值,显式传入网络值时生效。
|
||||
"""
|
||||
default_watcher = LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=True)
|
||||
assert default_watcher.poll_delay_ms == LocalDirectoryWatcher.POLL_DELAY_LOCAL_MS
|
||||
|
||||
network_watcher = LocalDirectoryWatcher(
|
||||
tmp_path, callback=MagicMock(), force_polling=True,
|
||||
poll_delay_ms=LocalDirectoryWatcher.POLL_DELAY_NETWORK_MS
|
||||
)
|
||||
assert network_watcher.poll_delay_ms == LocalDirectoryWatcher.POLL_DELAY_NETWORK_MS
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
|
||||
from watchfiles import Change
|
||||
|
||||
from app.monitor import DirectoryChangeEvent, LocalDirectoryWatcher, Monitor
|
||||
from app.monitor.dispatcher import TransferDispatcher
|
||||
|
||||
|
||||
class CallbackRecorder:
|
||||
@@ -28,6 +29,20 @@ class CallbackRecorder:
|
||||
self.events.append((event, text, event_path, file_size))
|
||||
|
||||
|
||||
def _build_monitor_with_dispatcher(handle_file: MagicMock = None):
|
||||
"""
|
||||
构造带分发器的测试用 Monitor 骨架。
|
||||
:param handle_file: 替换分发器 handle_file 的替身
|
||||
:return: (Monitor 骨架, 分发器)
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
|
||||
if handle_file is not None:
|
||||
dispatcher.handle_file = handle_file
|
||||
monitor._dispatcher = dispatcher
|
||||
return monitor, dispatcher
|
||||
|
||||
|
||||
def test_handle_changes_dispatches_added_and_modified_files(tmp_path):
|
||||
"""
|
||||
新增和修改文件应转换成目录监控整理回调。
|
||||
@@ -120,10 +135,8 @@ def test_event_handler_routes_file_events_to_transfer_handler():
|
||||
"""
|
||||
文件事件应继续按 local 存储交给整理流程。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
handle_file = MagicMock()
|
||||
setattr(monitor, "_Monitor__handle_file", handle_file)
|
||||
monitor, _ = _build_monitor_with_dispatcher(handle_file)
|
||||
event_path = Path("/downloads/movie.mkv")
|
||||
event = DirectoryChangeEvent(
|
||||
change_type=Change.added,
|
||||
@@ -149,10 +162,8 @@ def test_event_handler_ignores_directory_events():
|
||||
"""
|
||||
目录事件不应进入文件整理流程。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
handle_file = MagicMock()
|
||||
setattr(monitor, "_Monitor__handle_file", handle_file)
|
||||
monitor, _ = _build_monitor_with_dispatcher(handle_file)
|
||||
event_path = Path("/downloads/folder")
|
||||
event = DirectoryChangeEvent(
|
||||
change_type=Change.added,
|
||||
@@ -173,10 +184,8 @@ def test_event_handler_ignores_download_temp_files():
|
||||
"""
|
||||
下载器临时文件不应进入整理流程。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
handle_file = MagicMock()
|
||||
setattr(monitor, "_Monitor__handle_file", handle_file)
|
||||
monitor, _ = _build_monitor_with_dispatcher(handle_file)
|
||||
event_path = Path("/downloads/movie.mkv.!qB")
|
||||
event = DirectoryChangeEvent(
|
||||
change_type=Change.modified,
|
||||
@@ -198,10 +207,8 @@ def test_event_handler_ignores_non_transferable_files():
|
||||
"""
|
||||
非可整理后缀文件不应进入整理流程。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
handle_file = MagicMock()
|
||||
setattr(monitor, "_Monitor__handle_file", handle_file)
|
||||
monitor, _ = _build_monitor_with_dispatcher(handle_file)
|
||||
event_path = Path("/downloads/movie.nfo")
|
||||
event = DirectoryChangeEvent(
|
||||
change_type=Change.added,
|
||||
@@ -223,9 +230,7 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch):
|
||||
"""
|
||||
已有整理记录的源文件不应再次进入整理链。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
monitor._cache = {}
|
||||
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
|
||||
event_path = Path("/downloads/movie.mkv")
|
||||
lookups = []
|
||||
|
||||
@@ -244,12 +249,12 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch):
|
||||
transfer_chain = MagicMock()
|
||||
logger_info = MagicMock()
|
||||
logger_debug = MagicMock()
|
||||
monkeypatch.setattr("app.monitor.TransferHistoryOper", FakeTransferHistoryOper)
|
||||
monkeypatch.setattr("app.monitor.TransferChain", transfer_chain)
|
||||
monkeypatch.setattr("app.monitor.logger.info", logger_info)
|
||||
monkeypatch.setattr("app.monitor.logger.debug", logger_debug)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.TransferHistoryOper", FakeTransferHistoryOper)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.logger.info", logger_info)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.logger.debug", logger_debug)
|
||||
|
||||
handled = monitor._Monitor__handle_file(
|
||||
handled = dispatcher.handle_file(
|
||||
storage="local",
|
||||
event_path=event_path,
|
||||
file_size=1024,
|
||||
@@ -266,9 +271,7 @@ def test_handle_file_invokes_transfer_when_history_missing(monkeypatch):
|
||||
"""
|
||||
没有整理记录的源文件应继续进入整理链。
|
||||
"""
|
||||
monitor = object.__new__(Monitor)
|
||||
monitor.all_exts = [".mkv"]
|
||||
monitor._cache = {}
|
||||
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
|
||||
event_path = Path("/downloads/movie.mkv")
|
||||
|
||||
class FakeTransferHistoryOper:
|
||||
@@ -284,10 +287,10 @@ def test_handle_file_invokes_transfer_when_history_missing(monkeypatch):
|
||||
|
||||
transfer_chain_instance = MagicMock()
|
||||
transfer_chain = MagicMock(return_value=transfer_chain_instance)
|
||||
monkeypatch.setattr("app.monitor.TransferHistoryOper", FakeTransferHistoryOper)
|
||||
monkeypatch.setattr("app.monitor.TransferChain", transfer_chain)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.TransferHistoryOper", FakeTransferHistoryOper)
|
||||
monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain)
|
||||
|
||||
handled = monitor._Monitor__handle_file(
|
||||
handled = dispatcher.handle_file(
|
||||
storage="local",
|
||||
event_path=event_path,
|
||||
file_size=1024,
|
||||
|
||||
@@ -183,6 +183,7 @@ class TransferJobManagerTest(unittest.TestCase):
|
||||
target_oper = SimpleNamespace(
|
||||
get_folder=lambda path: target_folder,
|
||||
get_item=lambda path: None,
|
||||
get_item_strict=lambda path: None,
|
||||
)
|
||||
|
||||
new_item, errmsg = TransHandler._TransHandler__transfer_command(
|
||||
@@ -243,6 +244,7 @@ class TransferJobManagerTest(unittest.TestCase):
|
||||
target_oper = SimpleNamespace(
|
||||
get_folder=lambda path: target_folder,
|
||||
get_item=lambda path: None,
|
||||
get_item_strict=lambda path: None,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
@@ -313,6 +315,7 @@ class TransferJobManagerTest(unittest.TestCase):
|
||||
target_oper = SimpleNamespace(
|
||||
get_folder=lambda path: target_folder,
|
||||
get_item=lambda path: None,
|
||||
get_item_strict=lambda path: None,
|
||||
)
|
||||
in_meta = MetaVideo("Test.Show.S02E03")
|
||||
|
||||
|
||||
186
tests/test_transfer_overwrite_guard.py
Normal file
186
tests/test_transfer_overwrite_guard.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.filemanager.storages.alipan import AliPan
|
||||
from app.modules.filemanager.storages.local import LocalStorage
|
||||
from app.modules.filemanager.storages.rclone import Rclone
|
||||
from app.modules.filemanager.storages.u115 import U115Pan
|
||||
from app.schemas.exception import StorageQueryError
|
||||
|
||||
|
||||
def _local() -> LocalStorage:
|
||||
"""
|
||||
构造本地存储实例(跳过初始化)。
|
||||
"""
|
||||
return object.__new__(LocalStorage)
|
||||
|
||||
|
||||
def _u115() -> U115Pan:
|
||||
"""
|
||||
构造 115 存储实例(跳过初始化)。
|
||||
"""
|
||||
return object.__new__(U115Pan)
|
||||
|
||||
|
||||
def _alipan(monkeypatch) -> AliPan:
|
||||
"""
|
||||
构造阿里云盘存储实例(跳过初始化,_default_drive_id 为只读属性需在类级替换)。
|
||||
"""
|
||||
monkeypatch.setattr(AliPan, "_default_drive_id", "drive-1", raising=False)
|
||||
return object.__new__(AliPan)
|
||||
|
||||
|
||||
def test_local_strict_missing_file_returns_none(tmp_path):
|
||||
"""
|
||||
目标文件确实不存在时应确认为不存在,允许正常整理。
|
||||
"""
|
||||
assert _local().get_item_strict(tmp_path / "missing.mkv") is None
|
||||
|
||||
|
||||
def test_local_strict_existing_file_returns_item(tmp_path):
|
||||
"""
|
||||
目标文件存在时应返回文件项。
|
||||
"""
|
||||
target = tmp_path / "movie.mkv"
|
||||
target.write_bytes(b"movie")
|
||||
|
||||
item = _local().get_item_strict(target)
|
||||
|
||||
assert item is not None
|
||||
assert item.path == target.as_posix()
|
||||
|
||||
|
||||
def test_local_strict_broken_symlink_returns_none(tmp_path):
|
||||
"""
|
||||
失效软链接视为目标不存在,不应阻断整理。
|
||||
"""
|
||||
target = tmp_path / "movie.mkv"
|
||||
target.symlink_to(tmp_path / "gone.mkv")
|
||||
|
||||
assert _local().get_item_strict(target) is None
|
||||
|
||||
|
||||
def test_local_strict_raises_on_stat_error(tmp_path, monkeypatch):
|
||||
"""
|
||||
FUSE 挂载抖动导致 stat 失败时应抛出 StorageQueryError,拒绝覆盖。
|
||||
"""
|
||||
target = tmp_path / "movie.mkv"
|
||||
|
||||
def raise_stat_error(self, *args, **kwargs):
|
||||
"""
|
||||
模拟 CloudDrive FUSE 挂载返回 ENOTRECOVERABLE。
|
||||
"""
|
||||
raise OSError(131, "State not recoverable")
|
||||
|
||||
monkeypatch.setattr(Path, "stat", raise_stat_error)
|
||||
|
||||
with pytest.raises(StorageQueryError):
|
||||
_local().get_item_strict(target)
|
||||
|
||||
|
||||
def test_u115_strict_transport_failure_raises():
|
||||
"""
|
||||
115 请求失败(网络/限流重试用尽)时应抛出 StorageQueryError。
|
||||
"""
|
||||
storage = _u115()
|
||||
storage._request_api = MagicMock(return_value=None)
|
||||
|
||||
with pytest.raises(StorageQueryError):
|
||||
storage.get_item_strict(Path("/movie.mkv"))
|
||||
|
||||
|
||||
def test_u115_get_item_keeps_swallowing_transport_failure():
|
||||
"""
|
||||
宽松版 get_item 行为保持兼容:请求失败仍返回 None。
|
||||
"""
|
||||
storage = _u115()
|
||||
storage._request_api = MagicMock(return_value=None)
|
||||
|
||||
assert storage.get_item(Path("/movie.mkv")) is None
|
||||
|
||||
|
||||
def test_u115_strict_confirmed_absent_returns_none():
|
||||
"""
|
||||
115 业务码返回记录不存在(data 为空)时应确认为不存在。
|
||||
"""
|
||||
storage = _u115()
|
||||
storage._request_api = MagicMock(return_value={"state": True, "code": 20004, "data": {}})
|
||||
|
||||
assert storage.get_item_strict(Path("/movie.mkv")) is None
|
||||
|
||||
|
||||
def test_u115_strict_returns_item():
|
||||
"""
|
||||
115 返回有效文件数据时应构造文件项。
|
||||
"""
|
||||
storage = _u115()
|
||||
storage._request_api = MagicMock(return_value={"state": True, "code": 0, "data": {
|
||||
"file_id": 123,
|
||||
"file_category": "1",
|
||||
"file_name": "movie.mkv",
|
||||
"pick_code": "abc",
|
||||
"size_byte": 1024,
|
||||
"utime": 100,
|
||||
}})
|
||||
|
||||
item = storage.get_item_strict(Path("/movie.mkv"))
|
||||
|
||||
assert item is not None
|
||||
assert item.fileid == "123"
|
||||
assert item.size == 1024
|
||||
|
||||
|
||||
def test_alipan_strict_notfound_returns_none(monkeypatch):
|
||||
"""
|
||||
阿里云盘 NotFound 系列错误码应确认为不存在。
|
||||
"""
|
||||
storage = _alipan(monkeypatch)
|
||||
storage._request_api = MagicMock(return_value={"code": "NotFound.File", "message": "not found"})
|
||||
|
||||
assert storage.get_item_strict(Path("/movie.mkv")) is None
|
||||
|
||||
|
||||
def test_alipan_strict_other_error_raises(monkeypatch):
|
||||
"""
|
||||
阿里云盘非 NotFound 的业务错误(如限流)应抛出 StorageQueryError。
|
||||
"""
|
||||
storage = _alipan(monkeypatch)
|
||||
storage._request_api = MagicMock(return_value={"code": "TooManyRequests", "message": "limit"})
|
||||
|
||||
with pytest.raises(StorageQueryError):
|
||||
storage.get_item_strict(Path("/movie.mkv"))
|
||||
|
||||
|
||||
def test_alipan_strict_transport_failure_raises(monkeypatch):
|
||||
"""
|
||||
阿里云盘请求失败时应抛出 StorageQueryError。
|
||||
"""
|
||||
storage = _alipan(monkeypatch)
|
||||
storage._request_api = MagicMock(return_value=None)
|
||||
|
||||
with pytest.raises(StorageQueryError):
|
||||
storage.get_item_strict(Path("/movie.mkv"))
|
||||
|
||||
|
||||
def test_alipan_strict_returns_item(monkeypatch):
|
||||
"""
|
||||
阿里云盘返回有效数据时应构造文件项。
|
||||
"""
|
||||
storage = _alipan(monkeypatch)
|
||||
storage._request_api = MagicMock(return_value={"file_id": "f1", "name": "movie.mkv"})
|
||||
setattr(storage, "_AliPan__get_fileitem", MagicMock(return_value="ITEM"))
|
||||
|
||||
assert storage.get_item_strict(Path("/movie.mkv")) == "ITEM"
|
||||
|
||||
|
||||
def test_storage_base_strict_defaults_to_get_item():
|
||||
"""
|
||||
未覆写的存储沿用 get_item 判定,行为不变。
|
||||
"""
|
||||
storage = object.__new__(Rclone)
|
||||
storage.get_item = MagicMock(return_value=None)
|
||||
|
||||
assert storage.get_item_strict(Path("/movie.mkv")) is None
|
||||
storage.get_item.assert_called_once()
|
||||
Reference in New Issue
Block a user