feat: 数据库迁移前自动备份 (#6360)

This commit is contained in:
InfinityPacer
2026-08-19 18:35:56 +08:00
committed by GitHub
parent f700db5ac8
commit 2b3766e86e
12 changed files with 495 additions and 58 deletions
+1
View File
@@ -90,6 +90,7 @@ _LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_DATABASE_BACKUP_SETTING_KEYS = {
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
"DB_BACKUP_ON_UPGRADE",
"DB_BACKUP_PATH",
"DB_BACKUP_RETENTION_DAYS",
"DB_BACKUP_MAX_COUNT",
+2 -13
View File
@@ -38,7 +38,6 @@ from uvicorn import Config
from app.adapters.system.stdio import configure_rotating_stdio
from app.adapters.system.host import SystemUtils
# 禁用输出
stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE")
if stdio_log_file:
# 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。
@@ -56,9 +55,8 @@ elif SystemUtils.is_frozen():
from app.factory import app
from app.runtime.config import global_vars, settings
from app.startup.database_initializer import init_db, update_db
from app.startup.database_initializer import prepare_database
# 设置进程名
setproctitle.setproctitle(settings.PROJECT_NAME)
@@ -70,7 +68,6 @@ class MoviePilotServer(uvicorn.Server):
super().handle_exit(sig, frame)
# uvicorn服务
Server = MoviePilotServer(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=settings.API_WORKERS,
timeout_graceful_shutdown=60))
@@ -109,7 +106,6 @@ def start_tray():
import pystray
# 托盘图标
TrayIcon = pystray.Icon(
settings.PROJECT_NAME,
icon=Image.open(settings.ROOT_PATH / 'app.ico'),
@@ -124,7 +120,6 @@ def start_tray():
)
)
)
# 启动托盘图标
threading.Thread(target=TrayIcon.run, daemon=True).start()
@@ -138,17 +133,11 @@ def signal_handler(signum, frame):
def run_application() -> None:
"""初始化进程并启动 API 服务"""
# 注册信号处理器
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# 启动托盘
start_tray()
# 初始化数据库
init_db()
# 更新数据库
update_db()
# 启动API服务
prepare_database()
Server.run()
+2
View File
@@ -194,6 +194,8 @@ class ConfigModel(BaseModel):
DB_BACKUP_ENABLE: bool = False
# 定时备份的 Cron 表达式,留空时不注册定时任务
DB_BACKUP_CRON: str = "0 3 * * *"
# 检测到现有数据库需要迁移时,在结构变更前创建恢复点
DB_BACKUP_ON_UPGRADE: bool = True
# 备份根目录;未配置时使用 CONFIG_PATH/database_backup
DB_BACKUP_PATH: Optional[str] = None
# 本地备份的保留天数,0 表示不按时间清理
-2
View File
@@ -348,13 +348,11 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
},
}
# 创建定时服务
self._scheduler = BackgroundScheduler(
timezone=settings.TZ,
executors={"default": ThreadPoolExecutor(settings.CONF.scheduler)},
)
# 数据库备份复用宿主调度器,不创建独立定时线程。
self._register_database_backup_job()
# CookieCloud定时同步
+114 -18
View File
@@ -1,24 +1,130 @@
from collections.abc import Callable
from configparser import ConfigParser as _ConfigParser
import traceback
from alembic.command import upgrade
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from alembic.util import CommandError
from sqlalchemy import inspect
from sqlalchemy.engine import Engine
from app.runtime.config import settings
from app.db import Base
from app.db.engine import get_engine
from app.db.models import load_all_models
from app.runtime.log import logger
from app.startup.database import build_database_governance
def _build_alembic_config(engine: Engine | None = None) -> Config:
"""构造与应用活动数据库一致的 Alembic 配置。"""
engine = engine or get_engine()
alembic_cfg = Config()
alembic_cfg.file_config = _ConfigParser(interpolation=None)
alembic_cfg.set_main_option(
'script_location',
str(settings.ROOT_PATH / 'database'),
)
alembic_cfg.set_main_option(
'sqlalchemy.url',
engine.url.render_as_string(hide_password=False),
)
return alembic_cfg
def _migration_state(
engine: Engine,
alembic_cfg: Config,
) -> tuple[bool, tuple[str, ...], tuple[str, ...]]:
"""读取数据库迁移状态,并在结构写入前校验版本链。"""
script = ScriptDirectory.from_config(alembic_cfg)
target_heads = tuple(script.get_heads())
with engine.connect() as connection:
table_names = set(inspect(connection).get_table_names())
current_heads = tuple(
MigrationContext.configure(connection).get_current_heads()
)
has_existing_database = bool(table_names - {'alembic_version'})
_validate_migration_lineage(script, current_heads, target_heads)
return has_existing_database, current_heads, target_heads
def _validate_migration_lineage(
script: ScriptDirectory,
current_heads: tuple[str, ...],
target_heads: tuple[str, ...],
) -> None:
"""拒绝无法沿当前迁移链安全升级的数据库版本。"""
if len(target_heads) != 1:
raise RuntimeError(
f"数据库迁移脚本必须只有一个 head,当前为 {target_heads}"
)
if len(current_heads) > 1:
raise RuntimeError(
f"数据库存在多个 current revision,无法自动迁移:{current_heads}"
)
if not current_heads:
return
current = current_heads[0]
target = target_heads[0]
try:
script.get_revision(current)
except CommandError as error:
raise RuntimeError(
f"当前 MoviePilot 无法识别数据库 revision{current}"
) from error
if current == target:
return
ancestors = {
revision.revision
for revision in script.walk_revisions(base='base', head=target)
}
if current not in ancestors:
raise RuntimeError(
f"数据库 revision {current} 不是当前 head {target} 的可升级祖先"
)
def prepare_database(*, before_alembic: Callable[[], None] | None = None) -> None:
"""在建表或迁移前完成版本校验及可选备份。"""
engine = get_engine()
alembic_cfg = _build_alembic_config(engine)
has_existing_database, current_heads, target_heads = _migration_state(
engine,
alembic_cfg,
)
requires_migration = (
has_existing_database
and set(current_heads) != set(target_heads)
)
if (
requires_migration
and settings.DB_BACKUP_ENABLE
and settings.DB_BACKUP_ON_UPGRADE
):
current_version = current_heads[0] if current_heads else "未标记"
target_version = target_heads[0]
logger.info(
f"数据库需要从版本 {current_version} 升级到 {target_version}"
"正在创建迁移前备份"
)
build_database_governance().create_backup()
init_db()
if before_alembic:
# 首次初始化需要先建立用户表,再把管理员密码交给 Alembic 基础迁移消费。
before_alembic()
update_db(alembic_cfg)
def init_db():
"""
初始化数据库
"""
# 函数内导入而非模块级:写成模块级会让 import 本模块的一方也被迫拉起引擎模块。
# 引擎一律用 get_engine() 取——旧名字 `app.db.Engine` 只为仓库外插件保留,且它一经
# 属性访问就把引擎建出来,模块级写法会使本模块反过来依赖「数据库已在别处初始化完成」。
from app.db.engine import get_engine
# 确保所有模型都已注册到 Base.metadata 中
load_all_models()
@@ -26,25 +132,15 @@ def init_db():
Base.metadata.create_all(bind=get_engine())
def update_db():
def update_db(alembic_cfg: Config | None = None):
"""
更新数据库
"""
script_location = settings.ROOT_PATH / 'database'
try:
alembic_cfg = Config()
alembic_cfg.file_config = _ConfigParser(interpolation=None)
alembic_cfg.set_main_option('script_location', str(script_location))
# 与引擎构建使用同一套 URL 推导:两处各自拼接会在配置变更时悄悄漂移,
# 导致迁移连到与应用不同的库上
db_url = settings.DB_SQLITE_URL() if settings.DB_TYPE.lower() != "postgresql" \
else settings.DB_POSTGRESQL_URL()
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
alembic_cfg = alembic_cfg or _build_alembic_config()
upgrade(alembic_cfg, 'head')
except Exception as error:
logger.error(
f'数据库更新失败:{str(error)} - {traceback.format_exc()}'
f"数据库更新失败:{error}\n{traceback.format_exc()}"
)
raise
+18 -11
View File
@@ -2408,7 +2408,7 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
sys.path.insert(0, str(ROOT))
try:
from app.startup.database_initializer import init_db, update_db
from app.startup.database_initializer import prepare_database
from app.db.oper.systemconfig import SystemConfigOper
from app.schemas.types import SystemConfigKey
except ModuleNotFoundError as exc:
@@ -2416,9 +2416,13 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
) from exc
init_db()
generated_password = _prepare_superuser_password_for_bootstrap()
update_db()
generated_password = None
def prepare_superuser_password() -> None:
nonlocal generated_password
generated_password = _prepare_superuser_password_for_bootstrap()
prepare_database(before_alembic=prepare_superuser_password)
_ensure_superuser_account_inner()
if generated_password:
print_step(f"超级管理员初始密码:{generated_password}")
@@ -2571,15 +2575,19 @@ def _sync_superuser_account_inner() -> None:
sys.path.insert(0, str(ROOT))
try:
from app.startup.database_initializer import init_db, update_db
from app.startup.database_initializer import prepare_database
except ModuleNotFoundError as exc:
raise RuntimeError(
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
) from exc
init_db()
generated_password = _prepare_superuser_password_for_bootstrap()
update_db()
generated_password = None
def prepare_superuser_password() -> None:
nonlocal generated_password
generated_password = _prepare_superuser_password_for_bootstrap()
prepare_database(before_alembic=prepare_superuser_password)
_ensure_superuser_account_inner()
if generated_password:
print_step(f"超级管理员初始密码:{generated_password}")
@@ -3671,7 +3679,7 @@ def run_agent_request(
sys.path.insert(0, str(ROOT))
try:
from app.startup.database_initializer import init_db, update_db
from app.startup.database_initializer import prepare_database
from app.agent import MoviePilotAgent
from app.runtime.config import settings
except ModuleNotFoundError as exc:
@@ -3682,8 +3690,7 @@ def run_agent_request(
if not settings.AI_AGENT_ENABLE:
raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE")
init_db()
update_db()
prepare_database()
session = (session_id or "").strip()
if new_session or not session:
+4 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6059,
"edge_sha256": "85ab4dd2f01f48bae417f0401f272763f15ba7256b58ca0ab5a8256f4946c2f9",
"edge_count": 6061,
"edge_sha256": "022ba984b711776539b4dc120d0f2e92645f61b28b59dd6be3c32a64499d08ce",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -5705,6 +5705,8 @@
"app.startup.database_initializer -> app.runtime",
"app.startup.database_initializer -> app.runtime.config",
"app.startup.database_initializer -> app.runtime.log",
"app.startup.database_initializer -> app.startup",
"app.startup.database_initializer -> app.startup.database",
"app.startup.domain_initializer -> app.adapters",
"app.startup.domain_initializer -> app.adapters.system",
"app.startup.domain_initializer -> app.adapters.system.rust",
+6 -6
View File
@@ -1210,19 +1210,19 @@
},
{
"caller": "app.api.endpoints.system",
"line": 859
"line": 860
},
{
"caller": "app.api.endpoints.system",
"line": 973
"line": 974
},
{
"caller": "app.api.endpoints.system",
"line": 1027
"line": 1028
},
{
"caller": "app.api.endpoints.system",
"line": 1041
"line": 1042
},
{
"caller": "app.chain._messaging",
@@ -1652,7 +1652,7 @@
"consumers": [
{
"caller": "app.scheduler",
"line": 1150
"line": 1148
}
],
"producers": []
@@ -1813,7 +1813,7 @@
},
{
"caller": "app.scheduler",
"line": 770
"line": 768
}
]
},
+1
View File
@@ -27,6 +27,7 @@ def test_database_backup_schedule_only_watches_job_shape() -> None:
assert Scheduler.CONFIG_WATCH.intersection({
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
"DB_BACKUP_ON_UPGRADE",
"DB_BACKUP_PATH",
"DB_BACKUP_RETENTION_DAYS",
"DB_BACKUP_MAX_COUNT",
+334
View File
@@ -1,11 +1,17 @@
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import Mock
import uuid
import pytest
from alembic.util import CommandError
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text
from sqlalchemy.engine.url import make_url
from app.startup import database_initializer as db_init
from app.startup import database as startup_database
LOCAL_SETUP_PATH = (
@@ -43,6 +49,334 @@ def test_update_db_preserves_migration_error_and_traceback(monkeypatch) -> None:
assert "RuntimeError: migration failed" in logged_errors[0]
def test_prepare_database_creates_backup_before_schema_changes(monkeypatch) -> None:
"""既有数据库待迁移时,恢复点必须早于所有结构写入。"""
calls: list[str] = []
logged_messages: list[str] = []
governance = Mock()
governance.create_backup.side_effect = lambda: calls.append("backup")
monkeypatch.setattr(db_init, "get_engine", lambda: object())
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _engine: object())
monkeypatch.setattr(
db_init,
"_migration_state",
lambda *_: (True, ("old",), ("head",)),
)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ENABLE", True)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ON_UPGRADE", True)
monkeypatch.setattr(
db_init,
"build_database_governance",
lambda: governance,
)
monkeypatch.setattr(db_init.logger, "info", logged_messages.append)
monkeypatch.setattr(db_init, "init_db", lambda: calls.append("create_all"))
monkeypatch.setattr(
db_init,
"update_db",
lambda _config: calls.append("alembic"),
)
db_init.prepare_database(before_alembic=lambda: calls.append("before_alembic"))
assert calls == ["backup", "create_all", "before_alembic", "alembic"]
assert logged_messages == [
"数据库需要从版本 old 升级到 head,正在创建迁移前备份"
]
@pytest.mark.parametrize(
("has_existing_database", "current_heads", "backup_enabled", "upgrade_enabled"),
(
(False, (), True, True),
(True, ("head",), True, True),
(True, ("old",), False, True),
(True, ("old",), True, False),
),
)
def test_prepare_database_skips_backup_outside_enabled_pending_migration(
monkeypatch,
has_existing_database: bool,
current_heads: tuple[str, ...],
backup_enabled: bool,
upgrade_enabled: bool,
) -> None:
"""全新库、已到 head 或关闭保护时不创建自动恢复点。"""
governance = Mock()
monkeypatch.setattr(db_init, "get_engine", lambda: object())
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _engine: object())
monkeypatch.setattr(
db_init,
"_migration_state",
lambda *_: (has_existing_database, current_heads, ("head",)),
)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ENABLE", backup_enabled)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ON_UPGRADE", upgrade_enabled)
monkeypatch.setattr(
db_init,
"build_database_governance",
lambda: governance,
)
monkeypatch.setattr(db_init, "init_db", lambda: None)
monkeypatch.setattr(db_init, "update_db", lambda _config: None)
db_init.prepare_database()
governance.create_backup.assert_not_called()
def test_prepare_database_stops_before_schema_changes_when_backup_fails(
monkeypatch,
) -> None:
"""迁移保护失败时不得继续执行 create_all 或 Alembic。"""
backup_error = RuntimeError("backup failed")
init_calls: list[None] = []
governance = Mock()
governance.create_backup.side_effect = backup_error
monkeypatch.setattr(db_init, "get_engine", lambda: object())
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _engine: object())
monkeypatch.setattr(
db_init,
"_migration_state",
lambda *_: (True, ("old",), ("head",)),
)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ENABLE", True)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ON_UPGRADE", True)
monkeypatch.setattr(
db_init,
"build_database_governance",
lambda: governance,
)
monkeypatch.setattr(db_init, "init_db", lambda: init_calls.append(None))
monkeypatch.setattr(db_init, "update_db", lambda _config: init_calls.append(None))
with pytest.raises(RuntimeError) as raised:
db_init.prepare_database(
before_alembic=lambda: init_calls.append(None),
)
assert raised.value is backup_error
assert init_calls == []
def test_alembic_config_uses_active_engine_url_without_hiding_password() -> None:
"""Alembic 必须连接活动引擎目标,内部配置不得把密码替换为星号。"""
engine = SimpleNamespace(
url=make_url("postgresql://moviepilot:secret@database/moviepilot")
)
config = db_init._build_alembic_config(engine)
assert config.get_main_option("sqlalchemy.url") == (
"postgresql://moviepilot:secret@database/moviepilot"
)
def test_migration_state_distinguishes_fresh_legacy_and_current_sqlite(
tmp_path: Path,
) -> None:
"""SQLite 空库不备份,已有业务表且无 revision 时识别为待迁移。"""
engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}")
config = db_init._build_alembic_config(engine)
target_heads = tuple(db_init.ScriptDirectory.from_config(config).get_heads())
assert db_init._migration_state(engine, config) == (False, (), target_heads)
with engine.begin() as connection:
connection.execute(text("CREATE TABLE legacy_data (id INTEGER PRIMARY KEY)"))
assert db_init._migration_state(engine, config) == (True, (), target_heads)
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:head)"),
{"head": target_heads[0]},
)
assert db_init._migration_state(engine, config) == (
True,
target_heads,
target_heads,
)
def test_migration_state_rejects_unknown_revision_before_schema_writes(
tmp_path: Path,
) -> None:
"""未知或更高版本 revision 不得被误判为可执行升级。"""
engine = create_engine(f"sqlite:///{tmp_path / 'future.db'}")
config = db_init._build_alembic_config(engine)
with engine.begin() as connection:
connection.execute(text("CREATE TABLE legacy_data (id INTEGER PRIMARY KEY)"))
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES ('future')")
)
with pytest.raises(RuntimeError, match="无法识别数据库 revisionfuture"):
db_init._migration_state(engine, config)
assert set(db_init.inspect(engine).get_table_names()) == {
"alembic_version",
"legacy_data",
}
def test_migration_lineage_rejects_multiple_heads() -> None:
"""当前迁移执行器仅接受仓库和数据库均保持单一 head。"""
script = Mock()
with pytest.raises(RuntimeError, match="迁移脚本必须只有一个 head"):
db_init._validate_migration_lineage(script, (), ("head-a", "head-b"))
with pytest.raises(RuntimeError, match="多个 current revision"):
db_init._validate_migration_lineage(
script,
("current-a", "current-b"),
("head",),
)
def test_migration_lineage_rejects_known_divergent_revision() -> None:
"""可识别但不在目标祖先链上的 revision 不得继续自动迁移。"""
script = Mock()
script.walk_revisions.return_value = (
SimpleNamespace(revision="head"),
SimpleNamespace(revision="base"),
)
with pytest.raises(RuntimeError, match="不是当前 head head 的可升级祖先"):
db_init._validate_migration_lineage(
script,
("other-branch",),
("head",),
)
script.get_revision.assert_called_once_with("other-branch")
def test_migration_lineage_wraps_unknown_revision() -> None:
"""Alembic 未知 revision 错误应转换为可操作的启动错误。"""
script = Mock()
script.get_revision.side_effect = CommandError("unknown")
with pytest.raises(RuntimeError, match="无法识别数据库 revisionfuture"):
db_init._validate_migration_lineage(
script,
("future",),
("head",),
)
def test_prepare_database_creates_real_sqlite_restore_point_before_upgrade(
tmp_path: Path,
monkeypatch,
) -> None:
"""迁移前备份保留旧版本,活动 SQLite 升级到目标版本。"""
script_root = tmp_path / "database"
versions = script_root / "versions"
versions.mkdir(parents=True)
(script_root / "env.py").write_text(
"""
from alembic import context
from sqlalchemy import engine_from_config, pool
engine = engine_from_config(
context.config.get_section(context.config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with engine.connect() as connection:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()
""".strip(),
encoding="utf-8",
)
(versions / "001_base.py").write_text(
"""
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
""".strip(),
encoding="utf-8",
)
(versions / "002_head.py").write_text(
"""
from alembic import op
import sqlalchemy as sa
revision = "002"
down_revision = "001"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("records", sa.Column("migrated", sa.Integer()))
def downgrade():
op.drop_column("records", "migrated")
""".strip(),
encoding="utf-8",
)
engine = create_engine(f"sqlite:///{tmp_path / 'active.db'}")
metadata = MetaData()
Table("records", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES ('001')")
)
config = db_init._build_alembic_config(engine)
config.set_main_option("script_location", str(script_root))
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ENABLE", True)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_ON_UPGRADE", True)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_PATH", str(tmp_path / "backups"))
monkeypatch.setattr(db_init.settings, "DB_BACKUP_RETENTION_DAYS", 0)
monkeypatch.setattr(db_init.settings, "DB_BACKUP_MAX_COUNT", 0)
monkeypatch.setattr(db_init, "get_engine", lambda: engine)
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _engine: config)
monkeypatch.setattr(db_init, "Base", SimpleNamespace(metadata=metadata))
monkeypatch.setattr(db_init, "load_all_models", lambda: None)
monkeypatch.setattr(startup_database, "get_engine", lambda: engine)
db_init.prepare_database()
artifacts = sorted((tmp_path / "backups").glob("sqlite_*.db"))
assert len(artifacts) == 1
with create_engine(f"sqlite:///{artifacts[0]}").connect() as connection:
backup_revision = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
with engine.connect() as connection:
active_revision = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
active_columns = {
column["name"] for column in inspect(connection).get_columns("records")
}
assert backup_revision == "001"
assert active_revision == "002"
assert active_columns == {"id", "migrated"}
def test_local_setup_returns_failure_when_database_migration_fails(
monkeypatch,
capsys,
+11 -6
View File
@@ -405,8 +405,11 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
lambda *_args: calls.append("signal"),
)
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray"))
monkeypatch.setattr(main, "init_db", lambda: calls.append("init_db"))
monkeypatch.setattr(main, "update_db", lambda: calls.append("update_db"))
monkeypatch.setattr(
main,
"prepare_database",
lambda: calls.append("prepare_database"),
)
monkeypatch.setattr(main.Server, "run", lambda: calls.append("server"))
main.run_application()
@@ -416,8 +419,7 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
"signal",
"signal",
"tray",
"init_db",
"update_db",
"prepare_database",
"server",
]
@@ -437,8 +439,11 @@ def test_application_does_not_start_server_after_migration_failure(monkeypatch):
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,
"prepare_database",
MagicMock(side_effect=migration_error),
)
monkeypatch.setattr(main.Server, "run", server_run)
with pytest.raises(RuntimeError) as raised:
@@ -33,6 +33,8 @@ def test_database_backup_policy_rejects_invalid_values(
[
{"DB_BACKUP_CRON": ""},
{"DB_BACKUP_CRON": "0 3 * * *"},
{"DB_BACKUP_ON_UPGRADE": True},
{"DB_BACKUP_ON_UPGRADE": False},
{"DB_BACKUP_PATH": None},
{"DB_BACKUP_PATH": ""},
{"DB_BACKUP_PATH": "database_backup"},