diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 968d9bafb..5d934a692 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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", diff --git a/app/main.py b/app/main.py index 82f3f2fed..22c4aef79 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/runtime/config.py b/app/runtime/config.py index 4fb10ae08..323e400b4 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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 表示不按时间清理 diff --git a/app/scheduler.py b/app/scheduler.py index 94187004f..4fb2ae37b 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -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定时同步 diff --git a/app/startup/database_initializer.py b/app/startup/database_initializer.py index de2feeedf..c583c91aa 100644 --- a/app/startup/database_initializer.py +++ b/app/startup/database_initializer.py @@ -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 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index b8570921a..a75856cc6 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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: diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 44c1787d4..96d6b39e0 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index d47bb892f..a0823de67 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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 } ] }, diff --git a/tests/test_database_backup_scheduler.py b/tests/test_database_backup_scheduler.py index 68595fe8a..8880d4f0d 100644 --- a/tests/test_database_backup_scheduler.py +++ b/tests/test_database_backup_scheduler.py @@ -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", diff --git a/tests/test_database_migration_startup.py b/tests/test_database_migration_startup.py index 8f8877767..f043538e6 100644 --- a/tests/test_database_migration_startup.py +++ b/tests/test_database_migration_startup.py @@ -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="无法识别数据库 revision:future"): + 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="无法识别数据库 revision:future"): + 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, diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 129890530..4f215ffba 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -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: diff --git a/tests/test_system_database_backup_config.py b/tests/test_system_database_backup_config.py index 169dd4f5a..a56d41150 100644 --- a/tests/test_system_database_backup_config.py +++ b/tests/test_system_database_backup_config.py @@ -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"},