"""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