fix(monitor): 目录监控自愈、快照语义修正与覆盖保护闭环 (#6210)

This commit is contained in:
Aqr-K
2026-07-30 06:53:52 +08:00
committed by GitHub
parent e011b20210
commit cf80b551f9
20 changed files with 2353 additions and 1021 deletions

View 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 == {}

View 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

View File

@@ -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,

View File

@@ -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")

View 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()