mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 20:05:42 +08:00
509 lines
16 KiB
Python
509 lines
16 KiB
Python
"""
|
||
ConfigReloadMixin 配置变更事件处理器解析的回归测试(Issue #6329)。
|
||
|
||
ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解析回实例方法,
|
||
不能因 __name__ 与类上方法名不一致而被静默跳过。
|
||
"""
|
||
|
||
import inspect
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
|
||
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
|
||
from app.adapters.network.doh import DohHelper
|
||
from app.chain.scraping import ScrapingChain
|
||
from app.chain.transfer.facade import TransferChain
|
||
from app.foundation.singleton import Singleton, SingletonClass
|
||
from app.monitor.monitor import Monitor
|
||
from app.runtime.events import Event, EventHandlerBinding, eventmanager
|
||
from app.runtime.extensions.plugin.manager import PluginManager
|
||
from app.runtime.reload import ConfigReloadMixin
|
||
from app.runtime.state import SystemHelper
|
||
from app.scheduler.facade import Scheduler
|
||
from app.schemas.event import ConfigChangeEventData
|
||
from app.schemas.types import EventType
|
||
from app.startup.initializers.modules import (
|
||
configure_config_reload_event_handler_resolver,
|
||
configure_host_event_handler_resolver,
|
||
get_config_reload_handler_providers,
|
||
)
|
||
|
||
|
||
class _ReloadRecorder(ConfigReloadMixin):
|
||
"""
|
||
测试用同步重载子类,记录重载调用次数。
|
||
"""
|
||
|
||
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
|
||
|
||
def __init__(self):
|
||
"""
|
||
初始化同步重载调用计数。
|
||
"""
|
||
self.reload_count = 0
|
||
|
||
def on_config_changed(self):
|
||
"""
|
||
记录同步重载调用,供键筛选断言使用。
|
||
"""
|
||
self.reload_count += 1
|
||
|
||
|
||
class _AsyncReloadRecorder(ConfigReloadMixin):
|
||
"""
|
||
测试用异步重载子类,记录重载调用次数。
|
||
"""
|
||
|
||
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
|
||
|
||
def __init__(self):
|
||
"""
|
||
初始化异步重载调用计数。
|
||
"""
|
||
self.reload_count = 0
|
||
|
||
async def on_config_changed(self):
|
||
"""
|
||
记录异步重载调用,供等待执行的断言使用。
|
||
"""
|
||
self.reload_count += 1
|
||
|
||
|
||
@pytest.fixture(params=[_ReloadRecorder, _AsyncReloadRecorder])
|
||
def recorder(request, monkeypatch):
|
||
"""
|
||
将生成的处理器绑定到测试实例,并在测试后移除全局监听。
|
||
"""
|
||
recorder_cls = request.param
|
||
instance = recorder_cls()
|
||
|
||
def resolver(owner_class):
|
||
"""
|
||
只解析当前参数化用例的处理器实例。
|
||
"""
|
||
if owner_class is recorder_cls:
|
||
return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__)
|
||
return None
|
||
|
||
# 隔离真实解析器,仅绑定测试实例
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{"test_reload": resolver},
|
||
)
|
||
yield instance
|
||
# 移除类定义时注册的全局监听,避免影响其余测试
|
||
eventmanager.remove_event_listener(
|
||
EventType.ConfigChanged, recorder_cls.handle_config_changed
|
||
)
|
||
|
||
|
||
def _build_event(keys):
|
||
"""
|
||
构造携带指定配置键的 ConfigChanged 事件。
|
||
"""
|
||
return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys)))
|
||
|
||
|
||
def _configure_lifespan_resolvers() -> None:
|
||
"""
|
||
按启动组合顺序登记宿主和配置 owner resolver。
|
||
"""
|
||
configure_host_event_handler_resolver()
|
||
configure_config_reload_event_handler_resolver()
|
||
|
||
|
||
async def _dispatch(instance, event):
|
||
"""
|
||
按处理器类型走事件总线的同步或异步调用路径。
|
||
"""
|
||
handler = instance.__class__.handle_config_changed
|
||
if inspect.iscoroutinefunction(handler):
|
||
await eventmanager._EventManager__invoke_handler_by_type_async(handler, event)
|
||
else:
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(handler, event)
|
||
|
||
|
||
def test_generated_handler_exposes_method_name(recorder):
|
||
"""
|
||
生成的处理器 __name__ 必须与类上的方法名一致,保证事件总线能解析到实例方法。
|
||
"""
|
||
handler = recorder.__class__.handle_config_changed
|
||
assert handler.__name__ == "handle_config_changed"
|
||
assert handler.__qualname__ == f"{recorder.__class__.__name__}.handle_config_changed"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_config_changed_event_triggers_reload(recorder):
|
||
"""
|
||
命中 CONFIG_WATCH 的配置变更事件必须触发子类重载逻辑。
|
||
"""
|
||
await _dispatch(recorder, _build_event({"TEST_RELOAD_KEY"}))
|
||
assert recorder.reload_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unrelated_config_key_skips_reload(recorder):
|
||
"""
|
||
未监听的配置键变更不得触发重载。
|
||
"""
|
||
await _dispatch(recorder, _build_event({"OTHER_KEY"}))
|
||
assert recorder.reload_count == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_falls_back_to_qualname_when_name_mismatched(monkeypatch):
|
||
"""
|
||
处理器 __name__ 与类上方法名不一致时,事件总线应回退 __qualname__ 末段解析。
|
||
"""
|
||
instance = _ReloadRecorder()
|
||
|
||
# 包装器必须保留事件处理签名,当前用例只验证方法解析而不读取事件。
|
||
def wrapper(event): # pylint: disable=unused-argument
|
||
"""
|
||
模拟只同步了 __qualname__ 的动态处理器。
|
||
"""
|
||
instance.on_config_changed()
|
||
|
||
wrapper.__module__ = _ReloadRecorder.__module__
|
||
wrapper.__qualname__ = f"{_ReloadRecorder.__name__}.handle_config_changed"
|
||
|
||
def resolver(owner_class):
|
||
"""
|
||
把测试包装器解析到指定同步重载实例。
|
||
"""
|
||
if owner_class is _ReloadRecorder:
|
||
return EventHandlerBinding(instance=instance, owner_name=owner_class.__name__)
|
||
return None
|
||
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{"test_reload": resolver},
|
||
)
|
||
|
||
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||
wrapper, _build_event({"TEST_RELOAD_KEY"})
|
||
)
|
||
|
||
assert wrapper.__name__ == "wrapper"
|
||
assert instance.reload_count == 1
|
||
|
||
|
||
def test_externally_managed_reload_class_does_not_register_listener(monkeypatch):
|
||
"""
|
||
外部统一管理配置生命周期时,Mixin 保留重载能力但不重复绑定事件。
|
||
"""
|
||
registrations = []
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"add_event_listener",
|
||
lambda *args, **kwargs: registrations.append((args, kwargs)),
|
||
)
|
||
|
||
class _ExternallyManagedReloadRecorder(ConfigReloadMixin):
|
||
"""
|
||
模拟由外部生命周期唯一绑定事件的组件。
|
||
"""
|
||
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
|
||
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
|
||
|
||
def on_config_changed(self):
|
||
"""
|
||
保持空重载实现,验证类定义阶段不会注册监听。
|
||
"""
|
||
pass
|
||
|
||
assert registrations == []
|
||
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__
|
||
|
||
|
||
def test_config_reload_owner_is_skipped_without_lifecycle_resolver(monkeypatch):
|
||
"""
|
||
非模块配置 owner 缺少 resolver 时不得被事件总线临时构造。
|
||
"""
|
||
helper = object.__new__(DohHelper)
|
||
reload_config = Mock()
|
||
helper.on_config_changed = reload_config
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
DohHelper.handle_config_changed,
|
||
_build_event({"DOH_ENABLE"}),
|
||
)
|
||
|
||
reload_config.assert_not_called()
|
||
assert (
|
||
"app.adapters.network.doh.DohHelper.handle_config_changed"
|
||
in eventmanager.unresolved_handler_bindings()
|
||
)
|
||
|
||
|
||
def test_host_config_reload_resolver_binds_current_doh_owner(monkeypatch):
|
||
"""
|
||
DoH 配置事件必须绑定到已由组合根物化的当前 Adapter。
|
||
"""
|
||
helper = object.__new__(DohHelper)
|
||
reload_config = Mock()
|
||
helper.on_config_changed = reload_config
|
||
singleton_key = (DohHelper, (), frozenset())
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
_configure_lifespan_resolvers()
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
DohHelper.handle_config_changed,
|
||
_build_event({"DOH_ENABLE"}),
|
||
)
|
||
|
||
reload_config.assert_called_once_with()
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
DohHelper.handle_config_changed,
|
||
_build_event({"OTHER_KEY"}),
|
||
)
|
||
|
||
reload_config.assert_called_once_with()
|
||
|
||
|
||
def test_host_resolver_binds_current_scraping_owner(monkeypatch):
|
||
"""
|
||
宿主 resolver 必须把同步重载处理器绑定到当前 Chain 单例。
|
||
"""
|
||
chain = object.__new__(ScrapingChain)
|
||
reload_config = Mock()
|
||
chain.on_config_changed = reload_config
|
||
singleton_key = (ScrapingChain, (), frozenset())
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, chain)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
configure_host_event_handler_resolver()
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
ScrapingChain.handle_config_changed,
|
||
_build_event({"ScrapingSwitchs"}),
|
||
)
|
||
|
||
reload_config.assert_called_once_with()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_host_resolver_binds_current_scheduler_owner(monkeypatch):
|
||
"""
|
||
宿主 resolver 必须把异步重载处理器绑定到当前 Scheduler 单例。
|
||
"""
|
||
scheduler = object.__new__(Scheduler)
|
||
reload_config = AsyncMock()
|
||
scheduler.on_config_changed = reload_config
|
||
monkeypatch.setitem(SingletonClass._instances, Scheduler, scheduler)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
configure_host_event_handler_resolver()
|
||
|
||
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||
Scheduler.handle_config_changed,
|
||
_build_event({"DEV"}),
|
||
)
|
||
|
||
reload_config.assert_awaited_once_with()
|
||
|
||
|
||
def test_config_reload_resolver_reads_latest_singleton_owner(monkeypatch, compose_cache_backend):
|
||
"""
|
||
resolver 每次读取当前单例,不能保留已经换代的 Redis 实例。
|
||
"""
|
||
compose_cache_backend("redis")
|
||
first = object.__new__(RedisHelper)
|
||
first_reload = Mock()
|
||
first.on_config_changed = first_reload
|
||
second = object.__new__(RedisHelper)
|
||
second_reload = Mock()
|
||
second.on_config_changed = second_reload
|
||
singleton_key = (RedisHelper, (), frozenset())
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, first)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
_configure_lifespan_resolvers()
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
RedisHelper.handle_config_changed,
|
||
_build_event({"CACHE_BACKEND_URL"}),
|
||
)
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, second)
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
RedisHelper.handle_config_changed,
|
||
_build_event({"CACHE_BACKEND_URL"}),
|
||
)
|
||
|
||
first_reload.assert_called_once_with()
|
||
second_reload.assert_called_once_with()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_config_reload_resolver_invokes_current_async_redis_owner(monkeypatch, compose_cache_backend):
|
||
"""
|
||
异步配置处理器必须绑定当前 Redis owner,并保留键筛选。
|
||
"""
|
||
compose_cache_backend("redis")
|
||
helper = object.__new__(AsyncRedisHelper)
|
||
reload_config = AsyncMock()
|
||
helper.on_config_changed = reload_config
|
||
singleton_key = (AsyncRedisHelper, (), frozenset())
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
_configure_lifespan_resolvers()
|
||
|
||
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||
AsyncRedisHelper.handle_config_changed,
|
||
_build_event({"CACHE_REDIS_MAX_CONNECTIONS"}),
|
||
)
|
||
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||
AsyncRedisHelper.handle_config_changed,
|
||
_build_event({"OTHER_KEY"}),
|
||
)
|
||
|
||
reload_config.assert_awaited_once_with()
|
||
|
||
|
||
def test_config_reload_resolver_does_not_materialize_lazy_transfer_owner(
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
尚未创建的整理链只声明跳过,配置事件不得启动线程资源。
|
||
"""
|
||
singleton_key = (TransferChain, (), frozenset())
|
||
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
_configure_lifespan_resolvers()
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
TransferChain.handle_config_changed,
|
||
_build_event({"TRANSFER_THREADS"}),
|
||
)
|
||
|
||
assert TransferChain.get_existing_instance() is None
|
||
|
||
|
||
def test_plugin_manager_falls_through_plugin_resolver_to_current_owner(
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
resolver 先注册时也应在插件 Runtime 创建后接管当前管理器。
|
||
"""
|
||
singleton_key = (PluginManager, (), frozenset())
|
||
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
configure_config_reload_event_handler_resolver()
|
||
|
||
manager = object.__new__(PluginManager)
|
||
reload_config = Mock()
|
||
manager.on_config_changed = reload_config
|
||
monkeypatch.setitem(Singleton._instances, singleton_key, manager)
|
||
plugin_resolver = Mock(return_value=None)
|
||
eventmanager.register_handler_instance_resolver(
|
||
"plugins",
|
||
plugin_resolver,
|
||
)
|
||
|
||
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||
PluginManager.handle_config_changed,
|
||
_build_event({"PLUGIN_AUTO_RELOAD"}),
|
||
)
|
||
|
||
plugin_resolver.assert_not_called()
|
||
reload_config.assert_called_once_with()
|
||
|
||
|
||
def test_config_reload_resolver_registration_replaces_same_name(monkeypatch):
|
||
"""
|
||
重复装配只替换当前 lifespan resolver,不累积并行绑定。
|
||
"""
|
||
monkeypatch.setattr(
|
||
eventmanager,
|
||
"_EventManager__handler_instance_resolvers",
|
||
{},
|
||
)
|
||
|
||
configure_config_reload_event_handler_resolver()
|
||
first = eventmanager._EventManager__handler_instance_resolvers[
|
||
"config_reload"
|
||
]
|
||
configure_config_reload_event_handler_resolver()
|
||
|
||
assert set(eventmanager._EventManager__handler_instance_resolvers) == {
|
||
"config_reload"
|
||
}
|
||
assert (
|
||
eventmanager._EventManager__handler_instance_resolvers[
|
||
"config_reload"
|
||
]
|
||
is not first
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("owner_class", "registry", "singleton_key"),
|
||
[
|
||
(
|
||
PluginManager,
|
||
Singleton._instances,
|
||
(PluginManager, (), frozenset()),
|
||
),
|
||
(Monitor, SingletonClass._instances, Monitor),
|
||
],
|
||
)
|
||
def test_config_reload_provider_returns_lifecycle_owned_instance(
|
||
monkeypatch,
|
||
owner_class,
|
||
registry,
|
||
singleton_key,
|
||
):
|
||
"""
|
||
管理器和监控器必须从各自生命周期注册表读取当前实例。
|
||
"""
|
||
instance = object.__new__(owner_class)
|
||
monkeypatch.setitem(registry, singleton_key, instance)
|
||
|
||
providers = get_config_reload_handler_providers()
|
||
|
||
assert providers[owner_class]() is instance
|
||
|
||
|
||
def test_system_reload_provider_keeps_one_lifespan_instance() -> None:
|
||
"""
|
||
无资源的系统配置处理器也由 resolver 闭包保持稳定 owner 身份。
|
||
"""
|
||
provider = get_config_reload_handler_providers()[SystemHelper]
|
||
|
||
assert provider() is provider()
|