Files
MoviePilot/tests/test_plugin_backup_restore.py
T

341 lines
13 KiB
Python

"""插件持久化备份与 Docker 重置恢复合同测试。"""
import errno
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from app.adapters.external import market as market_module
from app.chain import system as system_module
from app.chain.system import SystemChain
def _patch_docker_paths(monkeypatch, tmp_path: Path, *, reset: bool) -> Path:
"""把插件恢复路径和 Docker 重置条件隔离到临时目录。"""
config_dir = tmp_path / "config"
runtime_dir = tmp_path / "app" / "plugins"
config_dir.mkdir(parents=True)
runtime_dir.mkdir(parents=True)
runtime_config = replace(
system_module.get_chain_runtime_config_snapshot(),
root_path=tmp_path,
config_path=config_dir,
)
monkeypatch.setattr(
system_module,
"get_chain_runtime_config_snapshot",
lambda: runtime_config,
)
monkeypatch.setattr(
system_module.SystemUtils,
"is_docker",
staticmethod(lambda: True),
)
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset,
)
return runtime_dir
def _patch_market_paths(monkeypatch, tmp_path: Path) -> tuple[Path, Path]:
"""把插件更新后的持久化备份路径隔离到临时目录。"""
plugin_root = tmp_path / "app" / "plugins"
config_dir = tmp_path / "config"
plugin_root.mkdir(parents=True)
config_dir.mkdir(parents=True)
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
market_module,
"settings",
SimpleNamespace(CONFIG_PATH=config_dir),
)
monkeypatch.setattr(
market_module.SystemUtils,
"is_docker",
staticmethod(lambda: True),
)
return plugin_root, config_dir / "plugins_backup"
def _write_plugin(root: Path, plugin_id: str, filename: str, content: str) -> Path:
plugin_dir = root / plugin_id
plugin_dir.mkdir(parents=True, exist_ok=True)
target = plugin_dir / filename
target.write_text(content, encoding="utf-8")
return target
def test_backup_plugins_refreshes_existing_snapshot(monkeypatch, tmp_path):
"""关停备份应刷新同名插件并移除旧快照中的遗留文件。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_dir = backup_root / "demo"
_write_plugin(runtime_dir, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
_write_plugin(backup_root, "demo", "stale.py", "stale")
SystemChain.backup_plugins()
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "new"
assert not (backup_dir / "stale.py").exists()
def test_backup_plugins_failure_preserves_previous_snapshot(monkeypatch, tmp_path):
"""复制新快照失败时应保留上一份可恢复内容。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_dir = backup_root / "demo"
_write_plugin(runtime_dir, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
def fail_copy(*_args, **_kwargs):
raise OSError("copy failed")
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.backup_plugins()
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "old"
def test_backup_plugins_keeps_snapshot_missing_from_runtime(monkeypatch, tmp_path):
"""运行目录缺失时不得删除唯一的持久化备份。"""
_patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_file = _write_plugin(backup_root, "demo", "plugin.py", "recoverable")
SystemChain.backup_plugins()
assert backup_file.read_text(encoding="utf-8") == "recoverable"
def test_restore_plugins_keeps_backup_on_regular_start(monkeypatch, tmp_path):
"""普通重启保留备份,等待真正的容器重置场景消费。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "demo", "plugin.py", "stable")
SystemChain.restore_plugins()
assert not (runtime_dir / "demo").exists()
assert (backup_dir / "demo" / "plugin.py").exists()
def test_restore_plugins_consumes_backup_after_source_restore(monkeypatch, tmp_path):
"""源码恢复完成即可消费备份,依赖恢复由启动后台任务统一处理。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "stable")
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "stable"
assert not backup_dir.exists()
def test_restore_plugins_retries_only_missing_sources(monkeypatch, tmp_path):
"""恢复失败后只补仍缺失的目录,不覆盖用户随后重新安装的插件。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup")
reset_state = {"value": True}
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset_state["value"],
)
original_copytree = system_module.shutil.copytree
def fail_copy(source, target, *args, **kwargs):
if Path(source).name == "DemoPlugin":
raise OSError("copy failed")
return original_copytree(source, target, *args, **kwargs)
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.restore_plugins()
pending = backup_dir / SystemChain._plugin_restore_pending_file
assert pending.exists()
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "reinstalled")
reset_state["value"] = False
monkeypatch.setattr(system_module.shutil, "copytree", original_copytree)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "reinstalled"
assert not backup_dir.exists()
def test_restore_plugins_retries_existing_target_after_copy_failure(
monkeypatch,
tmp_path,
):
"""原目标已存在时,失败重试仍应完成备份版本的原子替换。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
reset_state = {"value": True}
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset_state["value"],
)
original_copytree = system_module.shutil.copytree
def fail_copy(source, target, *args, **kwargs):
if Path(source).name == "DemoPlugin":
raise OSError("copy failed")
return original_copytree(source, target, *args, **kwargs)
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "runtime-old"
reset_state["value"] = False
monkeypatch.setattr(system_module.shutil, "copytree", original_copytree)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "backup-new"
assert not backup_dir.exists()
def test_restore_plugins_falls_back_when_overlay_rename_returns_exdev(
monkeypatch,
tmp_path,
):
"""镜像层目录拒绝 rename 时仍能完成可恢复的快照替换。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
original_replace = Path.replace
def exdev_for_existing_target(self, target):
if self == runtime_dir / "DemoPlugin":
raise OSError(errno.EXDEV, "cross-device link")
return original_replace(self, target)
monkeypatch.setattr(Path, "replace", exdev_for_existing_target)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "backup-new"
assert not backup_dir.exists()
def test_restore_plugins_restores_previous_after_partial_overlay_removal(
monkeypatch,
tmp_path,
):
"""overlayfs 删除旧目录部分失败时仍恢复完整旧快照。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
_write_plugin(runtime_dir, "DemoPlugin", "settings.json", "settings-old")
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
original_replace = Path.replace
original_rmtree = system_module.shutil.rmtree
removal_attempts = 0
def exdev_for_existing_target(self, target):
if self == runtime_dir / "DemoPlugin":
raise OSError(errno.EXDEV, "cross-device link")
return original_replace(self, target)
def fail_after_partial_removal(path, *args, **kwargs):
nonlocal removal_attempts
if Path(path) == runtime_dir / "DemoPlugin" and removal_attempts == 0:
removal_attempts += 1
(runtime_dir / "DemoPlugin" / "plugin.py").unlink()
raise OSError("directory removal interrupted")
return original_rmtree(path, *args, **kwargs)
monkeypatch.setattr(Path, "replace", exdev_for_existing_target)
monkeypatch.setattr(system_module.shutil, "rmtree", fail_after_partial_removal)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "runtime-old"
assert (runtime_dir / "DemoPlugin" / "settings.json").read_text(
encoding="utf-8"
) == "settings-old"
assert backup_dir.exists()
assert (backup_dir / SystemChain._plugin_restore_pending_file).exists()
def test_backup_keeps_restore_retry_marker(monkeypatch, tmp_path):
"""关停备份不得清除尚未完成的恢复标记。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
pending = backup_dir / SystemChain._plugin_restore_pending_file
pending.parent.mkdir(parents=True)
pending.touch()
_write_plugin(runtime_dir, "demo", "plugin.py", "current")
SystemChain.backup_plugins()
assert pending.exists()
def test_backup_does_not_overwrite_failed_restore_snapshot(monkeypatch, tmp_path):
"""待重试项目的原快照必须跨关停保留,避免恢复材料被当前目录覆盖。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
pending = backup_dir / SystemChain._plugin_restore_pending_file
pending.parent.mkdir(parents=True)
pending.write_text(
'{"failed_items": {"demo": false}}', encoding="utf-8"
)
_write_plugin(runtime_dir, "demo", "plugin.py", "reinstalled")
_write_plugin(backup_dir, "demo", "plugin.py", "recoverable")
SystemChain.backup_plugins()
assert (backup_dir / "demo" / "plugin.py").read_text(encoding="utf-8") == "recoverable"
def test_market_refresh_replaces_snapshot_and_removes_stale_files(monkeypatch, tmp_path):
"""插件更新成功后应刷新对应持久化快照。"""
plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path)
backup_dir = backup_root / "demo"
_write_plugin(plugin_root, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
_write_plugin(backup_root, "demo", "stale.py", "stale")
assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is True
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "new"
assert not (backup_dir / "stale.py").exists()
def test_market_refresh_failure_preserves_previous_snapshot(monkeypatch, tmp_path):
"""插件更新备份失败时应继续保留旧快照。"""
plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path)
backup_dir = backup_root / "demo"
_write_plugin(plugin_root, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
def fail_copy(*_args, **_kwargs):
raise OSError("copy failed")
monkeypatch.setattr(market_module.shutil, "copytree", fail_copy)
assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is False
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "old"