import asyncio import threading from unittest.mock import AsyncMock, Mock import pytest from app import scheduler as scheduler_module from app.scheduler import Scheduler from app.application.configuration import SchedulerRuntimeConfig 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_clear_cache_is_manual_only(monkeypatch): """缓存清理任务应仅手动执行,不注册到调度器自动运行。""" background_scheduler = _BackgroundSchedulerStub() generic_chain = Mock() for name in [ "MediaServerChain", "RecommendChain", "SchedulerChain", "SiteChain", "SubscribeChain", "TransferChain", "WallpaperHelper", "WorkflowChain", "get_plugin_manager", ]: monkeypatch.setattr(scheduler_module, name, lambda: generic_chain) monkeypatch.setattr( scheduler_module, "get_mediaserver_configs", lambda **_kwargs: [], ) monkeypatch.setattr( scheduler_module, "BackgroundScheduler", lambda **kwargs: 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_module, "get_scheduler_runtime_config", lambda: SchedulerRuntimeConfig( False, "Asia/Shanghai", 1, False, "", 0, None, False, 24, "rss", 30, False, 0, 0, False, None, False, None, ), ) scheduler = object.__new__(Scheduler) scheduler._scheduler = None scheduler._event = threading.Event() scheduler._lock = threading.RLock() scheduler._jobs = {} scheduler._lifecycle_state = "new" scheduler._handles = {} scheduler._job_generations = {} scheduler._active_job_generations = {} scheduler._agent_task_reservations = {} scheduler._agent_task_interruptions_reconciled = True scheduler._auth_count = 0 scheduler._auth_message = False 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 background_scheduler.started is True 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_module, "get_scheduler_runtime_config", lambda: Mock(site_link="https://example.invalid"), ) monkeypatch.setattr( scheduler_module, "SitesHelper", lambda: Mock(auth_level=0, check_user=Mock(return_value=(True, "demo"))), ) monkeypatch.setattr( scheduler_module, "get_configured_system_config", lambda: Mock(get=Mock(return_value=None)), ) monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain) monkeypatch.setattr( scheduler_module, "get_plugin_manager", lambda: plugin_manager, ) monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs) monkeypatch.setattr(scheduler_module, "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_module, "get_scheduler_runtime_config", lambda: Mock(site_link="https://example.invalid"), ) monkeypatch.setattr(scheduler_module, "SitesHelper", lambda: sites) monkeypatch.setattr( scheduler_module, "get_configured_system_config", lambda: Mock(get=Mock(return_value=None)), ) monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain) monkeypatch.setattr( scheduler_module, "get_plugin_manager", lambda: plugin_manager, ) monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs) monkeypatch.setattr(scheduler_module, "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()