mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 12:36:55 +08:00
138 lines
4.8 KiB
Python
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
|