"""插件持久化备份与 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"