mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""插件身份表 Alembic 迁移测试。"""
|
|
|
|
import importlib
|
|
import os
|
|
import uuid
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
try:
|
|
import psycopg2 as postgres_driver
|
|
from psycopg2 import sql
|
|
POSTGRESQL_DIALECT = "postgresql+psycopg2"
|
|
except ModuleNotFoundError:
|
|
import psycopg as postgres_driver
|
|
from psycopg import sql
|
|
POSTGRESQL_DIALECT = "postgresql+psycopg"
|
|
|
|
from app.db.models.pluginidentity import PluginIdentity
|
|
|
|
BASE_MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
|
|
INSTALLATION_MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
|
|
DECLARATION_MIGRATION = "database.versions.5f2a9c1e7b4d_3_0_12"
|
|
|
|
|
|
def _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
module_name: str = BASE_MIGRATION,
|
|
):
|
|
"""把迁移绑定到隔离数据库连接。"""
|
|
migration = importlib.import_module(module_name)
|
|
context = MigrationContext.configure(connection)
|
|
monkeypatch.setattr(migration, "op", Operations(context))
|
|
return migration
|
|
|
|
|
|
def test_plugin_identity_migration_upgrades_twice_and_downgrades(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""SQLite 应可从旧身份表升级到声明快照并按逆序完整回滚。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
installation = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
INSTALLATION_MIGRATION,
|
|
)
|
|
installation.upgrade()
|
|
installation.upgrade()
|
|
declaration = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
DECLARATION_MIGRATION,
|
|
)
|
|
declaration.upgrade()
|
|
declaration.upgrade()
|
|
|
|
inspector = sa.inspect(connection)
|
|
assert "pluginidentity" in inspector.get_table_names()
|
|
columns = {
|
|
column["name"] for column in inspector.get_columns("pluginidentity")
|
|
}
|
|
assert columns == {column.name for column in PluginIdentity.__table__.columns}
|
|
unique_constraints = {
|
|
constraint["name"]: tuple(constraint["column_names"])
|
|
for constraint in inspector.get_unique_constraints("pluginidentity")
|
|
}
|
|
assert unique_constraints["uq_pluginidentity_normalized_plugin_id"] == (
|
|
"normalized_plugin_id",
|
|
)
|
|
check_constraints = {
|
|
constraint["name"]
|
|
for constraint in inspector.get_check_constraints("pluginidentity")
|
|
}
|
|
assert check_constraints == {
|
|
"ck_pluginidentity_normalized_plugin_id",
|
|
"ck_pluginidentity_revision",
|
|
}
|
|
assert "plugininstallation" in inspector.get_table_names()
|
|
|
|
declaration.downgrade()
|
|
installation.downgrade()
|
|
migration.downgrade()
|
|
assert not {
|
|
"pluginidentity",
|
|
"plugininstallation",
|
|
} & set(sa.inspect(connection).get_table_names())
|
|
|
|
migration.upgrade()
|
|
installation.upgrade()
|
|
declaration.upgrade()
|
|
assert {
|
|
column["name"]
|
|
for column in sa.inspect(connection).get_columns("pluginidentity")
|
|
} == {column.name for column in PluginIdentity.__table__.columns}
|
|
|
|
|
|
def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> None:
|
|
"""create_all 已建当前表时重复升级不得创建冲突对象。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
PluginIdentity.__table__.create(connection)
|
|
migration = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
DECLARATION_MIGRATION,
|
|
)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
assert {
|
|
column["name"]
|
|
for column in sa.inspect(connection).get_columns("pluginidentity")
|
|
} == {column.name for column in PluginIdentity.__table__.columns}
|
|
|
|
|
|
def test_plugin_identity_declaration_migration_backfills_and_restores_legacy_fields(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""声明迁移应保守回填旧字段,并能在降级时恢复旧投影。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
base = _bind_migration(monkeypatch, connection)
|
|
base.upgrade()
|
|
table = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
connection.execute(
|
|
table.insert().values(
|
|
plugin_id="DemoPlugin",
|
|
normalized_plugin_id="demoplugin",
|
|
trusted_source_type="official",
|
|
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
|
binding_basis="official_default",
|
|
payload_source_type="official",
|
|
payload_source_key="github:jxxghp/moviepilot-plugins",
|
|
declared_version="1.0.0",
|
|
package_generation="v3",
|
|
system_version=">=3.0.0",
|
|
supports_v3=True,
|
|
supports_v3t=False,
|
|
payload_receipt="sha256:" + "0" * 64,
|
|
revision=1,
|
|
created_at="2026-08-25T12:00:00+00:00",
|
|
updated_at="2026-08-25T12:00:00+00:00",
|
|
bound_at="2026-08-25T12:00:00+00:00",
|
|
payload_applied_at="2026-08-25T12:00:00+00:00",
|
|
)
|
|
)
|
|
unknown_id = connection.execute(
|
|
table.insert().values(
|
|
plugin_id="UnknownPlugin",
|
|
normalized_plugin_id="unknownplugin",
|
|
trusted_source_type="unknown",
|
|
binding_basis="legacy_unbound",
|
|
payload_source_type="unknown",
|
|
revision=1,
|
|
created_at="2026-08-25T12:00:00+00:00",
|
|
updated_at="2026-08-25T12:00:00+00:00",
|
|
).returning(table.c.id)
|
|
).scalar_one()
|
|
|
|
declaration = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
DECLARATION_MIGRATION,
|
|
)
|
|
declaration.upgrade()
|
|
current = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
row = connection.execute(
|
|
sa.select(current).where(current.c.plugin_id == "DemoPlugin")
|
|
).mappings().one()
|
|
assert row["declared_metadata"] == {
|
|
"schema_version": 1,
|
|
"declaration_version": None,
|
|
"manifest_matches_payload": False,
|
|
"manifest": {"system_version": ">=3.0.0"},
|
|
"runtime": {"v3": True, "v3t": False},
|
|
}
|
|
assert connection.execute(
|
|
sa.select(current.c.id).where(current.c.declared_metadata.is_(None))
|
|
).scalars().all() == [unknown_id]
|
|
|
|
declaration.downgrade()
|
|
restored = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
restored_row = connection.execute(
|
|
sa.select(restored).where(restored.c.plugin_id == "DemoPlugin")
|
|
).mappings().one()
|
|
assert restored_row["system_version"] == ">=3.0.0"
|
|
assert restored_row["supports_v3"] is True
|
|
assert restored_row["supports_v3t"] is False
|
|
assert "declared_metadata" not in restored.c
|
|
|
|
|
|
def test_plugin_identity_declaration_migration_rejects_partial_legacy_schema(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""非 canonical 旧字段集合必须停止迁移,不能静默丢弃残余声明。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
base = _bind_migration(monkeypatch, connection)
|
|
base.upgrade()
|
|
with Operations(MigrationContext.configure(connection)).batch_alter_table(
|
|
"pluginidentity"
|
|
) as batch_op:
|
|
batch_op.drop_column("supports_v3t")
|
|
|
|
declaration = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
DECLARATION_MIGRATION,
|
|
)
|
|
with pytest.raises(RuntimeError, match="旧声明字段不完整"):
|
|
declaration.upgrade()
|
|
|
|
columns = {
|
|
column["name"]
|
|
for column in sa.inspect(connection).get_columns("pluginidentity")
|
|
}
|
|
assert "declared_metadata" not in columns
|
|
assert {"system_version", "supports_v3"} <= columns
|
|
|
|
|
|
def test_plugin_identity_migration_matches_postgresql_identity() -> None:
|
|
"""独立 Alembic 路径应保留 PostgreSQL 循环 Identity 主键。"""
|
|
migration = importlib.import_module(BASE_MIGRATION)
|
|
metadata = sa.MetaData()
|
|
table = sa.Table(
|
|
"pluginidentity",
|
|
metadata,
|
|
migration._id_column("postgresql"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
)
|
|
|
|
identity = table.c.id.identity
|
|
assert identity is not None
|
|
assert identity.start == 1
|
|
assert identity.cycle is True
|
|
ddl = str(CreateTable(table).compile(dialect=postgresql.dialect()))
|
|
assert "GENERATED BY DEFAULT AS IDENTITY" in ddl
|
|
assert "CYCLE" in ddl
|
|
|
|
|
|
def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
|
|
"""已配置的隔离 PostgreSQL 应执行真实建表、约束和回滚。"""
|
|
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
|
|
host = os.getenv(f"{prefix}HOST")
|
|
database = os.getenv(f"{prefix}DATABASE")
|
|
username = os.getenv(f"{prefix}USERNAME")
|
|
if not host or not database or not username:
|
|
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
|
|
|
|
port = os.getenv(f"{prefix}PORT", "5432")
|
|
password = os.getenv(f"{prefix}PASSWORD", "")
|
|
schema = f"plugin_identity_{uuid.uuid4().hex}"
|
|
with postgres_driver.connect(
|
|
host=host,
|
|
port=port,
|
|
dbname=database,
|
|
user=username,
|
|
password=password,
|
|
) as connection:
|
|
connection.autocommit = True
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
|
|
)
|
|
|
|
engine = None
|
|
try:
|
|
engine = sa.create_engine(
|
|
sa.URL.create(
|
|
POSTGRESQL_DIALECT,
|
|
username=username,
|
|
password=password,
|
|
host=host,
|
|
port=int(port),
|
|
database=database,
|
|
),
|
|
connect_args={"options": f"-csearch_path={schema}"},
|
|
)
|
|
with engine.begin() as connection:
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
installation = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
INSTALLATION_MIGRATION,
|
|
)
|
|
installation.upgrade()
|
|
legacy_table = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
inserted_id = connection.execute(
|
|
legacy_table.insert().values(
|
|
plugin_id="DemoPlugin",
|
|
normalized_plugin_id="demoplugin",
|
|
trusted_source_type="official",
|
|
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
|
binding_basis="official_default",
|
|
payload_source_type="official",
|
|
payload_source_key="github:jxxghp/moviepilot-plugins",
|
|
declared_version="1.0.0",
|
|
package_generation="v3",
|
|
system_version=">=3.0.0",
|
|
supports_v3=True,
|
|
supports_v3t=False,
|
|
payload_receipt="sha256:" + "0" * 64,
|
|
revision=1,
|
|
created_at="2026-08-25T12:00:00+00:00",
|
|
updated_at="2026-08-25T12:00:00+00:00",
|
|
bound_at="2026-08-25T12:00:00+00:00",
|
|
payload_applied_at="2026-08-25T12:00:00+00:00",
|
|
).returning(legacy_table.c.id)
|
|
).scalar_one()
|
|
assert inserted_id == 1
|
|
unknown_id = connection.execute(
|
|
legacy_table.insert().values(
|
|
plugin_id="UnknownPlugin",
|
|
normalized_plugin_id="unknownplugin",
|
|
trusted_source_type="unknown",
|
|
binding_basis="legacy_unbound",
|
|
payload_source_type="unknown",
|
|
revision=1,
|
|
created_at="2026-08-25T12:00:00+00:00",
|
|
updated_at="2026-08-25T12:00:00+00:00",
|
|
).returning(legacy_table.c.id)
|
|
).scalar_one()
|
|
assert unknown_id == 2
|
|
declaration = _bind_migration(
|
|
monkeypatch,
|
|
connection,
|
|
DECLARATION_MIGRATION,
|
|
)
|
|
declaration.upgrade()
|
|
declaration.upgrade()
|
|
|
|
table = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
row = connection.execute(
|
|
sa.select(table).where(table.c.id == inserted_id)
|
|
).mappings().one()
|
|
assert row["declared_metadata"] == {
|
|
"schema_version": 1,
|
|
"declaration_version": None,
|
|
"manifest_matches_payload": False,
|
|
"manifest": {"system_version": ">=3.0.0"},
|
|
"runtime": {"v3": True, "v3t": False},
|
|
}
|
|
unknown_row = connection.execute(
|
|
sa.select(table).where(table.c.id == unknown_id)
|
|
).mappings().one()
|
|
assert unknown_row["declared_metadata"] is None
|
|
|
|
with pytest.raises(sa.exc.IntegrityError):
|
|
with connection.begin_nested():
|
|
connection.execute(
|
|
table.insert().values(
|
|
plugin_id="UppercaseKey",
|
|
normalized_plugin_id="UppercaseKey",
|
|
trusted_source_type="unknown",
|
|
binding_basis="legacy_unbound",
|
|
payload_source_type="unknown",
|
|
revision=1,
|
|
created_at="2026-08-25T12:00:00+00:00",
|
|
updated_at="2026-08-25T12:00:00+00:00",
|
|
)
|
|
)
|
|
|
|
declaration.downgrade()
|
|
restored = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
restored_row = connection.execute(
|
|
sa.select(restored).where(restored.c.id == inserted_id)
|
|
).mappings().one()
|
|
assert restored_row["system_version"] == ">=3.0.0"
|
|
assert restored_row["supports_v3"] is True
|
|
assert restored_row["supports_v3t"] is False
|
|
|
|
declaration.upgrade()
|
|
reupgraded = sa.Table(
|
|
"pluginidentity",
|
|
sa.MetaData(),
|
|
autoload_with=connection,
|
|
)
|
|
reupgraded_row = connection.execute(
|
|
sa.select(reupgraded).where(reupgraded.c.id == inserted_id)
|
|
).mappings().one()
|
|
assert reupgraded_row["declared_metadata"]["runtime"] == {
|
|
"v3": True,
|
|
"v3t": False,
|
|
}
|
|
|
|
declaration.downgrade()
|
|
installation.downgrade()
|
|
migration.downgrade()
|
|
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
|
|
finally:
|
|
if engine is not None:
|
|
engine.dispose()
|
|
with postgres_driver.connect(
|
|
host=host,
|
|
port=port,
|
|
dbname=database,
|
|
user=username,
|
|
password=password,
|
|
) as connection:
|
|
connection.autocommit = True
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
|
|
sql.Identifier(schema)
|
|
)
|
|
)
|