Files
MoviePilot/tests/test_monitor_rescan.py
Aqr-K a2e70b443d fix(monitor,transfer): 修复 FUSE 挂载无响应导致的监控冻死、整理链锁死与漏件 (#6276)
* wip(v3): 移植监控与整理韧性修复到 v3 基线

包含:监控看门狗隔离/挂载探测、整理队列持久化、文件系统子进程代理、
写入原子化。迁移重挂到 v3 链 8a4c7e1d2f90 -> 7f5c1d2e3a4b -> e3d9f4b7c806。
tmdb 相关测试尚未通过,待定位。

* fix(v3): 修正移植引入的 16 项测试失败

- poller.py:合并时我方保留的行仍用旧变量名 merged_snapshot,而 v3 已统一
  改名为 current_snapshot,导致 NameError 被外层 except 吞掉、快照从未保存
- smb.py:采纳 f-string 拆分写法,恢复 Python 3.11 可解析
- dispatcher 测试:历史查重由 _should_skip_by_history 统一承担,mock 点随之调整
- tmdb 缓存测试:补充 v3 新增的 media_source/media_id 字段
- tmdb 重试测试:为 fake 补充 match_multi/async_match_multi

尚余 3 项与 v3 识别流程的连接失败处理有关,待单独判断。

* fix(v3): 测试适配 v3 的 media_source/media_id 重构

v3 将媒体标识从 tmdbid 统一重构为 media_source + media_id,recognize_media
的 tmdbid 参数已被 **kwargs 静默吞掉——传了也不生效,流程会误降级到名称搜索。
tmdb 重试用例改用新参数后恢复正确路径。

同时修正 fake 的 match_multi 语义:真实实现(tmdbapi.match_multi)吞掉所有
异常并返回 None,连接失败与「未找到」在该路径上本就不可区分,fake 需保持一致。

至此移植引入的 19 项失败全部清零。

---------

Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
2026-08-13 08:19:54 +08:00

322 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
目录监控延迟重扫机制的边界缺陷回归测试。
覆盖点:
- 同目录重复登记去重4.1
- 整体扫描失败不燃烧轮次 + 失败次数上限4.2
- 目录删除后条目出队不计入失败重试4.2
- MONITOR_RESCAN_DELAYS 配置解析(合法/非法回退4.3
- 目录树整体移入时只登记顶层目录、祖先已登记时跳过子孙目录4.4
- 待重扫队列溢出时日志升级为 warn4.4
"""
import shutil
from pathlib import Path
from unittest.mock import MagicMock
from watchfiles import Change
from app.core.config import settings
from app.monitor.watcher import LocalDirectoryWatcher
def _build_watcher(tmp_path, force_polling=True):
"""
构造测试用目录监控。
:param tmp_path: 监控目录
:param force_polling: 是否强制轮询
:return: 目录监控
"""
return LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=force_polling)
# ==================== 4.1 同目录重复登记去重 ====================
def test_schedule_rescan_dedups_same_directory(tmp_path):
"""
readdir 闪断可能让同一目录产生两次 added 事件,重复调用 _schedule_rescan
只应保留一条待重扫记录,不应重复登记。
"""
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
watcher._schedule_rescan(directory, seen={"already-seen"})
assert len(watcher._pending_rescans) == 1
# 保留原条目即可,不需要用新事件的 seen 覆盖
assert watcher._pending_rescans[0]["seen"] == set()
def test_expand_added_directories_dedups_repeated_added_event_across_batches(tmp_path):
"""
同一目录在不同批次的 changes 中各出现一次 added 事件时(模拟 readdir 闪断),
整体展开流程也不应重复登记重扫。
"""
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._expand_added_directories({(Change.added, directory.as_posix())})
watcher._expand_added_directories({(Change.added, directory.as_posix())})
assert len(watcher._pending_rescans) == 1
# ==================== 4.2 扫描失败不燃烧轮次 + 失败上限 + 目录删除终态 ====================
def test_collect_directory_files_reports_missing_directory(tmp_path):
"""
目录已不存在时应识别为终态missing=True而不是当作扫描失败。
"""
missing = tmp_path / "gone"
watcher = _build_watcher(tmp_path)
collected, is_missing, scan_failed = watcher._collect_directory_files(missing, exclude=set())
assert collected == set()
assert is_missing is True
assert scan_failed is False
def test_collect_directory_files_reports_scan_failure(tmp_path, monkeypatch):
"""
顶层 rglob 抛 OSError 应识别为整体扫描失败scan_failed=True
与目录已删除的终态区分开。
"""
directory = tmp_path / "season"
directory.mkdir()
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher = _build_watcher(tmp_path)
collected, is_missing, scan_failed = watcher._collect_directory_files(directory, exclude=set())
assert collected == set()
assert is_missing is False
assert scan_failed is True
def test_process_pending_rescans_does_not_burn_round_on_scan_failure(tmp_path, monkeypatch):
"""
整体扫描失败时不应消耗重扫轮次,条目应保持在原轮次继续重试,
并累计一次失败计数。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0, 100))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
item = watcher._pending_rescans[0]
assert item["round"] == 0
assert item["failures"] == 1
def test_process_pending_rescans_drops_item_after_max_failures(tmp_path, monkeypatch):
"""
连续扫描失败达到 MAX_RESCAN_FAILURES 上限后应放弃重扫并 warn
避免目录长期不可访问时无限重试。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0,))
monkeypatch.setattr(LocalDirectoryWatcher, "MAX_RESCAN_FAILURES", 2)
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
watcher._process_pending_rescans()
assert watcher._pending_rescans == []
logger_warn.assert_called_once()
def test_process_pending_rescans_resets_failure_count_after_success(tmp_path, monkeypatch):
"""
扫描恢复成功后应重置失败计数,不应带着历史失败次数继续累积。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0, 100))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
watcher._pending_rescans[0]["failures"] = 3
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["failures"] == 0
def test_process_pending_rescans_drops_deleted_directory_without_failure(tmp_path, monkeypatch):
"""
目录已被删除是终态,应直接出队,不计入失败重试次数。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0,))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
shutil.rmtree(directory)
watcher._process_pending_rescans()
assert watcher._pending_rescans == []
# ==================== 4.3 重扫窗口可配置 ====================
def test_parse_rescan_delays_accepts_valid_string():
"""
合法的逗号分隔正整数字符串应正确解析为元组。
"""
assert LocalDirectoryWatcher._parse_rescan_delays("30,120,600,1800") == (30, 120, 600, 1800)
assert LocalDirectoryWatcher._parse_rescan_delays(" 5 , 10 ") == (5, 10)
def test_parse_rescan_delays_falls_back_on_empty():
"""
空字符串/None 应回退到默认值。
"""
assert LocalDirectoryWatcher._parse_rescan_delays("") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
assert LocalDirectoryWatcher._parse_rescan_delays(None) == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
def test_parse_rescan_delays_falls_back_on_invalid_format(monkeypatch):
"""
非法格式(无法转换为整数)应回退默认值并记录 warn 日志。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
assert LocalDirectoryWatcher._parse_rescan_delays("abc,def") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
logger_warn.assert_called_once()
def test_parse_rescan_delays_rejects_non_positive_values(monkeypatch):
"""
包含非正整数0 或负数)视为非法配置,应回退默认值并记录 warn 日志。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
assert LocalDirectoryWatcher._parse_rescan_delays("30,-5") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
logger_warn.assert_called_once()
def test_directory_rescan_delays_property_reads_settings(tmp_path, monkeypatch):
"""
DIRECTORY_RESCAN_DELAYS 应实时反映 MONITOR_RESCAN_DELAYS 配置。
"""
monkeypatch.setattr(settings, "MONITOR_RESCAN_DELAYS", "5,10")
watcher = _build_watcher(tmp_path)
assert watcher.DIRECTORY_RESCAN_DELAYS == (5, 10)
def test_directory_rescan_delays_property_falls_back_on_invalid_settings(tmp_path, monkeypatch):
"""
配置非法时属性访问应回退默认值,而不是抛异常影响监控主流程。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
monkeypatch.setattr(settings, "MONITOR_RESCAN_DELAYS", "not-a-number")
watcher = _build_watcher(tmp_path)
assert watcher.DIRECTORY_RESCAN_DELAYS == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
# ==================== 4.4 目录树移入只登记顶层 + 溢出 warn ====================
def test_expand_added_directories_only_schedules_top_level(tmp_path):
"""
大目录树整体移入时changes 里每一层子目录都会各自产生一次 added 事件,
但只有顶层目录需要登记重扫:子目录内容已被顶层目录的 rglob 覆盖。
"""
top = tmp_path / "task"
nested = top / "season1"
nested2 = nested / "sub"
nested2.mkdir(parents=True)
(top / "movie.mkv").write_bytes(b"x")
watcher = _build_watcher(tmp_path)
watcher._expand_added_directories({
(Change.added, top.as_posix()),
(Change.added, nested.as_posix()),
(Change.added, nested2.as_posix()),
})
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == top
def test_schedule_rescan_skips_descendant_of_pending_ancestor(tmp_path):
"""
某祖先目录已经在待重扫队列中时,其子孙目录不应再单独登记——
祖先条目的 rglob 会递归覆盖到子孙目录的新文件。
"""
parent = tmp_path / "task"
child = parent / "season"
child.mkdir(parents=True)
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(parent, seen=set())
watcher._schedule_rescan(child, seen=set())
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == parent
def test_is_descendant_of_any_does_not_match_sibling_with_prefix_name(tmp_path):
"""
路径前缀相同但并非真实父子关系的兄弟目录(如 task / task2不应被
误判为祖先命中,避免字符串前缀匹配导致的误跳过。
"""
parent = tmp_path / "task"
sibling = tmp_path / "task2"
assert LocalDirectoryWatcher._is_descendant_of_any(sibling, {parent}) is False
def test_schedule_rescan_overflow_logs_warning(tmp_path, monkeypatch):
"""
待重扫队列已满时应放弃登记,且日志级别应为 warn原实现仅 debug
可能导致文件永久漏扫却难以被发现)。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "MAX_PENDING_RESCANS", 1)
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
watcher = _build_watcher(tmp_path)
first_dir = tmp_path / "a"
second_dir = tmp_path / "b"
first_dir.mkdir()
second_dir.mkdir()
watcher._schedule_rescan(first_dir, seen=set())
watcher._schedule_rescan(second_dir, seen=set())
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == first_dir
logger_warn.assert_called_once()