mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* fix: retain plugin lifecycle ownership and recover installation state * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
111 lines
5.3 KiB
Python
111 lines
5.3 KiB
Python
"""
|
|
补充插件停止拒绝和本体启用位恢复的真实入口边界。
|
|
"""
|
|
|
|
from contextlib import nullcontext
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from app.application import scheduling
|
|
from app.application.plugin import folders, management, routes
|
|
from app.runtime.extensions.plugin.manager import PluginManager
|
|
from tests.test_plugin_instance_enablement import _host, _store
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_uninstall_preserves_membership_and_data_when_stop_is_refused(monkeypatch):
|
|
"""
|
|
异步卸载遇到真实停止失败回执时保留成员、配置、实例和数据,允许稍后重试。
|
|
"""
|
|
manager = Mock()
|
|
manager.mutation.side_effect = lambda _operation: nullcontext()
|
|
manager.get_plugin_instance.return_value = SimpleNamespace(instance_id="ReviewClone")
|
|
manager.get_plugin_source_instances.return_value = []
|
|
manager.plugins = {}
|
|
manager.stop.return_value = False
|
|
configuration = SimpleNamespace(async_update_atomically=AsyncMock())
|
|
monkeypatch.setattr(management, "get_plugin_manager", lambda: manager)
|
|
monkeypatch.setattr(management, "get_configured_system_config", lambda: configuration)
|
|
monkeypatch.setattr(routes, "remove_plugin_api", Mock())
|
|
monkeypatch.setattr(scheduling, "remove_plugin_job", Mock())
|
|
remove_folder = Mock()
|
|
monkeypatch.setattr(folders, "remove_plugin_from_folders", remove_folder)
|
|
with pytest.raises(RuntimeError, match="尚未停止"):
|
|
await management.uninstall_plugin_runtime("ReviewClone")
|
|
manager.stop.assert_called_once_with("ReviewClone")
|
|
configuration.async_update_atomically.assert_not_awaited()
|
|
manager.delete_plugin_config.assert_not_called()
|
|
manager.delete_plugin_data.assert_not_called()
|
|
manager.delete_plugin_instance.assert_not_called()
|
|
manager.remove_plugin.assert_not_called()
|
|
remove_folder.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("package_result", [True, False, RuntimeError("清理旧分身文件失败")])
|
|
async def test_legacy_clone_uninstall_reports_actual_package_cleanup(monkeypatch, package_result):
|
|
"""
|
|
旧版磁盘分身清理按真实结果回执,拒绝或异常不能误报文件已删除。
|
|
"""
|
|
manager = Mock()
|
|
manager.mutation.side_effect = lambda _operation: nullcontext()
|
|
manager.get_plugin_instance.return_value = None
|
|
manager.get_plugin_source_instances.return_value = []
|
|
manager.plugins = {"LegacyClone": SimpleNamespace(is_clone=True)}
|
|
manager.stop.return_value = None
|
|
if isinstance(package_result, Exception):
|
|
manager.remove_plugin_package.side_effect = package_result
|
|
else:
|
|
manager.remove_plugin_package.return_value = package_result
|
|
configuration = SimpleNamespace(async_update_atomically=AsyncMock())
|
|
monkeypatch.setattr(management, "get_plugin_manager", lambda: manager)
|
|
monkeypatch.setattr(management, "get_configured_system_config", lambda: configuration)
|
|
monkeypatch.setattr(routes, "remove_plugin_api", Mock())
|
|
monkeypatch.setattr(scheduling, "remove_plugin_job", Mock())
|
|
monkeypatch.setattr(folders, "remove_plugin_from_folders", Mock())
|
|
result = await management.uninstall_plugin_runtime("LegacyClone")
|
|
expected = package_result is True
|
|
assert result == {"was_clone": True, "clone_files_removed": expected}
|
|
configuration.async_update_atomically.assert_awaited_once()
|
|
manager.remove_plugin_package.assert_called_once_with("LegacyClone")
|
|
manager.delete_plugin_config.assert_called_once_with("LegacyClone", force=True)
|
|
manager.delete_plugin_data.assert_called_once_with("LegacyClone", force=True)
|
|
manager.remove_plugin.assert_called_once_with("LegacyClone")
|
|
|
|
|
|
@pytest.mark.parametrize("enabled", [True, False])
|
|
def test_manager_restores_host_enabled_without_discarding_its_descriptor(enabled):
|
|
"""
|
|
通过真实实例存储恢复冻结启用位,保留已有展示字段且不会为启用操作停止实例。
|
|
"""
|
|
host = _host("ReviewHost", enabled=not enabled).model_copy(update={"plugin_name": "保留名称"})
|
|
store, directory = _store({"ReviewHost": host})
|
|
manager = object.__new__(PluginManager)
|
|
manager._plugin_instance_store = store
|
|
manager.stop = Mock(return_value=None)
|
|
assert manager.is_plugin_host_enabled("MissingHost") is False
|
|
assert manager.is_plugin_host_enabled("ReviewHost") is not enabled
|
|
manager.restore_plugin_host_enabled("ReviewHost", enabled)
|
|
assert manager.is_plugin_host_enabled("ReviewHost") is enabled
|
|
assert directory.records["ReviewHost"].plugin_name == "保留名称"
|
|
if enabled:
|
|
manager.stop.assert_not_called()
|
|
else:
|
|
manager.stop.assert_called_once_with("ReviewHost")
|
|
|
|
|
|
def test_manager_does_not_persist_disabled_flag_until_running_owner_stops():
|
|
"""
|
|
停止失败时不能先写停用位,原实例及其描述留给下一轮补偿。
|
|
"""
|
|
store, directory = _store({"ReviewHost": _host("ReviewHost")})
|
|
manager = object.__new__(PluginManager)
|
|
manager._plugin_instance_store = store
|
|
manager.stop = Mock(return_value=False)
|
|
with pytest.raises(RuntimeError, match="尚未停止"):
|
|
manager.restore_plugin_host_enabled("ReviewHost", False)
|
|
assert manager.is_plugin_host_enabled("ReviewHost") is True
|
|
assert directory.records["ReviewHost"].is_enabled is True
|