mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix(database): fail startup on migration errors (#6279)
This commit is contained in:
+6
-2
@@ -1,4 +1,5 @@
|
|||||||
from configparser import ConfigParser as _ConfigParser
|
from configparser import ConfigParser as _ConfigParser
|
||||||
|
import traceback
|
||||||
|
|
||||||
from alembic.command import upgrade
|
from alembic.command import upgrade
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
@@ -38,5 +39,8 @@ def update_db():
|
|||||||
|
|
||||||
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
||||||
upgrade(alembic_cfg, 'head')
|
upgrade(alembic_cfg, 'head')
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
logger.error(f'数据库更新失败:{str(e)}')
|
logger.error(
|
||||||
|
f'数据库更新失败:{str(error)} - {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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_BACKEND_READY_TIMEOUT=08 无效" not in output
|
||||||
assert "MoviePilot Web 已可访问" 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
|
||||||
|
|||||||
@@ -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):
|
def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
|
||||||
"""Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求"""
|
"""Uvicorn 启动不能清除数据库初始化阶段已经发布的停止请求"""
|
||||||
from app import main
|
from app import main
|
||||||
|
|||||||
Reference in New Issue
Block a user