Files
MoviePilot/tests/test_uvicorn_entrypoint.py
T

138 lines
4.8 KiB
Python

"""MoviePilot Uvicorn factory、reload 与生产服务器入口测试。"""
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError
from app.startup import lifecycle
PROJECT_ROOT = Path(__file__).parents[1]
def test_create_app_does_not_start_plugin_manager_or_threads(monkeypatch):
"""ASGI factory 只构建应用结构,不得在创建阶段物化插件运行时。"""
plugin_manager = MagicMock(side_effect=AssertionError("plugin runtime started"))
monkeypatch.setattr(factory, "PluginManager", plugin_manager)
threads_before = threading.active_count()
created = factory.create_app()
assert created is not factory.app
assert threading.active_count() == threads_before
plugin_manager.assert_not_called()
def test_production_entry_uses_custom_single_process_server(monkeypatch):
"""生产单 worker 保留发布协作停止标志的自定义 Server。"""
server = MagicMock()
monkeypatch.setattr(main.settings, "DEV", False)
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
monkeypatch.setattr(main, "create_server", MagicMock(return_value=server))
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
main.run_api_server()
assert main.Server is server
server.run.assert_called_once_with()
uvicorn_run.assert_not_called()
def test_development_reload_uses_import_string_factory(monkeypatch):
"""开发 reload 必须让 Uvicorn 重新导入 factory,而不是序列化 app 实例。"""
monkeypatch.setattr(main.settings, "DEV", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
monkeypatch.setattr(main, "Server", MagicMock())
main.run_api_server()
assert main.Server is None
uvicorn_run.assert_called_once_with(
main.APP_FACTORY,
factory=True,
host=main.settings.HOST,
port=main.settings.PORT,
reload=True,
workers=1,
timeout_graceful_shutdown=60,
)
def test_safe_mode_multi_worker_uses_import_string_factory(monkeypatch):
"""安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。"""
monkeypatch.setattr(main.settings, "DEV", False)
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 2)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
main.run_api_server()
assert uvicorn_run.call_args.kwargs["factory"] is True
assert uvicorn_run.call_args.kwargs["reload"] is False
assert uvicorn_run.call_args.kwargs["workers"] == 2
def test_reload_and_multiple_workers_are_rejected_together(monkeypatch):
"""Uvicorn 不支持的 reload + workers 组合必须给出明确错误。"""
monkeypatch.setattr(main.settings, "DEV", True)
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", True)
monkeypatch.setattr(main.settings, "API_WORKERS", 2)
uvicorn_run = MagicMock()
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
with pytest.raises(UnsupportedProcessTopologyError, match="不能同时启用"):
main.run_api_server()
uvicorn_run.assert_not_called()
def test_request_shutdown_is_safe_before_server_creation(monkeypatch):
"""数据库准备或 reload supervisor 阶段收到退出请求时不依赖 Server 已创建。"""
stop_system = MagicMock()
monkeypatch.setattr(main.global_vars, "stop_system", stop_system)
monkeypatch.setattr(main, "Server", None)
main.request_shutdown()
stop_system.assert_called_once_with()
def test_production_server_preserves_shutdown_requested_before_creation(
monkeypatch,
):
"""数据库准备期间收到的停止请求必须传递给随后创建的生产 Server。"""
stop_event = threading.Event()
stop_event.set()
monkeypatch.setattr(main.global_vars, "STOP_EVENT", stop_event)
server = main.create_server()
assert server.should_exit is True
def test_local_launcher_keeps_module_entrypoint():
"""本地开发脚本继续通过 app.main 进入统一启动准备流程。"""
script = (PROJECT_ROOT / "scripts" / "start-local.sh").read_text(
encoding="utf-8"
)
assert 'exec "$VENV_PYTHON" -m app.main' in script
def test_supported_asgi_entries_share_database_lifecycle() -> None:
"""主程序、factory 与 TestClient 应通过同一 lifespan 准备数据库。"""
created = factory.create_app()
assert factory.app.router.lifespan_context is lifecycle.lifespan
assert main.app.router.lifespan_context is lifecycle.lifespan
assert created.router.lifespan_context is lifecycle.lifespan
assert "prepare_database" not in main.run_application.__code__.co_names