import shutil from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock import pytest from app.adapters.system.plugin.package import PluginPackageManager def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: """构造使用隔离运行目录和事务目录的插件包管理器。""" monkeypatch.setattr( "app.adapters.system.plugin.package.settings", SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"), ) return PluginPackageManager(helper=Mock()) def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path): """已存在插件在后续阶段失败时应完整恢复原文件。""" manager = _manager(monkeypatch, tmp_path) plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" plugin_dir.mkdir(parents=True) source_file = plugin_dir / "__init__.py" source_file.write_text("old", encoding="utf-8") checkpoint = manager.checkpoint("DemoPlugin") source_file.write_text("new", encoding="utf-8") (plugin_dir / "partial.py").write_text("partial", encoding="utf-8") manager.rollback(checkpoint) assert source_file.read_text(encoding="utf-8") == "old" assert not (plugin_dir / "partial.py").exists() assert not checkpoint.transaction_dir.exists() def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path): """首次安装失败时应删除安装过程创建的不完整目录。""" manager = _manager(monkeypatch, tmp_path) checkpoint = manager.checkpoint("DemoPlugin") plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("partial", encoding="utf-8") manager.rollback(checkpoint) assert not plugin_dir.exists() assert not checkpoint.transaction_dir.exists() def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch, tmp_path): """补偿快照损坏时先失败,不能先删除当前可用插件。""" manager = _manager(monkeypatch, tmp_path) plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("old", encoding="utf-8") checkpoint = manager.checkpoint("DemoPlugin") shutil.rmtree(checkpoint.transaction_dir / "package") (plugin_dir / "__init__.py").write_text("new", encoding="utf-8") with pytest.raises(FileNotFoundError): manager.rollback(checkpoint) assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new" def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path): """本地来源不可复制时不得丢失已经运行的插件副本。""" manager = _manager(monkeypatch, tmp_path) plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" plugin_dir.mkdir(parents=True) source_file = plugin_dir / "__init__.py" source_file.write_text("stable", encoding="utf-8") missing_source = tmp_path / "missing" / "demoplugin" assert manager.sync_local("DemoPlugin", missing_source) is False assert source_file.read_text(encoding="utf-8") == "stable" def test_clone_rewrites_python_and_federation_assets(monkeypatch, tmp_path): """插件分身文件处理应由包适配器完成并隔离配置命名空间。""" manager = _manager(monkeypatch, tmp_path) plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" dist_dir = plugin_dir / "dist" dist_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text( "class DemoPlugin:\n" " plugin_name = 'Demo'\n" " plugin_desc = 'Description'\n" " plugin_config_prefix = 'demo_'\n" " plugin_version = '1.0.0'\n" " plugin_icon = 'old.png'\n" " def init_plugin(self, config=None):\n" " pass\n", encoding="utf-8", ) (dist_dir / "demoplugin.js").write_text( "const name = 'DemoPlugin'; const css = 'css__DemoPlugin__root';", encoding="utf-8", ) success, message = manager.clone( plugin_id="DemoPlugin", clone_id="DemoPluginBlue", original_class_name="DemoPlugin", suffix="blue", name="Demo Blue", description="Blue clone", version="2.0.0", icon="blue.png", ) clone_dir = tmp_path / "app" / "plugins" / "demopluginblue" clone_source = (clone_dir / "__init__.py").read_text(encoding="utf-8") assert success is True assert message == "文件修改成功" assert "class DemoPluginblue" in clone_source assert 'plugin_name = "Demo Blue"' in clone_source assert 'plugin_config_prefix = "demopluginblue_"' in clone_source assert "is_clone = True" in clone_source assert (clone_dir / "dist" / "demopluginblue.js").is_file()