Files
MoviePilot/tests/test_scheduler_cache_expiry.py

276 lines
9.3 KiB
Python
Raw Permalink 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.
"""
验证调度器缓存有效期与过期后刷新。
"""
import asyncio
import threading
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.configuration import SchedulerRuntimeConfig
from app.scheduler import catalog as scheduler_catalog
from app.scheduler import lifecycle as scheduler_lifecycle
from app.scheduler import reconcile as scheduler_reconcile
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
from app.startup.initializers import scheduler as scheduler_initializer
class _BackgroundSchedulerStub:
"""
记录系统定时任务注册结果的调度器替身。
"""
def __init__(self):
"""
初始化任务记录。
"""
self.jobs = []
self.started = False
def add_job(self, func, trigger, **kwargs):
"""
记录一次任务注册。
"""
self.jobs.append({"func": func, "trigger": trigger, **kwargs})
def start(self):
"""
记录调度器已启动。
"""
self.started = True
def test_scheduler_constructor_does_not_start_background_jobs(monkeypatch):
"""
取得调度器单例不应绕过应用生命周期启动后台任务。
"""
init = Mock()
monkeypatch.setattr(Scheduler, "init", init)
scheduler = object.__new__(Scheduler)
scheduler.__init__()
init.assert_not_called()
assert scheduler._scheduler is None
def test_scheduler_initializer_starts_background_jobs(monkeypatch):
"""
应用启动入口负责显式启动已经构造的调度器。
"""
scheduler = Mock()
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
scheduler_initializer.init_scheduler()
scheduler.init.assert_called_once_with()
def test_scheduler_initializer_stop_preserves_sync_abi(monkeypatch):
"""
同步调用停止入口仍应立即关闭 Scheduler,不返回 coroutine。
"""
scheduler = Mock()
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
assert scheduler_initializer.stop_scheduler() is None
scheduler.stop.assert_called_once_with()
scheduler.stop_async.assert_not_called()
def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
"""
生命周期事件循环中的停止入口应返回可等待的异步收口。
"""
scheduler = Mock()
scheduler.stop_async = AsyncMock()
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
async def scenario():
result = scheduler_initializer.stop_scheduler()
assert result is not None
await result
asyncio.run(scenario())
scheduler.stop_async.assert_awaited_once_with()
scheduler.stop.assert_not_called()
def test_scheduler_initializer_retains_bindings_when_async_stop_fails(monkeypatch):
"""
Scheduler 未停止时不得撤销仓储、业务能力或 concrete class 登记。
"""
scheduler = Mock()
scheduler.stop_async = AsyncMock(side_effect=RuntimeError("scheduler busy"))
reset_bindings = Mock()
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
monkeypatch.setattr(
scheduler_initializer,
"reset_scheduler_bindings",
reset_bindings,
)
async def scenario() -> None:
"""
等待异步关闭失败并验证 reset 未被调用。
"""
result = scheduler_initializer.stop_scheduler()
assert result is not None
with pytest.raises(RuntimeError, match="scheduler busy"):
await result
asyncio.run(scenario())
reset_bindings.assert_not_called()
def test_clear_cache_is_manual_only(monkeypatch):
"""
缓存清理任务应仅手动执行,不注册到调度器自动运行。
"""
background_scheduler = _BackgroundSchedulerStub()
executor_groups = {}
services = Mock()
monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock())
monkeypatch.setattr(
scheduler_catalog,
"get_mediaserver_configs",
lambda **_kwargs: [],
)
monkeypatch.setattr(
scheduler_catalog,
"BackgroundScheduler",
lambda **kwargs: executor_groups.update(kwargs["executors"]) or background_scheduler,
)
monkeypatch.setattr(Scheduler, "stop", lambda self: None)
monkeypatch.setattr(Scheduler, "init_workflow_jobs", lambda self: None)
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
monkeypatch.setattr(
scheduler_lifecycle,
"get_scheduler_runtime_config",
lambda: SchedulerRuntimeConfig(
False, "Asia/Shanghai", 1, False, "", 0, None, False, 24,
"rss", 30, False, 0, 0, True, 1, False, None,
),
)
scheduler = object.__new__(Scheduler)
scheduler._scheduler = None
scheduler._event = threading.Event()
scheduler._lock = threading.RLock()
scheduler._jobs = {}
scheduler._lifecycle_state = "new"
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler._agent_task_interruptions_reconciled = True
scheduler._auth_count = 0
scheduler._auth_message = False
scheduler._services = services
scheduler.init()
scheduled_job_ids = {job["id"] for job in background_scheduler.jobs}
assert "clear_cache" not in scheduled_job_ids
assert "clear_cache" in scheduler._jobs
assert scheduler._jobs["clear_cache"]["manual"] is True
assert scheduler._jobs["outbox_dispatch"]["name"] == "重试未完成的后台处理"
outbox_job = next(job for job in background_scheduler.jobs if job["id"] == "outbox_dispatch")
assert outbox_job["name"] == "重试未完成的后台处理"
assert background_scheduler.started is True
assert set(executor_groups) == {"default", "agent"}
heartbeat = next(job for job in background_scheduler.jobs if job["id"] == "agent_heartbeat")
assert heartbeat["executor"] == "agent"
assert scheduler._jobs["agent_heartbeat"]["execution_group"] == "agent"
def test_user_auth_refreshes_plugin_routes_after_runtime_reinitialization(monkeypatch):
"""
自动认证重建插件实例后必须同步刷新动态路由投影。
"""
scheduler = object.__new__(Scheduler)
scheduler._auth_count = 1
scheduler._auth_message = False
scheduler._auth_plugin_routes_pending = False
plugin_manager = Mock()
plugin_jobs = Mock()
refresh_routes = Mock()
message_chain = Mock()
monkeypatch.setattr(
scheduler_reconcile,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(
scheduler_reconcile,
"SitesHelper",
lambda: Mock(auth_level=0, check_user=Mock(return_value=(True, "demo"))),
)
monkeypatch.setattr(
scheduler_reconcile,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
scheduler._services = Mock(post_message=message_chain.post_message)
monkeypatch.setattr(
scheduler_reconcile,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
scheduler.user_auth()
plugin_manager.init_config.assert_called_once_with()
plugin_jobs.assert_called_once_with()
refresh_routes.assert_called_once_with()
assert scheduler._auth_plugin_routes_pending is False
def test_user_auth_retries_pending_plugin_route_projection(monkeypatch):
"""
认证已成功但路由投影失败时,后续认证任务只重试未完成的投影。
"""
scheduler = object.__new__(Scheduler)
scheduler._auth_count = 1
scheduler._auth_message = False
scheduler._auth_plugin_routes_pending = False
plugin_manager = Mock()
plugin_jobs = Mock()
refresh_routes = Mock(side_effect=[RuntimeError("loop unavailable"), None])
message_chain = Mock()
sites = Mock(auth_level=0, check_user=Mock(return_value=(True, "demo")))
monkeypatch.setattr(
scheduler_reconcile,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(scheduler_reconcile, "SitesHelper", lambda: sites)
monkeypatch.setattr(
scheduler_reconcile,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
scheduler._services = Mock(post_message=message_chain.post_message)
monkeypatch.setattr(
scheduler_reconcile,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
with pytest.raises(RuntimeError, match="loop unavailable"):
scheduler.user_auth()
assert scheduler._auth_plugin_routes_pending is True
sites.auth_level = 2
scheduler.user_auth()
assert scheduler._auth_plugin_routes_pending is False
assert refresh_routes.call_count == 2
plugin_manager.init_config.assert_called_once_with()
plugin_jobs.assert_called_once_with()
sites.check_user.assert_called_once_with()