Files
MoviePilot/tests/test_plugin_instance_migration_roundtrip.py
Aqr-K d6509f092c chore(plugin): 技能表目录登记补进生成脚本并补迁移往返验证
技能文档 skills/database-operation/SKILL.md 的核心表清单是
scripts/generate_agent_skill_docs.py 里 DATABASE_TABLE_GUIDES 的生成物,
前面两笔改动却是手改产物登记 plugininstance,任何人重跑生成器都会把它弄丢。
把条目补进字典,再用生成器重出该文档。

同时补上 agentinvocation:官方 v3 已有该表但字典里没有,生成器因此直接抛
ValueError,不补就根本跑不起来。它的说明照抄文档里现有的那份手写文案,
避免登记动作反而把已有描述改瘦。

迁移测试原本只按单个 revision 直接调 upgrade(),绕过了 Alembic 自己的版本
解析与记账,链接错的 down_revision、少写的 downgrade、重复升级时的重复建表
都测不出来。新增用例改走 alembic.command,在落盘的临时库上从官方 head
b2d4f6a8c1e3 升到链尾 487f7e681955 再降回去,验证配置与描述符确实落到
plugininstance、旧 plugin.* 键就地移除而 PluginInstances 键按回滚依据保留。
2026-09-12 19:48:57 -04:00

253 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件实例迁移链在真实 Alembic 运行器上的升降级往返验证。
同目录下按单个 revision 调 ``upgrade()`` 的用例只验证单步搬迁逻辑,绕过了
Alembic 自己的版本解析、顺序编排与 ``alembic_version`` 记账。真实事故恰恰出在
这一层:链接错的 ``down_revision``、少写的 ``downgrade``、第二次升级时重复建表,
单步用例一个都发现不了。本模块因此走 ``alembic.command``,在一个落盘的临时库上
从官方 head 一路升到链尾再降回去,验证三个迁移串起来确实可用。
"""
from __future__ import annotations
import json
from configparser import ConfigParser
from pathlib import Path
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# 官方 v3 的迁移链末端,也是本特性三个迁移的共同起点
OFFICIAL_HEAD = "b2d4f6a8c1e3"
CHAIN_HEAD = "487f7e681955"
CHAIN = ("281965691a20", "c4e1a7b9d2f6", CHAIN_HEAD)
_TABLE = "plugininstance"
_LEGACY_KEY = "PluginInstances"
# 一条分身描述符、两条插件配置(本体一条、分身一条),照搬运行期真实形状
_LEGACY_INSTANCES = {
"DemoPluginWork": {
"instance_id": "DemoPluginWork",
"source_plugin_id": "DemoPlugin",
"plugin_name": "工作实例",
"plugin_desc": "分身描述",
"plugin_icon": "work.svg",
},
}
_HOST_CONFIG = {"enabled": True, "cron": "0 1 * * *", "notify": False}
_CLONE_CONFIG = {"enabled": False, "cron": "0 2 * * *", "notify": True}
def _instance_table() -> sa.TableClause:
"""按列类型声明实例表,使 JSON 配置列读出时自动解码。"""
return sa.table(
_TABLE,
sa.column("instance_id", sa.String()),
sa.column("source_plugin_id", sa.String()),
sa.column("plugin_name", sa.String()),
sa.column("plugin_desc", sa.String()),
sa.column("plugin_icon", sa.String()),
sa.column("log_level", sa.String()),
sa.column("log_expires_at", sa.String()),
sa.column("config_data", sa.JSON()),
)
def _seed_legacy_rows(engine: sa.Engine) -> None:
"""写入迁移前的 systemconfig 存量:实例描述符单键与两条 plugin.* 配置键。"""
with engine.begin() as connection:
for key, value in (
(_LEGACY_KEY, _LEGACY_INSTANCES),
("plugin.DemoPlugin", _HOST_CONFIG),
("plugin.DemoPluginWork", _CLONE_CONFIG),
):
connection.execute(
sa.text("INSERT INTO systemconfig (key, value) VALUES (:key, :value)"),
{"key": key, "value": json.dumps(value)},
)
def _systemconfig_keys(engine: sa.Engine) -> set[str]:
"""读取当前库里的全部 systemconfig 键。"""
with engine.connect() as connection:
return {
row[0]
for row in connection.execute(sa.text("SELECT key FROM systemconfig"))
}
def _systemconfig_value(engine: sa.Engine, key: str):
"""读取一条 systemconfig 值并解码,键不存在时返回 ``None``。"""
with engine.connect() as connection:
row = connection.execute(
sa.text("SELECT value FROM systemconfig WHERE key = :key"), {"key": key}
).first()
if row is None:
return None
value = row[0]
return json.loads(value) if isinstance(value, (str, bytes, bytearray)) else value
def _instance_rows(engine: sa.Engine) -> dict[str, sa.Row]:
"""按实例 ID 读出实例表全部行。"""
table = _instance_table()
with engine.connect() as connection:
return {
row.instance_id: row
for row in connection.execute(sa.select(table))
}
def _current_revision(engine: sa.Engine) -> str | None:
"""读取库里记账的当前 revision。"""
with engine.connect() as connection:
if "alembic_version" not in sa.inspect(connection).get_table_names():
return None
return connection.execute(
sa.text("SELECT version_num FROM alembic_version")
).scalar()
def _table_names(engine: sa.Engine) -> set[str]:
"""读取库中已有表名。"""
with engine.connect() as connection:
return set(sa.inspect(connection).get_table_names())
@pytest.fixture
def official_head_database(tmp_path):
"""造一个停在官方 head 的落盘 SQLite 库,并塞入迁移前的存量数据。
官方 head 与本分支的 schema 差异只有 ``plugininstance`` 一张表,因此「按当前
模型建表但跳过该表,再把版本记账戳到官方 head」与真实的官方库等价从链条最
开头逐条重放做不到,因为官方的初始化流程本身是先 ``create_all`` 再升级,早期
迁移并不自带建表。
:return: ``(Alembic 配置, 指向该库的 Engine)``
"""
from app.db.base import Base
from app.db.models import load_all_models
load_all_models()
database_path = tmp_path / "roundtrip.db"
url = f"sqlite:///{database_path}"
engine = sa.create_engine(url)
Base.metadata.create_all(
engine,
tables=[
table
for name, table in Base.metadata.tables.items()
if name != _TABLE
],
)
config = Config()
# 不读 ini 文件,避免 env.py 对日志配置段的依赖,与应用启动时的构造方式一致
config.file_config = ConfigParser(interpolation=None)
config.set_main_option("script_location", str(PROJECT_ROOT / "database"))
config.set_main_option("sqlalchemy.url", url)
command.stamp(config, OFFICIAL_HEAD)
_seed_legacy_rows(engine)
try:
yield config, engine
finally:
# 建的是整套官方表,单个库文件接近 1MBpytest 默认还要留最近几轮 tmp_path
# 攒下来不小,因此释放句柄后立刻删掉,不把回收推给它
engine.dispose()
database_path.unlink(missing_ok=True)
def test_upgrade_from_official_head_lands_legacy_payloads_on_instance_rows(
official_head_database,
) -> None:
"""整条链升上来后,描述符与两条配置都应落到实例表,旧配置键就地移除。"""
config, engine = official_head_database
command.upgrade(config, CHAIN_HEAD)
assert _current_revision(engine) == CHAIN_HEAD
rows = _instance_rows(engine)
assert set(rows) == {"DemoPlugin", "DemoPluginWork"}
clone = rows["DemoPluginWork"]
assert clone.source_plugin_id == "DemoPlugin"
assert clone.plugin_name == "工作实例"
assert clone.plugin_desc == "分身描述"
assert clone.plugin_icon == "work.svg"
assert clone.config_data == _CLONE_CONFIG
host = rows["DemoPlugin"]
# 本体没有描述符条目,靠 plugin.<ID> 配置键补建,故身份两列相等
assert host.source_plugin_id == "DemoPlugin"
assert host.config_data == _HOST_CONFIG
# 日志等级列由链尾迁移补出,缺省为空表示跟随全局等级
assert clone.log_level is None and clone.log_expires_at is None
assert host.log_level is None and host.log_expires_at is None
keys = _systemconfig_keys(engine)
# 同一份配置不能两处各留一份plugin.* 旧键必须消失
assert "plugin.DemoPlugin" not in keys
assert "plugin.DemoPluginWork" not in keys
# 描述符单键刻意保留作回滚依据
assert _systemconfig_value(engine, _LEGACY_KEY) == _LEGACY_INSTANCES
def test_downgrade_back_to_official_head_restores_the_legacy_config_keys(
official_head_database,
) -> None:
"""降回官方 head 不应报错,实例表整张移除且配置回到旧键,回退路径不丢配置。"""
config, engine = official_head_database
command.upgrade(config, CHAIN_HEAD)
command.downgrade(config, OFFICIAL_HEAD)
assert _current_revision(engine) == OFFICIAL_HEAD
assert _TABLE not in _table_names(engine)
assert _systemconfig_value(engine, "plugin.DemoPlugin") == _HOST_CONFIG
assert _systemconfig_value(engine, "plugin.DemoPluginWork") == _CLONE_CONFIG
assert _systemconfig_value(engine, _LEGACY_KEY) == _LEGACY_INSTANCES
def test_upgrade_is_idempotent_on_an_already_migrated_database(
official_head_database,
) -> None:
"""重复升级与降级后重升都不应报错,且不会把行搬成两份。"""
config, engine = official_head_database
command.upgrade(config, CHAIN_HEAD)
first_pass = _instance_rows(engine)
# 已在链尾时再升一次Alembic 层面是空操作,验证不会因重复执行而失败
command.upgrade(config, CHAIN_HEAD)
assert _current_revision(engine) == CHAIN_HEAD
assert _instance_rows(engine).keys() == first_pass.keys()
# 降回官方 head 后重升:三个迁移都要在已有存量的库上重跑一遍
command.downgrade(config, OFFICIAL_HEAD)
command.upgrade(config, CHAIN_HEAD)
assert _current_revision(engine) == CHAIN_HEAD
replayed = _instance_rows(engine)
assert set(replayed) == {"DemoPlugin", "DemoPluginWork"}
assert replayed["DemoPlugin"].config_data == _HOST_CONFIG
assert replayed["DemoPluginWork"].config_data == _CLONE_CONFIG
assert replayed["DemoPluginWork"].source_plugin_id == "DemoPlugin"
def test_chain_applies_one_revision_at_a_time_in_the_declared_order(
official_head_database,
) -> None:
"""逐级升级应按声明顺序推进记账,确认三个迁移的链接方向没有接反。"""
config, engine = official_head_database
for revision in CHAIN:
command.upgrade(config, revision)
assert _current_revision(engine) == revision
assert _TABLE in _table_names(engine)