Files
MoviePilot/tests/test_database_backup_scheduler.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

319 lines
12 KiB
Python

"""
数据库备份与宿主调度器的接入合同。
"""
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