mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""插件实例日志上下文接入点契约测试。
|
||
|
||
覆盖四类宿主受控调用点:插件实例的构造与 `init_plugin`
|
||
(`PluginLifecycle.start`/`initialize`)、事件处理器回调(`EventDispatcher`
|
||
的四个 invoke 方法)、定时服务回调(`SchedulerReconcileOwner.update_plugin_job`)、
|
||
HTTP API 端点回调(`PluginProjection.apis`)。每个用例只断言绑定期间
|
||
`current_plugin_instance_id()` 能读到发起调用的实例 ID,且调用结束后上下文
|
||
必须恢复为未绑定,不污染后续用例。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
import app.scheduler.reconcile as reconcile_module
|
||
from app.runtime.event.binding import EventBindingResolver, EventHandlerBinding
|
||
from app.runtime.event.dispatch import EventDispatcher
|
||
from app.runtime.extensions.plugin.database import PluginDatabase
|
||
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
|
||
from app.runtime.extensions.plugin.projection import PluginProjection
|
||
from app.runtime.log import current_plugin_instance_id
|
||
from app.scheduler.reconcile import SchedulerReconcileOwner
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 插件实例构造与 init_plugin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _lifecycle(*, plugins, running=None):
|
||
"""构造隔离外部事件和模块清理的生命周期实例。"""
|
||
classes: dict = {}
|
||
running = running if running is not None else {}
|
||
lifecycle = PluginLifecycle(
|
||
classes=classes,
|
||
running=running,
|
||
load_plugins=lambda _pid, _installed, _check: list(plugins),
|
||
loadable_plugins=lambda: ["DemoPluginWork"],
|
||
plugin_config=lambda _pid: {},
|
||
auth_checker=lambda _plugin: True,
|
||
clear_modules=MagicMock(),
|
||
clear_tools=MagicMock(),
|
||
enable_events=MagicMock(),
|
||
disable_events=MagicMock(),
|
||
runtime_status_writer=MagicMock(),
|
||
database=lambda: PluginDatabase(),
|
||
log=MagicMock(),
|
||
event_sender=MagicMock(),
|
||
)
|
||
return lifecycle, classes, running
|
||
|
||
|
||
def test_lifecycle_start_binds_instance_during_construct_and_init_plugin():
|
||
"""构造与 init_plugin 期间应能读到发起它的实例 ID,start 返回后上下文必须清空。"""
|
||
seen: list[tuple[str, str | None]] = []
|
||
|
||
class _Plugin:
|
||
plugin_name = "演示插件"
|
||
plugin_version = "1.0.0"
|
||
|
||
def __init__(self) -> None:
|
||
seen.append(("construct", current_plugin_instance_id()))
|
||
|
||
def init_plugin(self, _config: dict) -> None:
|
||
seen.append(("init", current_plugin_instance_id()))
|
||
|
||
@staticmethod
|
||
def get_state() -> bool:
|
||
return True
|
||
|
||
_Plugin.__name__ = "DemoPluginWork"
|
||
lifecycle, _classes, _running = _lifecycle(plugins=[_Plugin])
|
||
|
||
lifecycle.start("DemoPluginWork")
|
||
|
||
assert seen == [
|
||
("construct", "DemoPluginWork"),
|
||
("init", "DemoPluginWork"),
|
||
]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
def test_lifecycle_initialize_binds_instance_during_reinit():
|
||
"""公开 init_plugin(配置页重新生效)同样要绑定发起它的实例。"""
|
||
seen: list[str | None] = []
|
||
|
||
class _Plugin:
|
||
def init_plugin(self, _config: dict) -> None:
|
||
seen.append(current_plugin_instance_id())
|
||
|
||
@staticmethod
|
||
def get_state() -> bool:
|
||
return True
|
||
|
||
lifecycle, _classes, _running = _lifecycle(
|
||
plugins=[],
|
||
running={"DemoPluginWork": _Plugin()},
|
||
)
|
||
|
||
lifecycle.initialize("DemoPluginWork", {"enable": True})
|
||
|
||
assert seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. 事件处理器回调
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _FakeEventType:
|
||
"""提供 dispatch 内部读取的 `.value` 属性。"""
|
||
|
||
value = "test.event"
|
||
|
||
|
||
class _FakeEvent:
|
||
"""携带 dispatch 内部读取的最小事件属性集。"""
|
||
|
||
correlation_id = None
|
||
event_type = _FakeEventType()
|
||
|
||
|
||
def _dispatcher(resolvers: dict) -> EventDispatcher:
|
||
"""构造只依赖真实 EventBindingResolver 的最小事件调度器。"""
|
||
binding_resolver = EventBindingResolver(lock=threading.Lock(), resolvers=lambda: resolvers)
|
||
return EventDispatcher(
|
||
registry=MagicMock(),
|
||
binding_resolver=binding_resolver,
|
||
event_factory=MagicMock(),
|
||
error_handler=MagicMock(side_effect=AssertionError("handler must not error")),
|
||
async_handle_sink=MagicMock(),
|
||
sync_handle_sink=MagicMock(),
|
||
)
|
||
|
||
|
||
class _PluginEventHandler:
|
||
"""模拟虚拟实例克隆类:`__name__` 与运行实例 ID 相同。"""
|
||
|
||
def __init__(self) -> None:
|
||
"""记录事件处理期间观察到的实例上下文。"""
|
||
self.seen: list[str | None] = []
|
||
|
||
def on_event(self, _event: object) -> None:
|
||
"""记录当前绑定的实例 ID。"""
|
||
self.seen.append(current_plugin_instance_id())
|
||
|
||
async def on_event_async(self, _event: object) -> None:
|
||
"""异步处理器同样记录当前绑定的实例 ID。"""
|
||
self.seen.append(current_plugin_instance_id())
|
||
|
||
|
||
def test_invoke_sync_binds_owning_instance():
|
||
"""同步事件处理器执行期间应绑定声明它的插件实例。"""
|
||
instance = _PluginEventHandler()
|
||
_PluginEventHandler.__name__ = "DemoPluginWork"
|
||
resolvers = {
|
||
"plugins": lambda owner_class: (
|
||
EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True)
|
||
if owner_class is _PluginEventHandler
|
||
else None
|
||
)
|
||
}
|
||
dispatcher = _dispatcher(resolvers)
|
||
|
||
dispatcher.invoke_sync(_PluginEventHandler.on_event, _FakeEvent())
|
||
|
||
assert instance.seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
def test_invoke_sync_strict_binds_owning_instance():
|
||
"""strict 变体同样要绑定实例,且失败时仍需正确复位上下文。"""
|
||
instance = _PluginEventHandler()
|
||
_PluginEventHandler.__name__ = "DemoPluginWork"
|
||
resolvers = {
|
||
"plugins": lambda owner_class: (
|
||
EventHandlerBinding(instance=instance, owner_name="Demo")
|
||
if owner_class is _PluginEventHandler
|
||
else None
|
||
)
|
||
}
|
||
dispatcher = _dispatcher(resolvers)
|
||
|
||
dispatcher.invoke_sync_strict(_PluginEventHandler.on_event, _FakeEvent())
|
||
|
||
assert instance.seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invoke_async_binds_owning_instance_for_coroutine_handler():
|
||
"""异步事件处理器(直接 await)执行期间应绑定声明它的插件实例。"""
|
||
instance = _PluginEventHandler()
|
||
_PluginEventHandler.__name__ = "DemoPluginWork"
|
||
resolvers = {
|
||
"plugins": lambda owner_class: (
|
||
EventHandlerBinding(instance=instance, owner_name="Demo")
|
||
if owner_class is _PluginEventHandler
|
||
else None
|
||
)
|
||
}
|
||
dispatcher = _dispatcher(resolvers)
|
||
|
||
await dispatcher.invoke_async(_PluginEventHandler.on_event_async, _FakeEvent())
|
||
|
||
assert instance.seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invoke_async_binds_owning_instance_across_threadpool_hop():
|
||
"""同步处理器经线程池执行时,绑定必须跨越 `run_in_threadpool` 的执行上下文切换。"""
|
||
instance = _PluginEventHandler()
|
||
_PluginEventHandler.__name__ = "DemoPluginWork"
|
||
resolvers = {
|
||
"plugins": lambda owner_class: (
|
||
EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True)
|
||
if owner_class is _PluginEventHandler
|
||
else None
|
||
)
|
||
}
|
||
dispatcher = _dispatcher(resolvers)
|
||
|
||
await dispatcher.invoke_async(_PluginEventHandler.on_event, _FakeEvent())
|
||
|
||
assert instance.seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
def test_invoke_sync_does_not_bind_free_function_handler():
|
||
"""自由函数处理器不属于任何插件实例,不应绑定任何上下文。"""
|
||
seen: list[str | None] = []
|
||
|
||
def _free_handler(_event: object) -> None:
|
||
seen.append(current_plugin_instance_id())
|
||
|
||
dispatcher = _dispatcher({})
|
||
|
||
dispatcher.invoke_sync(_free_handler, _FakeEvent())
|
||
|
||
assert seen == [None]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. 定时服务回调
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_update_plugin_job_binds_instance_around_service_callback(monkeypatch):
|
||
"""插件定时服务被调度器实际调用时应绑定注册它的插件实例。"""
|
||
seen: list[str | None] = []
|
||
|
||
def _service_callback() -> None:
|
||
seen.append(current_plugin_instance_id())
|
||
|
||
fake_manager = SimpleNamespace(
|
||
get_plugin_services=lambda pid: [
|
||
{
|
||
"id": "job1",
|
||
"name": "演示任务",
|
||
"func": _service_callback,
|
||
"trigger": "interval",
|
||
"kwargs": {"seconds": 60},
|
||
}
|
||
],
|
||
get_plugin_attr=lambda _pid, _attr: "演示插件",
|
||
)
|
||
monkeypatch.setattr(reconcile_module, "get_plugin_manager", lambda: fake_manager)
|
||
|
||
owner = SchedulerReconcileOwner.__new__(SchedulerReconcileOwner)
|
||
owner._scheduler = MagicMock()
|
||
owner._lock = threading.RLock()
|
||
owner._jobs = {}
|
||
owner.start = MagicMock()
|
||
owner.remove_plugin_job = lambda _pid, job_id=None: None
|
||
owner._assign_job_generation = lambda _job_id, _job: None
|
||
|
||
owner.update_plugin_job("DemoPluginWork")
|
||
|
||
registered_job = owner._jobs["DemoPluginWork_job1"]
|
||
registered_job["func"]()
|
||
|
||
assert seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. HTTP API 端点回调
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _ApiEndpointPlugin:
|
||
"""声明一条会读取实例上下文的 HTTP API 路由的最小插件桩。"""
|
||
|
||
plugin_name = "接口插件"
|
||
|
||
def __init__(self) -> None:
|
||
"""初始化调用记录。"""
|
||
self.seen: list[str | None] = []
|
||
|
||
def get_state(self) -> bool:
|
||
"""插件始终启用。"""
|
||
return True
|
||
|
||
def get_name(self) -> str:
|
||
"""返回插件展示名称。"""
|
||
return self.plugin_name
|
||
|
||
def get_api(self) -> list[dict]:
|
||
"""声明一条状态查询路由,endpoint 绑定到本实例的方法。"""
|
||
return [{"path": "/status", "endpoint": self.status, "methods": ["GET"]}]
|
||
|
||
def status(self) -> dict:
|
||
"""处理状态查询请求期间记录当前绑定的实例 ID。"""
|
||
self.seen.append(current_plugin_instance_id())
|
||
return {"ok": True}
|
||
|
||
|
||
def test_projection_api_endpoint_binds_owning_instance_without_any_caller_context():
|
||
"""路由被 FastAPI 直接调用(不经过任何宿主受控调用点)时仍应绑定实例。"""
|
||
plugin = _ApiEndpointPlugin()
|
||
projection = PluginProjection({"DemoPluginWork": plugin})
|
||
|
||
apis = projection.apis()
|
||
|
||
assert current_plugin_instance_id() is None
|
||
apis[0]["endpoint"]()
|
||
|
||
assert plugin.seen == ["DemoPluginWork"]
|
||
assert current_plugin_instance_id() is None
|