diff --git a/app/db/init.py b/app/db/init.py index e3d9310f4..56c0e11c0 100644 --- a/app/db/init.py +++ b/app/db/init.py @@ -1,4 +1,5 @@ from configparser import ConfigParser as _ConfigParser +import traceback from alembic.command import upgrade from alembic.config import Config @@ -38,5 +39,8 @@ def update_db(): alembic_cfg.set_main_option('sqlalchemy.url', db_url) upgrade(alembic_cfg, 'head') - except Exception as e: - logger.error(f'数据库更新失败:{str(e)}') + except Exception as error: + logger.error( + f'数据库更新失败:{str(error)} - {traceback.format_exc()}' + ) + raise diff --git a/tests/test_database_migration_startup.py b/tests/test_database_migration_startup.py new file mode 100644 index 000000000..d1dd58b61 --- /dev/null +++ b/tests/test_database_migration_startup.py @@ -0,0 +1,63 @@ +import importlib.util +from pathlib import Path +import sys +import uuid + +import pytest + +from app.db import init as db_init + + +LOCAL_SETUP_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py" +) + + +def _load_local_setup_module(): + """加载隔离的本地安装脚本实例,避免测试间共享模块状态。""" + module_name = f"moviepilot_local_setup_migration_{uuid.uuid4().hex}" + spec = importlib.util.spec_from_file_location(module_name, LOCAL_SETUP_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_update_db_preserves_migration_error_and_traceback(monkeypatch) -> None: + """迁移失败日志应保留堆栈,同时向调用方传播原始异常。""" + migration_error = RuntimeError("migration failed") + logged_errors: list[str] = [] + + def fail_upgrade(*_args, **_kwargs) -> None: + raise migration_error + + monkeypatch.setattr(db_init, "upgrade", fail_upgrade) + monkeypatch.setattr(db_init.logger, "error", logged_errors.append) + + with pytest.raises(RuntimeError) as raised: + db_init.update_db() + + assert raised.value is migration_error + assert len(logged_errors) == 1 + assert "数据库更新失败:migration failed" in logged_errors[0] + assert "RuntimeError: migration failed" in logged_errors[0] + + +def test_local_setup_returns_failure_when_database_migration_fails( + monkeypatch, + capsys, +) -> None: + """本地维护命令不得在迁移失败后继续访问业务表。""" + module = _load_local_setup_module() + migration_error = RuntimeError("migration failed") + + def fail_sync() -> None: + raise migration_error + + monkeypatch.setattr(sys, "argv", [str(LOCAL_SETUP_PATH), "sync-superuser"]) + monkeypatch.setattr(module, "_resolve_interactive_config_dir", lambda *_: None) + monkeypatch.setattr(module, "configure_config_dir", lambda **_: Path("config")) + monkeypatch.setattr(module, "_sync_superuser_account_inner", fail_sync) + + assert module.main() == 1 + assert "migration failed" in capsys.readouterr().err diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index cd90b128c..9d052706a 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -261,3 +261,16 @@ def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> N assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=08 无效" not in output assert "MoviePilot Web 已可访问" in output + + +def test_backend_failure_keepalive_contract_is_explicit() -> None: + """后端异常默认保活诊断,显式关闭后才退出容器。""" + content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + function = content.split( + "function diagnostic_keepalive() {", 1 + )[1].split("\n}", 1)[0] + + assert 'MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true' in function + assert 'if [ "${keepalive}" = "false" ]' in function + assert 'graceful_exit "$exit_code" "python_exit"' in function + assert "容器将保持运行以便执行 moviepilot doctor" in function diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 34b9c7d41..25f88bc13 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -154,6 +154,25 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch): ] +def test_application_does_not_start_server_after_migration_failure(monkeypatch): + """数据库迁移失败时不得启动 API 服务。""" + from app import main + + migration_error = RuntimeError("migration failed") + server_run = MagicMock() + monkeypatch.setattr(main.signal, "signal", MagicMock()) + monkeypatch.setattr(main, "start_tray", MagicMock()) + monkeypatch.setattr(main, "init_db", MagicMock()) + monkeypatch.setattr(main, "update_db", MagicMock(side_effect=migration_error)) + monkeypatch.setattr(main.Server, "run", server_run) + + with pytest.raises(RuntimeError) as raised: + main.run_application() + + assert raised.value is migration_error + server_run.assert_not_called() + + def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch): """Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求""" from app import main