mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
815 lines
27 KiB
Python
815 lines
27 KiB
Python
import importlib
|
||
import importlib.util
|
||
from pathlib import Path
|
||
import sys
|
||
from types import SimpleNamespace
|
||
from unittest.mock import Mock
|
||
import uuid
|
||
|
||
from alembic.migration import MigrationContext
|
||
from alembic.operations import Operations
|
||
import pytest
|
||
from alembic.util import CommandError
|
||
from fastapi import FastAPI
|
||
import sqlalchemy as sa
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Column,
|
||
Integer,
|
||
JSON,
|
||
MetaData,
|
||
String,
|
||
Table,
|
||
create_engine,
|
||
event,
|
||
inspect,
|
||
text,
|
||
)
|
||
from sqlalchemy.engine.url import make_url
|
||
|
||
from app.startup.composition import database as startup_database
|
||
from app.startup.initializers import database as db_init
|
||
from app.startup import lifecycle
|
||
from app.runtime.health import get_application_health
|
||
from app.db.models.systemconfig import SystemConfig
|
||
|
||
|
||
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_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_verify_database_revision_requires_current_head(monkeypatch) -> None:
|
||
"""readiness 的数据库校验必须拒绝升级后仍未到 head 的状态。"""
|
||
engine = object()
|
||
config = object()
|
||
monkeypatch.setattr(db_init, "get_engine", lambda: engine)
|
||
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config)
|
||
monkeypatch.setattr(
|
||
db_init,
|
||
"_migration_state",
|
||
lambda *_: (True, ("old",), ("head",)),
|
||
)
|
||
|
||
with pytest.raises(RuntimeError, match="仍未到达当前 head"):
|
||
db_init.verify_database_revision()
|
||
|
||
|
||
def test_verify_database_revision_accepts_current_head(monkeypatch) -> None:
|
||
"""活动 revision 与唯一目标 head 一致时允许发布数据库就绪。"""
|
||
engine = object()
|
||
config = object()
|
||
monkeypatch.setattr(db_init, "get_engine", lambda: engine)
|
||
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config)
|
||
monkeypatch.setattr(
|
||
db_init,
|
||
"_migration_state",
|
||
lambda *_: (True, ("head",), ("head",)),
|
||
)
|
||
|
||
db_init.verify_database_revision()
|
||
|
||
|
||
def test_lifecycle_database_component_marks_ready_after_head_check(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""数据库组件必须按迁移、head 校验、发布状态的顺序执行。"""
|
||
app = FastAPI()
|
||
calls: list[str] = []
|
||
monkeypatch.setattr(
|
||
db_init,
|
||
"prepare_database",
|
||
lambda: calls.append("prepare"),
|
||
)
|
||
monkeypatch.setattr(
|
||
db_init,
|
||
"verify_database_revision",
|
||
lambda: calls.append("verify"),
|
||
)
|
||
|
||
lifecycle.prepare_database_component(app)
|
||
|
||
assert calls == ["prepare", "verify"]
|
||
assert get_application_health(app).database_ready is True
|
||
|
||
|
||
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("moviepilot_*_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_migration_config_write_rolls_back_with_alembic_transaction(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""配置 DML 不得脱离 Alembic 事务提前提交。"""
|
||
migration = importlib.import_module(
|
||
"database.versions.e8b1c4d7a2f9_2_2_18"
|
||
)
|
||
engine = create_engine("sqlite://")
|
||
|
||
metadata = MetaData()
|
||
systemconfig = Table(
|
||
"systemconfig",
|
||
metadata,
|
||
Column("id", Integer, primary_key=True),
|
||
Column("key", String),
|
||
Column("value", JSON),
|
||
)
|
||
for table_name in ("subscribe", "subscribehistory", "transferhistory"):
|
||
Table(table_name, metadata, Column("id", Integer, primary_key=True))
|
||
metadata.create_all(engine)
|
||
|
||
legacy_organize = """
|
||
{
|
||
'title': '{{ title_year }}'
|
||
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
|
||
'text': '{% if vote_average %}评分:{{ vote_average }},{% endif %}'
|
||
'类型:{{ type }}'
|
||
'{% if category %},类别:{{ category }}{% endif %}'
|
||
'{% if resource_term %},质量:{{ resource_term }}{% endif %},'
|
||
'共{{ file_count }}个文件,大小:{{ total_size }}'
|
||
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
|
||
}"""
|
||
legacy_download = """
|
||
{
|
||
'title': '{{ title_year }}'
|
||
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
|
||
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
|
||
'{% if resource_term %}\\n质量:{{ resource_term }}{% endif %}'
|
||
'{% if size %}\\n大小:{{ size }}{% endif %}'
|
||
'{% if torrent_title %}\\n种子:{{ torrent_title }}{% endif %}'
|
||
'{% if pubdate %}\\n发布时间:{{ pubdate }}{% endif %}'
|
||
'{% if freedate %}\\n免费时间:{{ freedate }}{% endif %}'
|
||
'{% if seeders %}\\n做种数:{{ seeders }}{% endif %}'
|
||
'{% if volume_factor %}\\n促销:{{ volume_factor }}{% endif %}'
|
||
'{% if hit_and_run %}\\nHit&Run:{{ hit_and_run }}{% endif %}'
|
||
'{% if labels %}\\n标签:{{ labels }}{% endif %}'
|
||
'{% if description %}\\n描述:{{ description }}{% endif %}'
|
||
}"""
|
||
original_templates = {
|
||
"organizeSuccess": legacy_organize,
|
||
"downloadAdded": legacy_download,
|
||
}
|
||
with engine.begin() as connection:
|
||
connection.execute(
|
||
systemconfig.insert().values(
|
||
key="NotificationTemplates",
|
||
value=original_templates,
|
||
)
|
||
)
|
||
|
||
with engine.connect() as connection:
|
||
transaction = connection.begin()
|
||
monkeypatch.setattr(
|
||
migration,
|
||
"op",
|
||
Operations(MigrationContext.configure(connection)),
|
||
)
|
||
config_write_seen = False
|
||
|
||
def fail_after_config_write(
|
||
_connection,
|
||
_cursor,
|
||
statement,
|
||
_parameters,
|
||
_context,
|
||
_executemany,
|
||
) -> None:
|
||
nonlocal config_write_seen
|
||
if statement.lstrip().upper().startswith("UPDATE SYSTEMCONFIG"):
|
||
config_write_seen = True
|
||
raise RuntimeError("injected migration failure")
|
||
|
||
event.listen(engine, "after_cursor_execute", fail_after_config_write)
|
||
try:
|
||
with pytest.raises(RuntimeError, match="injected migration failure"):
|
||
migration.upgrade()
|
||
finally:
|
||
event.remove(engine, "after_cursor_execute", fail_after_config_write)
|
||
transaction.rollback()
|
||
|
||
assert config_write_seen
|
||
|
||
with engine.connect() as connection:
|
||
columns = {
|
||
column["name"]
|
||
for column in inspect(connection).get_columns("subscribe")
|
||
}
|
||
# SQLite 默认驱动不回滚 DDL,但配置写入仍必须服从 Alembic 事务。
|
||
assert "audio_quality" in columns
|
||
stored_templates = connection.execute(
|
||
systemconfig.select().with_only_columns(systemconfig.c.value).where(
|
||
systemconfig.c.key == "NotificationTemplates"
|
||
)
|
||
).scalar_one()
|
||
assert stored_templates == original_templates
|
||
|
||
|
||
def test_initial_migration_rolls_back_user_and_storages_together(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""2.0.0 管理员与存储初始化必须共享 Alembic 事务。"""
|
||
migration = importlib.import_module(
|
||
"database.versions.294b007932ef_2_0_0"
|
||
)
|
||
engine = create_engine("sqlite://")
|
||
|
||
metadata = MetaData()
|
||
Table(
|
||
"user",
|
||
metadata,
|
||
Column("id", Integer, primary_key=True),
|
||
Column("name", String, nullable=False),
|
||
Column("email", String),
|
||
Column("hashed_password", String),
|
||
Column("is_active", Boolean),
|
||
Column("is_superuser", Boolean),
|
||
Column("avatar", String),
|
||
Column("is_otp", Boolean),
|
||
Column("otp_secret", String),
|
||
Column("permissions", JSON),
|
||
Column("settings", JSON),
|
||
)
|
||
systemconfig = Table(
|
||
"systemconfig",
|
||
metadata,
|
||
Column("id", Integer, primary_key=True),
|
||
Column("key", String),
|
||
Column("value", JSON),
|
||
)
|
||
metadata.create_all(engine)
|
||
|
||
monkeypatch.setattr(migration.settings, "SUPERUSER", "migration-admin")
|
||
monkeypatch.setattr(
|
||
migration.settings,
|
||
"SUPERUSER_PASSWORD",
|
||
"migration-password",
|
||
)
|
||
monkeypatch.setattr(
|
||
migration,
|
||
"get_password_hash",
|
||
lambda password: f"hashed:{password}",
|
||
)
|
||
|
||
with engine.connect() as connection:
|
||
transaction = connection.begin()
|
||
monkeypatch.setattr(
|
||
migration,
|
||
"op",
|
||
Operations(MigrationContext.configure(connection)),
|
||
)
|
||
|
||
def fail_storages_write(
|
||
_connection,
|
||
_cursor,
|
||
statement,
|
||
_parameters,
|
||
_context,
|
||
_executemany,
|
||
) -> None:
|
||
if statement.lstrip().upper().startswith("INSERT INTO SYSTEMCONFIG"):
|
||
raise RuntimeError("injected storages failure")
|
||
|
||
event.listen(engine, "after_cursor_execute", fail_storages_write)
|
||
try:
|
||
with pytest.raises(RuntimeError, match="injected storages failure"):
|
||
migration.upgrade()
|
||
finally:
|
||
event.remove(engine, "after_cursor_execute", fail_storages_write)
|
||
transaction.rollback()
|
||
|
||
with engine.connect() as connection:
|
||
assert connection.execute(text("SELECT COUNT(*) FROM user")).scalar_one() == 0
|
||
assert connection.execute(
|
||
text("SELECT COUNT(*) FROM systemconfig")
|
||
).scalar_one() == 0
|
||
|
||
|
||
def test_userconfig_cleanup_migration_uses_alembic_transaction(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""2.0.3 用户配置清理必须随当前 Alembic 事务一起回滚。"""
|
||
migration = importlib.import_module(
|
||
"database.versions.e2dbe1421fa4_2_0_3"
|
||
)
|
||
engine = create_engine("sqlite://")
|
||
|
||
metadata = MetaData()
|
||
table_columns = {
|
||
"downloadhistory": (("note", JSON), ("media_category", String)),
|
||
"subscribe": (
|
||
("note", JSON),
|
||
("custom_words", String),
|
||
("media_category", String),
|
||
("filter_groups", JSON),
|
||
),
|
||
"mediaserveritem": (("note", JSON),),
|
||
"message": (("note", JSON),),
|
||
"plugindata": (("value", JSON),),
|
||
"site": (("note", JSON),),
|
||
"sitestatistic": (("note", JSON),),
|
||
"systemconfig": (("value", JSON),),
|
||
"userconfig": (("value", JSON),),
|
||
}
|
||
tables = {
|
||
table_name: Table(
|
||
table_name,
|
||
metadata,
|
||
Column("id", Integer, primary_key=True),
|
||
*(Column(column_name, column_type) for column_name, column_type in columns),
|
||
)
|
||
for table_name, columns in table_columns.items()
|
||
}
|
||
metadata.create_all(engine)
|
||
with engine.begin() as connection:
|
||
connection.execute(
|
||
tables["userconfig"].insert().values(value={"retained": True})
|
||
)
|
||
|
||
with engine.connect() as connection:
|
||
transaction = connection.begin()
|
||
monkeypatch.setattr(
|
||
migration,
|
||
"op",
|
||
Operations(MigrationContext.configure(connection)),
|
||
)
|
||
migration.upgrade()
|
||
assert connection.execute(
|
||
sa.select(sa.func.count()).select_from(tables["userconfig"])
|
||
).scalar_one() == 0
|
||
transaction.rollback()
|
||
|
||
with engine.connect() as connection:
|
||
assert connection.execute(
|
||
sa.select(sa.func.count()).select_from(tables["userconfig"])
|
||
).scalar_one() == 1
|
||
|
||
|
||
def test_user_permission_migration_uses_alembic_transaction(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""2.1.6 权限初始化必须保留原筛选语义并随迁移事务回滚。"""
|
||
migration = importlib.import_module(
|
||
"database.versions.3df653756eec_2_1_6"
|
||
)
|
||
engine = create_engine("sqlite://")
|
||
|
||
metadata = MetaData()
|
||
user = Table(
|
||
"user",
|
||
metadata,
|
||
Column("id", Integer, primary_key=True),
|
||
Column("is_superuser", Boolean),
|
||
Column("permissions", JSON),
|
||
)
|
||
metadata.create_all(engine)
|
||
existing_permissions = {"manage": True}
|
||
with engine.begin() as connection:
|
||
connection.execute(user.insert(), [
|
||
{"id": 1, "is_superuser": False, "permissions": None},
|
||
{"id": 2, "is_superuser": False, "permissions": existing_permissions},
|
||
{"id": 3, "is_superuser": True, "permissions": None},
|
||
])
|
||
|
||
with engine.connect() as connection:
|
||
transaction = connection.begin()
|
||
monkeypatch.setattr(
|
||
migration,
|
||
"op",
|
||
Operations(MigrationContext.configure(connection)),
|
||
)
|
||
migration.upgrade()
|
||
migrated = connection.execute(
|
||
sa.select(user.c.id, user.c.permissions).order_by(user.c.id)
|
||
).all()
|
||
assert migrated == [
|
||
(
|
||
1,
|
||
{
|
||
"discovery": True,
|
||
"search": True,
|
||
"subscribe": True,
|
||
"manage": False,
|
||
},
|
||
),
|
||
(2, existing_permissions),
|
||
(3, None),
|
||
]
|
||
transaction.rollback()
|
||
|
||
with engine.connect() as connection:
|
||
assert connection.execute(
|
||
sa.select(user.c.id, user.c.permissions).order_by(user.c.id)
|
||
).all() == [
|
||
(1, None),
|
||
(2, existing_permissions),
|
||
(3, None),
|
||
]
|
||
|
||
|
||
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
|
||
|
||
|
||
def test_local_setup_apply_config_registers_offline_transaction_runner(
|
||
monkeypatch,
|
||
tmp_path: Path,
|
||
db,
|
||
) -> None:
|
||
"""离线 apply-config 写入配置前必须装配同步事务执行器。"""
|
||
db.watermark(SystemConfig)
|
||
module = _load_local_setup_module()
|
||
monkeypatch.setattr(db_init, "prepare_database", lambda **_kwargs: None)
|
||
monkeypatch.setattr(module, "_ensure_superuser_account_inner", lambda: None)
|
||
payload = {
|
||
"directories": [{
|
||
"name": "offline-config",
|
||
"download_path": str(tmp_path / "downloads"),
|
||
"library_path": str(tmp_path / "library"),
|
||
"priority": 0,
|
||
}],
|
||
}
|
||
|
||
module._apply_local_system_config_inner(payload)
|
||
|
||
persisted = SystemConfig.get_by_key(
|
||
db.session,
|
||
"Directories",
|
||
)
|
||
assert persisted is not None
|
||
assert persisted.value[0]["name"] == "offline-config"
|