mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
341 lines
13 KiB
Python
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"
|