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>
597 lines
17 KiB
Python
597 lines
17 KiB
Python
"""
|
||
插件生命周期六类状态中的运行结果测试。
|
||
"""
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
from app.runtime.extensions.plugin.database import PluginDatabase
|
||
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,
|
||
refresh_classification=None,
|
||
remove_classification=None,
|
||
enable_events=None,
|
||
log=None,
|
||
runtime_compatible=True,
|
||
):
|
||
"""
|
||
构造隔离外部事件和模块清理的生命周期实例。
|
||
"""
|
||
classes = {}
|
||
running = {}
|
||
statuses = {}
|
||
lifecycle = PluginLifecycle(
|
||
classes=classes,
|
||
running=running,
|
||
load_plugins=lambda _plugin_id, _installed, _check: list(plugins),
|
||
loadable_plugins=lambda: ["DemoPlugin"],
|
||
plugin_config=lambda _plugin_id: {},
|
||
auth_checker=lambda _plugin: auth,
|
||
clear_modules=MagicMock(),
|
||
clear_tools=MagicMock(),
|
||
enable_events=enable_events or MagicMock(),
|
||
disable_events=MagicMock(),
|
||
runtime_status_writer=statuses.__setitem__,
|
||
runtime_compatible=lambda _plugin_id: runtime_compatible,
|
||
database=lambda: PluginDatabase(),
|
||
log=log or MagicMock(),
|
||
event_sender=MagicMock(),
|
||
refresh_classification=refresh_classification,
|
||
remove_classification=remove_classification,
|
||
)
|
||
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_targeted_lifecycle_accepts_case_insensitive_directory_id():
|
||
"""
|
||
市场目录 ID 通常为小写,不能因插件类名大小写不同而误报加载失败。
|
||
"""
|
||
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 并记录插件及诊断阶段。
|
||
"""
|
||
log = MagicMock()
|
||
lifecycle, _classes, running, statuses = _lifecycle(plugins=[], log=log)
|
||
|
||
result = lifecycle.start("DemoPlugin")
|
||
|
||
assert result == {"DemoPlugin": PluginRuntimeStatus.LOAD_FAILED}
|
||
assert running == {}
|
||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
|
||
assert "DemoPlugin" in log.error.call_args.args[0]
|
||
assert "加载器未返回匹配的插件类" in log.error.call_args.args[0]
|
||
|
||
|
||
def test_lifecycle_reports_incompatible_runtime_instead_of_load_failure():
|
||
"""
|
||
载荷声明与当前运行时不兼容时,卡片要显示不支持而不是加载失败。
|
||
"""
|
||
lifecycle, _classes, running, statuses = _lifecycle(
|
||
plugins=[],
|
||
runtime_compatible=False,
|
||
)
|
||
|
||
result = lifecycle.start("DemoPlugin")
|
||
|
||
assert result == {"DemoPlugin": PluginRuntimeStatus.INCOMPATIBLE_RUNTIME}
|
||
assert running == {}
|
||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.INCOMPATIBLE_RUNTIME
|
||
|
||
|
||
def test_classification_registration_precedes_event_enablement():
|
||
"""
|
||
启用插件必须先刷新分类声明,再开放可能产生扩展事实的事件入口。
|
||
"""
|
||
order: list[str] = []
|
||
lifecycle, _classes, _running, _statuses = _lifecycle(
|
||
plugins=[_plugin_class()],
|
||
refresh_classification=lambda _plugin_id, _instance: order.append("refresh"),
|
||
remove_classification=lambda _plugin_id: order.append("remove"),
|
||
enable_events=lambda _plugin_type: order.append("enable"),
|
||
)
|
||
|
||
lifecycle.start("DemoPlugin")
|
||
|
||
assert order == ["remove", "refresh", "enable"]
|
||
|
||
|
||
def test_invalid_classification_declaration_does_not_fail_plugin_start():
|
||
"""
|
||
可选分类声明异常会被撤销并记录,但插件主体仍进入 active。
|
||
"""
|
||
removals: list[str] = []
|
||
log = MagicMock()
|
||
|
||
def fail_refresh(_plugin_id, _instance):
|
||
"""
|
||
模拟插件返回命名空间不合法的分类字段声明。
|
||
"""
|
||
raise ValueError("invalid field")
|
||
|
||
lifecycle, _classes, running, _statuses = _lifecycle(
|
||
plugins=[_plugin_class()],
|
||
refresh_classification=fail_refresh,
|
||
remove_classification=removals.append,
|
||
log=log,
|
||
)
|
||
|
||
result = lifecycle.start("DemoPlugin")
|
||
|
||
assert result == {"DemoPlugin": PluginRuntimeStatus.ACTIVE}
|
||
assert "DemoPlugin" in running
|
||
assert removals == ["DemoPlugin", "DemoPlugin"]
|
||
assert "分类扩展声明无效" in log.warning.call_args.args[0]
|
||
|
||
|
||
def test_disable_and_stop_remove_classification_registration():
|
||
"""
|
||
配置禁用和最终停止都必须撤销当前运行实例拥有的分类声明。
|
||
"""
|
||
removals: list[str] = []
|
||
|
||
class DemoPlugin:
|
||
"""
|
||
允许测试切换启用状态的运行时插件。
|
||
"""
|
||
|
||
plugin_name = "演示插件"
|
||
plugin_version = "1.0.0"
|
||
enabled = True
|
||
|
||
def init_plugin(self, config):
|
||
"""
|
||
按配置切换启用状态。
|
||
"""
|
||
if "enabled" in config:
|
||
self.enabled = bool(config["enabled"])
|
||
|
||
def get_state(self):
|
||
"""
|
||
返回当前测试状态。
|
||
"""
|
||
return self.enabled
|
||
|
||
lifecycle, _classes, _running, _statuses = _lifecycle(
|
||
plugins=[DemoPlugin],
|
||
refresh_classification=lambda _plugin_id, _instance: None,
|
||
remove_classification=removals.append,
|
||
)
|
||
lifecycle.start("DemoPlugin")
|
||
removals.clear()
|
||
|
||
lifecycle.initialize("DemoPlugin", {"enabled": False})
|
||
assert removals == ["DemoPlugin", "DemoPlugin"]
|
||
|
||
removals.clear()
|
||
lifecycle.stop("DemoPlugin")
|
||
assert removals == ["DemoPlugin"]
|
||
|
||
|
||
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_preserves_owner_after_hook_failure():
|
||
"""
|
||
旧stop入口在hook报错时返回False并保留实例,成功场景另行保持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()
|
||
|
||
previous = running["DemoPlugin"]
|
||
assert lifecycle.stop("DemoPlugin") is False
|
||
assert order == ["close", "stop_service"]
|
||
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
|
||
lifecycle._clear_modules.assert_not_called()
|
||
assert classes["DemoPlugin"] is DemoPlugin
|
||
assert running["DemoPlugin"] is previous
|
||
|
||
|
||
def test_legacy_reload_stops_after_old_instance_hook_failure():
|
||
"""
|
||
旧实例hook失败必须阻止reload创建新实例,保留可追踪的资源owner。
|
||
"""
|
||
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.LOAD_FAILED
|
||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
|
||
assert running["DemoPlugin"] is previous
|
||
assert len(instances) == 1
|
||
stop_service.assert_called_once_with()
|
||
lifecycle._event_sender.assert_not_called()
|