""" 整理队列持久化与重启回放测试。 整理队列是纯内存的 queue.Queue:挂载挂死后的人工重启、版本升级、OOM、宿主 重启都会让队列连同「这些文件还没整理」这个事实一起蒸发,而已稳定落地的文件 不会再产生任何监控事件,也不会有新的补偿扫描起点——结果就是永久漏件。 这些测试固定三项不变量:入队即落盘登记、终态即注销、重启能回放。 """ from pathlib import Path import threading from unittest.mock import MagicMock from app.chain.transfer import TransferChain from app.application.transfer import TransferTask from app.schemas.file import FileItem def _build_chain(pendingoper) -> TransferChain: """ 构造绕过单例初始化的 TransferChain 骨架。 :param pendingoper: 待整理登记管理替身 :return: TransferChain 骨架 """ chain = object.__new__(TransferChain) chain._pendingoper = pendingoper return chain def _task(path: str, storage: str = "local") -> TransferTask: """ 构造测试用整理任务。 :param path: 源文件路径 :param storage: 存储 :return: 整理任务 """ file_path = Path(path) return TransferTask(fileitem=FileItem( storage=storage, path=path, type="file", name=file_path.name, basename=file_path.stem, extension=file_path.suffix[1:], )) def test_register_pending_records_storage_and_path(): """ 入队时必须落盘登记「存储 + 源路径」这一最小事实。 """ pendingoper = MagicMock() chain = _build_chain(pendingoper) chain._TransferChain__register_pending(_task("/mnt/cd2/downloads/Movie.2024.mkv")) pendingoper.register.assert_called_once_with( storage="local", src_path="/mnt/cd2/downloads/Movie.2024.mkv" ) def test_register_pending_failure_does_not_break_enqueue(): """ 落盘登记只是重启后的补救手段,登记失败绝不能阻断正常整理。 """ pendingoper = MagicMock() pendingoper.register.side_effect = RuntimeError("db locked") chain = _build_chain(pendingoper) # 不抛异常即为通过 chain._TransferChain__register_pending(_task("/mnt/cd2/downloads/Movie.2024.mkv")) def test_discard_pending_on_terminal_state(): """ 整理到达终态后必须注销登记,否则每次重启都会重复回放。 """ pendingoper = MagicMock() chain = _build_chain(pendingoper) chain._TransferChain__discard_pending(_task("/mnt/cd2/downloads/Movie.2024.mkv")) pendingoper.discard.assert_called_once_with( storage="local", src_path="/mnt/cd2/downloads/Movie.2024.mkv" ) def test_replay_resends_pending_files_to_transfer(tmp_path, monkeypatch): """ 重启回放:登记过的文件要重新送入整理链,恢复被内存队列蒸发的任务。 """ media = tmp_path / "Movie.2024.mkv" media.write_bytes(b"x" * 10) pendingoper = MagicMock() pendingoper.list_all.return_value = [("local", str(media))] chain = _build_chain(pendingoper) transferred = [] monkeypatch.setattr(chain, "do_transfer", lambda **kw: transferred.append(kw["fileitem"])) chain._TransferChain__replay_pending() assert len(transferred) == 1 item = transferred[0] assert item.path == media.as_posix() assert item.storage == "local" assert item.type == "file" # 回放时重新读取当前大小,不依赖登记时的陈旧信息 assert item.size == 10 def test_replay_discards_vanished_files(tmp_path): """ 源文件已消失的登记要注销,否则每次启动都会重复回放一个不存在的文件。 """ pendingoper = MagicMock() missing = tmp_path / "gone.mkv" pendingoper.list_all.return_value = [("local", str(missing))] chain = _build_chain(pendingoper) chain.do_transfer = MagicMock() chain._TransferChain__replay_pending() chain.do_transfer.assert_not_called() pendingoper.discard.assert_called_once_with(storage="local", src_path=str(missing)) def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch): """ 挂载未就绪时读取失败属于暂时性故障,登记必须保留,等下次启动或人工整理。 这与「文件已消失」必须区别对待:把挂载抖动误判成文件消失就等于主动丢件。 """ media = tmp_path / "Movie.2024.mkv" media.write_bytes(b"x") pendingoper = MagicMock() pendingoper.list_all.return_value = [("local", str(media))] chain = _build_chain(pendingoper) chain.do_transfer = MagicMock() def unreadable(self, *_args, **_kwargs): """ 模拟挂载未就绪时的 stat 失败。 """ raise OSError(107, "Transport endpoint is not connected") monkeypatch.setattr(Path, "stat", unreadable) chain._TransferChain__replay_pending() chain.do_transfer.assert_not_called() pendingoper.discard.assert_not_called() def test_replay_restores_bluray_directory_type(tmp_path, monkeypatch): """ 蓝光原盘登记时保留尾部斜杠,回放必须还原成目录类型,否则会被当成单文件整理。 """ bluray = tmp_path / "Movie.2024.BluRay" bluray.mkdir() src_path = f"{bluray.as_posix()}/" pendingoper = MagicMock() pendingoper.list_all.return_value = [("local", src_path)] chain = _build_chain(pendingoper) transferred = [] monkeypatch.setattr(chain, "do_transfer", lambda **kw: transferred.append(kw["fileitem"])) chain._TransferChain__replay_pending() assert len(transferred) == 1 assert transferred[0].type == "dir" assert transferred[0].path == src_path def test_replay_is_noop_without_registrations(): """ 没有登记时回放不应触碰整理链。 """ pendingoper = MagicMock() pendingoper.list_all.return_value = [] chain = _build_chain(pendingoper) chain.do_transfer = MagicMock() chain._TransferChain__replay_pending() chain.do_transfer.assert_not_called() def test_replay_survives_db_failure(): """ 读取登记失败不能让启动流程报错。 """ pendingoper = MagicMock() pendingoper.list_all.side_effect = RuntimeError("db gone") chain = _build_chain(pendingoper) chain.do_transfer = MagicMock() chain._TransferChain__replay_pending() chain.do_transfer.assert_not_called() def test_replay_continues_after_single_file_failure(tmp_path, monkeypatch): """ 单个文件回放失败不能中断整批回放,否则一个坏文件会拖住所有漏件的恢复。 """ first = tmp_path / "A.mkv" second = tmp_path / "B.mkv" for item in (first, second): item.write_bytes(b"x") pendingoper = MagicMock() pendingoper.list_all.return_value = [("local", str(first)), ("local", str(second))] chain = _build_chain(pendingoper) handled = [] def flaky(**kw): """ 第一个文件整理抛异常,第二个正常。 """ if kw["fileitem"].name == "A.mkv": raise RuntimeError("boom") handled.append(kw["fileitem"].name) monkeypatch.setattr(chain, "do_transfer", flaky) chain._TransferChain__replay_pending() assert handled == ["B.mkv"] def test_replay_stop_keeps_unprocessed_registrations(tmp_path, monkeypatch): """ 宿主关闭后不得继续检查或注销下一条登记,未处理项留给下次启动回放。 """ first = tmp_path / "A.mkv" first.write_bytes(b"x") missing_second = tmp_path / "gone.mkv" pendingoper = MagicMock() pendingoper.list_all.return_value = [ ("local", str(first)), ("local", str(missing_second)), ] chain = _build_chain(pendingoper) stop_event = threading.Event() transferred = [] def transfer_first(**kwargs): """首条回放送入整理链后模拟宿主发出关闭信号。""" transferred.append(kwargs["fileitem"].path) stop_event.set() monkeypatch.setattr(chain, "do_transfer", transfer_first) chain._TransferChain__replay_pending(stop_event) assert transferred == [first.as_posix()] pendingoper.discard.assert_not_called()