mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 02:54:20 +08:00
* 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>
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""
|
||
目录监控延迟重扫机制的边界缺陷回归测试。
|
||
|
||
覆盖点:
|
||
- 同目录重复登记去重(4.1)
|
||
- 整体扫描失败不燃烧轮次 + 失败次数上限(4.2)
|
||
- 目录删除后条目出队,不计入失败重试(4.2)
|
||
- MONITOR_RESCAN_DELAYS 配置解析(合法/非法回退)(4.3)
|
||
- 目录树整体移入时只登记顶层目录、祖先已登记时跳过子孙目录(4.4)
|
||
- 待重扫队列溢出时日志升级为 warn(4.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()
|