""" 数据库备份与宿主调度器的接入合同。 """ import ast import threading from dataclasses import replace from pathlib import Path from unittest.mock import Mock import pytest from app.application.configuration import SchedulerRuntimeConfig from app.scheduler import catalog as scheduler_catalog from app.scheduler import maintenance as scheduler_maintenance from app.scheduler.facade import Scheduler from app.scheduler.registry import ExecutionRegistry class _SchedulerStub: """ 记录 Scheduler 注册结果的最小调度器替身。 """ def __init__(self) -> None: """ 初始化空作业表。 """ self.jobs = {} def add_job(self, func, *, trigger, id, **kwargs) -> None: """ 按作业 ID 保存最近一次注册参数。 """ self.jobs[id] = {"func": func, "trigger": trigger, **kwargs} def _scheduler() -> Scheduler: """ 构造不启动后台线程的 Scheduler。 """ scheduler = object.__new__(Scheduler) scheduler._scheduler = _SchedulerStub() scheduler._jobs = {} scheduler._lock = threading.RLock() scheduler._lifecycle_state = "running" scheduler._registry = ExecutionRegistry(scheduler._lock) return scheduler def _config(**changes) -> SchedulerRuntimeConfig: """ 构造数据库备份测试所需的最小调度配置快照。 """ config = SchedulerRuntimeConfig( dev=False, timezone="Asia/Shanghai", scheduler_workers=1, db_backup_enable=False, db_backup_cron="", cookiecloud_interval=None, mediaserver_sync_interval=None, subscribe_search=False, subscribe_search_interval=24, subscribe_mode="rss", subscribe_rss_interval=30, data_cleanup_enable=False, sitedata_refresh_interval=None, memory_gc_interval=None, ai_agent_enable=False, ai_agent_job_interval=None, usage_statistic_share=False, site_link=None, auto_update=False, auto_update_resource=False, ) return replace(config, **changes) def test_database_backup_schedule_only_watches_job_shape() -> None: """ 备份调度只监听影响任务开关与时间表达式的配置。 """ assert Scheduler.CONFIG_WATCH.intersection({ "DB_BACKUP_ENABLE", "DB_BACKUP_CRON", "DB_BACKUP_ON_UPGRADE", "DB_BACKUP_PATH", "DB_BACKUP_RETENTION_DAYS", "DB_BACKUP_MAX_COUNT", }) == {"DB_BACKUP_ENABLE", "DB_BACKUP_CRON"} def test_auto_update_setting_is_hot_reloadable() -> None: """ 主程序或资源开关变更时均应触发 Scheduler 重建。 """ assert "MOVIEPILOT_AUTO_UPDATE" in Scheduler.CONFIG_WATCH assert "AUTO_UPDATE_RESOURCE" in Scheduler.CONFIG_WATCH def test_wallpaper_settings_are_hot_reloadable() -> None: """ 壁纸来源或地址变化时必须重建调度器并清理缓存。 """ assert Scheduler.CONFIG_WATCH.issuperset({ "WALLPAPER", "WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL", }) @pytest.mark.parametrize("wallpaper", ["", "tmdb"]) def test_wallpaper_schedule_follows_wallpaper_setting(monkeypatch, wallpaper) -> None: """ 无壁纸时不注册缓存任务,启用来源后恢复注册。 """ scheduler = _scheduler() scheduler._services = Mock() background_scheduler = Mock() monkeypatch.setattr(scheduler_catalog, "BackgroundScheduler", lambda **_kwargs: background_scheduler) monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock()) monkeypatch.setattr(scheduler_catalog, "get_mediaserver_configs", lambda **_kwargs: []) monkeypatch.setattr(scheduler, "init_workflow_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) scheduler._initialize_catalog(_config(wallpaper=wallpaper)) registered = wallpaper != "" assert ("random_wallpager" in scheduler._jobs) is registered assert any( call.kwargs.get("id") == "random_wallpager" for call in background_scheduler.add_job.call_args_list ) is registered def test_disabled_database_backup_does_not_register_job() -> None: """ 关闭备份时不注册作业。 """ scheduler = _scheduler() scheduler._register_database_backup_job(_config()) assert scheduler._scheduler.jobs == {} def test_enabled_database_backup_without_cron_does_not_register_job() -> None: """ 总开关开启但未配置周期时,不启用定时备份。 """ scheduler = _scheduler() scheduler._register_database_backup_job(_config(db_backup_enable=True)) assert scheduler._scheduler.jobs == {} def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None: """ 重复注册备份调度时替换同名任务,保持单一任务实例。 """ scheduler = _scheduler() trigger = object() monkeypatch.setattr(scheduler_catalog.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger)) config = _config(db_backup_enable=True, db_backup_cron="0 3 * * *") scheduler._register_database_backup_job(config) scheduler._register_database_backup_job(config) assert list(scheduler._scheduler.jobs) == ["database_backup"] assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True @pytest.mark.parametrize("auto_update", [False, True]) @pytest.mark.parametrize("auto_update_resource", [False, True]) def test_auto_update_check_is_registered_only_when_enabled( monkeypatch, auto_update, auto_update_resource ) -> None: """ 任一开关开启即注册检查任务,均关闭则不注册。 """ scheduler = _scheduler() scheduler._services = Mock() background_scheduler = Mock() monkeypatch.setattr(scheduler_catalog, "BackgroundScheduler", lambda **_kwargs: background_scheduler) monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock()) monkeypatch.setattr(scheduler_catalog, "get_mediaserver_configs", lambda **_kwargs: []) monkeypatch.setattr(scheduler, "init_workflow_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) scheduler_catalog.SchedulerCatalogOwner._initialize_catalog( scheduler, _config(auto_update=auto_update, auto_update_resource=auto_update_resource) ) assert any( call.kwargs.get("id") == "system_update_check" for call in background_scheduler.add_job.call_args_list ) is (auto_update or auto_update_resource) assert ("system_update_check" in scheduler._jobs) is (auto_update or auto_update_resource) def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None: """ 定时备份使用已注册的数据库治理实例。 """ governance = Mock() monkeypatch.setattr(scheduler_maintenance, "get_database_governance", lambda: governance) result = Scheduler.database_backup() assert result is governance.create_backup.return_value governance.create_backup.assert_called_once_with() @pytest.mark.parametrize("enabled", [False, True]) def test_subscription_search_scans_due_items_only_when_enabled(monkeypatch, enabled) -> None: """ 系统开关控制到期扫描,五分钟扫描节奏与实际搜索间隔分别传递。 """ scheduler = _scheduler() scheduler._services = Mock() background_scheduler = Mock() monkeypatch.setattr(scheduler_catalog, "BackgroundScheduler", lambda **_kwargs: background_scheduler) monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock()) monkeypatch.setattr(scheduler_catalog, "get_mediaserver_configs", lambda **_kwargs: []) monkeypatch.setattr(scheduler, "init_workflow_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) scheduler._initialize_catalog(_config(subscribe_search=enabled, subscribe_search_interval=48)) calls = [call for call in background_scheduler.add_job.call_args_list if call.kwargs.get("id") == "subscribe_search"] assert bool(calls) is enabled if enabled: assert calls[0].kwargs["minutes"] == 5 assert scheduler._jobs["subscribe_search"]["kwargs"]["scheduled_interval"] == 48 def test_scheduler_database_dependencies_are_explicit_module_imports() -> None: """ 调度器数据库依赖必须在模块层显式导入,不隐藏在函数内。 """ scheduler_root = Path(__file__).parents[1] / "app" / "scheduler" trees = [ast.parse(path.read_text(encoding="utf-8")) for path in scheduler_root.glob("*.py")] function_imports = [ node for tree in trees for function in ast.walk(tree) if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) for node in ast.walk(function) if isinstance(node, (ast.Import, ast.ImportFrom)) and getattr(node, "module", "") and str(getattr(node, "module", "")).startswith("app.application.database") ] assert function_imports == [] def test_cookiecloud_credentials_are_hot_reloadable() -> None: """ 补全或清空 CookieCloud 凭据后必须重建定时同步任务。 """ assert Scheduler.CONFIG_WATCH.issuperset({ "COOKIECLOUD_INTERVAL", "COOKIECLOUD_HOST", "COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "COOKIECLOUD_ENABLE_LOCAL", }) @pytest.mark.parametrize("configured", [False, True]) def test_cookiecloud_schedule_requires_configuration(monkeypatch, configured) -> None: """ 未配置 CookieCloud 时只保留手动入口,不注册定时同步。 """ scheduler = _scheduler() scheduler._services = Mock() background_scheduler = Mock() monkeypatch.setattr(scheduler_catalog, "BackgroundScheduler", lambda **_kwargs: background_scheduler) monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock()) monkeypatch.setattr(scheduler_catalog, "get_mediaserver_configs", lambda **_kwargs: []) monkeypatch.setattr(scheduler, "init_workflow_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) scheduler._initialize_catalog( _config(cookiecloud_interval=1440, cookiecloud_configured=configured) ) assert "cookiecloud" in scheduler._jobs assert any( call.kwargs.get("id") == "cookiecloud" for call in background_scheduler.add_job.call_args_list ) is configured @pytest.mark.parametrize( ("values", "expected"), [ ({}, False), ({"COOKIECLOUD_KEY": "uuid"}, False), ({"COOKIECLOUD_KEY": " ", "COOKIECLOUD_PASSWORD": "pwd"}, False), ({"COOKIECLOUD_KEY": "uuid", "COOKIECLOUD_PASSWORD": "pwd"}, True), ({"COOKIECLOUD_KEY": "uuid", "COOKIECLOUD_PASSWORD": "pwd", "COOKIECLOUD_HOST": ""}, False), ({"COOKIECLOUD_KEY": "uuid", "COOKIECLOUD_PASSWORD": "pwd", "COOKIECLOUD_HOST": "", "COOKIECLOUD_ENABLE_LOCAL": True}, True), ], ) def test_scheduler_config_reports_cookiecloud_configuration(values, expected) -> None: """ 调度快照与下载入口使用相同的必要参数判断。 """ from app.runtime.config import Settings from app.startup.composition.configuration import build_scheduler_runtime_config assert build_scheduler_runtime_config(Settings(**values)).cookiecloud_configured is expected