Files
MoviePilot/tests/test_plugin_lifecycle_status.py

367 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件生命周期六类状态中的运行结果测试。"""
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.schemas.plugin import PluginRuntimeStatus
def _plugin_class(*, init_error: Exception | None = None):
"""构造满足插件最小生命周期合同的测试类。"""
class DemoPlugin:
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
if init_error:
raise init_error
@staticmethod
def get_state():
return True
return DemoPlugin
def _lifecycle(*, plugins, auth=True):
"""构造隔离外部事件和模块清理的生命周期实例。"""
classes = {}
running = {}
statuses = {}
lifecycle = PluginLifecycle(
classes=classes,
running=running,
load_plugins=lambda _plugin_id, _installed, _check: list(plugins),
installed_plugins=lambda: ["DemoPlugin"],
plugin_config=lambda _plugin_id: {},
auth_checker=lambda _plugin: auth,
clear_modules=MagicMock(),
clear_tools=MagicMock(),
enable_events=MagicMock(),
disable_events=MagicMock(),
runtime_status_writer=statuses.__setitem__,
log=MagicMock(),
event_sender=MagicMock(),
)
return lifecycle, classes, running, statuses
def test_lifecycle_records_active_result():
"""插件完成构造和初始化后进入 active。"""
lifecycle, classes, running, statuses = _lifecycle(
plugins=[_plugin_class()],
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.ACTIVE}
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE
def test_lifecycle_records_policy_block_without_runtime_instance():
"""类已发现但权限策略拒绝时进入 blocked_by_policy。"""
lifecycle, _classes, running, statuses = _lifecycle(
plugins=[_plugin_class()],
auth=False,
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.BLOCKED_BY_POLICY}
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.BLOCKED_BY_POLICY
def test_lifecycle_records_load_failure_for_init_exception():
"""插件初始化异常时保留类信息并进入 load_failed。"""
lifecycle, classes, running, statuses = _lifecycle(
plugins=[_plugin_class(init_error=RuntimeError("init failed"))],
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.LOAD_FAILED}
assert "DemoPlugin" in classes
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
def test_lifecycle_records_load_failure_when_loader_returns_no_class():
"""目标源码无法产生合法插件类时进入 load_failed。"""
lifecycle, _classes, running, statuses = _lifecycle(plugins=[])
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.LOAD_FAILED}
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
def test_phased_quiesce_disables_events_before_hooks_and_keeps_instance():
"""宿主先停用 handler再在事件结算后执行旧 hook 并保留实例。"""
order: list[str] = []
class DemoPlugin:
"""记录旧插件 ABI hook 调用顺序的测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件事件 handler 启用。"""
return True
def close(self):
"""停止插件私有计时器和 watcher。"""
order.append("close")
def stop_service(self):
"""停止插件服务并允许尾事件继续投递。"""
order.append("stop_service")
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.reset_mock()
lifecycle._disable_events.side_effect = (
lambda _plugin_type: order.append("disable_events")
)
lifecycle._clear_modules.reset_mock()
lifecycle._clear_tools.reset_mock()
assert lifecycle.quiesce_handlers() is True
assert order == ["disable_events"]
assert lifecycle.quiesce_services() is True
assert order == ["disable_events", "close", "stop_service"]
assert classes["DemoPlugin"] is DemoPlugin
assert isinstance(running["DemoPlugin"], DemoPlugin)
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
lifecycle._clear_modules.assert_not_called()
lifecycle._clear_tools.assert_not_called()
assert lifecycle.finalize() is True
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
lifecycle._clear_modules.assert_called_once_with(None)
lifecycle._clear_tools.assert_called_once_with()
assert classes == {}
assert running == {}
def test_quiesce_runs_stop_service_after_close_failure_and_retries_missing_hook():
"""close 异常不能跳过 stop_service重试时只补偿尚未成功的 hook。"""
order: list[str] = []
close_fails = [True]
class DemoPlugin:
"""首次 close 失败、stop_service 正常完成的测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件为启用状态。"""
return True
def close(self):
"""首次调用模拟插件私有资源关闭异常。"""
order.append("close")
if close_fails[0]:
raise RuntimeError("close failed")
def stop_service(self):
"""记录旧 ABI 的第二个停机 hook。"""
order.append("stop_service")
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
assert lifecycle.quiesce() is False
assert order == ["close", "stop_service"]
assert lifecycle.finalize() is False
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
close_fails[0] = False
assert lifecycle.quiesce() is True
assert order == ["close", "stop_service", "close"]
assert lifecycle.finalize() is True
assert classes == {}
assert running == {}
def test_quiesce_does_not_close_resources_until_all_handlers_are_disabled():
"""任一 handler 停用失败时不得调用可能破坏共享资源的旧停机 hook。"""
close = MagicMock()
class DemoPlugin:
"""提供可观察 close hook 的测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件事件 handler 启用。"""
return True
def close(self):
"""记录资源关闭调用。"""
close()
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.side_effect = RuntimeError("disable failed")
assert lifecycle.quiesce() is False
close.assert_not_called()
assert lifecycle.finalize() is False
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
lifecycle._disable_events.side_effect = None
assert lifecycle.quiesce() is True
close.assert_called_once_with()
assert lifecycle.finalize() is True
def test_legacy_stop_entry_remains_idempotent():
"""旧 stop 先解绑事件、保持 None 返回,且重复调用不重复执行 hook。"""
order: list[str] = []
class DemoPlugin:
"""提供可计数停机 hook 的兼容测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件为启用状态。"""
return True
def close(self):
"""记录 close 调用。"""
order.append("close")
def stop_service(self):
"""记录 stop_service 调用。"""
order.append("stop_service")
lifecycle, _classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.reset_mock()
lifecycle._disable_events.side_effect = (
lambda _plugin_type: order.append("disable_events")
)
assert lifecycle.stop() is None
assert lifecycle.stop() is None
assert order == ["disable_events", "close", "stop_service"]
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
assert running == {}
def test_legacy_stop_force_finalizes_after_hook_failure():
"""旧 stop 即使 hook 报错也释放实例,并保持历史 None 返回 ABI。"""
order: list[str] = []
class DemoPlugin:
"""close 失败但仍应完成兼容卸载的测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件为启用状态。"""
return True
def close(self):
"""模拟旧插件关闭异常。"""
order.append("close")
raise RuntimeError("close failed")
def stop_service(self):
"""证明 close 异常不会跳过后续 hook。"""
order.append("stop_service")
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.reset_mock()
lifecycle._clear_modules.reset_mock()
assert lifecycle.stop("DemoPlugin") is None
assert order == ["close", "stop_service"]
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
lifecycle._clear_modules.assert_called_once_with("DemoPlugin")
assert classes == {}
assert running == {}
def test_legacy_reload_continues_after_old_instance_hook_failure():
"""旧实例 hook 失败只影响诊断,不得阻止 reload 创建新实例。"""
instances: list[object] = []
stop_service = MagicMock()
class DemoPlugin:
"""记录每次构造并让 close 持续失败的重载测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def __init__(self):
"""记录新建的运行实例。"""
instances.append(self)
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件为启用状态。"""
return True
@staticmethod
def close():
"""模拟旧实例退出异常。"""
raise RuntimeError("close failed")
def stop_service(self):
"""记录旧 ABI 的第二个停机 hook。"""
stop_service()
lifecycle, _classes, running, statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
previous = running["DemoPlugin"]
result = lifecycle.reload("DemoPlugin", "plugin-reload")
assert result is PluginRuntimeStatus.ACTIVE
assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE
assert running["DemoPlugin"] is not previous
assert len(instances) == 2
stop_service.assert_called_once_with()
lifecycle._event_sender.assert_called_once_with(
"plugin-reload",
data={"plugin_id": "DemoPlugin"},
)