Files
MoviePilot/tests/test_transfer_planning_migration.py
T

393 lines
14 KiB
Python

"""整理规划输入与检查点字段的 Alembic 迁移测试。"""
import importlib
import json
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.application.transfer.workflow import TransferPlanningInput
from app.db.models.transferpending import TransferPending
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"
PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
POST_LEASE_EXECUTION_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
def _bind_migration(monkeypatch, connection, module_name=PLANNING_MIGRATION):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(module_name)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _create_admission_table(connection) -> None:
"""创建 3.0.13 时代的持久准入表。"""
metadata = sa.MetaData()
table = sa.Table(
"transferpending",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("storage", sa.String(), nullable=False),
sa.Column("src_path", sa.String(), nullable=False),
sa.Column("created_at", sa.String(), nullable=True),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("updated_at", sa.String(40), nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.UniqueConstraint("task_id", name="uq_transferpending_task_id"),
)
sa.Index(
"ux_transferpending_storage_path",
table.c.storage,
table.c.src_path,
unique=True,
)
sa.Index(
"ix_transferpending_state_created",
table.c.state,
table.c.created_at,
table.c.id,
)
metadata.create_all(connection)
connection.execute(table.insert(), {
"id": 1,
"task_id": "stable-task",
"storage": "local",
"src_path": "/downloads/Movie.mkv",
"created_at": "2026-08-27 10:00:00",
"state": "accepted",
"updated_at": "2026-08-27 10:00:00",
"last_error": "previous enqueue failure",
})
def _planning_row(connection) -> dict[str, object]:
"""读取单条规划持久字段快照。"""
return dict(connection.execute(sa.text(
"SELECT task_id, state, input_version, planning_input, "
"input_fingerprint, checkpoint_version, checkpoint_payload, planned_at "
"FROM transferpending WHERE id = 1"
)).mappings().one())
def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
"""断言规划迁移在当前隔离连接上的完整可逆生命周期。"""
_create_admission_table(connection)
planning_migration = _bind_migration(monkeypatch, connection)
lease_migration = _bind_migration(monkeypatch, connection, LEASE_MIGRATION)
planning_migration.upgrade()
planning_migration.upgrade()
lease_migration.upgrade()
lease_migration.upgrade()
inspector = sa.inspect(connection)
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
upgraded = _planning_row(connection)
planning_payload = upgraded["planning_input"]
if isinstance(planning_payload, str):
planning_payload = json.loads(planning_payload)
planning_input = TransferPlanningInput.from_payload(planning_payload)
assert planning_input.source_fileitem == {
"storage": "local",
"path": "/downloads/Movie.mkv",
}
assert planning_input.options == {"legacy_replan": True}
assert upgraded["input_version"] == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
assert upgraded["checkpoint_payload"] is None
assert upgraded["state"] == "accepted"
pending = sa.table(
"transferpending",
sa.column("id", sa.Integer()),
sa.column("state", sa.String()),
sa.column("checkpoint_version", sa.Integer()),
sa.column("checkpoint_payload", sa.JSON()),
sa.column("planned_at", sa.String()),
)
connection.execute(
pending.update()
.where(pending.c.id == 1)
.values(
state="planned",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1},
planned_at="2026-08-27 11:00:00",
)
)
lease_migration.downgrade()
lease_migration.downgrade()
planning_migration.downgrade()
downgraded = sa.inspect(connection)
assert {
column["name"]
for column in downgraded.get_columns("transferpending")
} == {
"id", "task_id", "storage", "src_path", "created_at",
"state", "updated_at", "last_error",
}
legacy = connection.execute(sa.text(
"SELECT task_id, state FROM transferpending WHERE id = 1"
)).mappings().one()
assert dict(legacy) == {"task_id": "stable-task", "state": "accepted"}
assert {
index["name"]
for index in downgraded.get_indexes("transferpending")
} == {"ix_transferpending_state_created", "ux_transferpending_storage_path"}
planning_migration.upgrade()
lease_migration.upgrade()
reupgraded = _planning_row(connection)
assert reupgraded["task_id"] == "stable-task"
assert reupgraded["state"] == "accepted"
assert reupgraded["checkpoint_payload"] is None
assert reupgraded["input_fingerprint"] == planning_input.fingerprint
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
def test_transfer_planning_upgrade_downgrade_reupgrade(monkeypatch) -> None:
"""SQLite 应支持规划字段重复升级、降级和再次升级。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_assert_upgrade_downgrade_reupgrade(connection, monkeypatch)
def test_provider_pending_downgrade_restores_accepted_on_sqlite(monkeypatch) -> None:
"""旧版本无法解释 provider 快照,降级时必须保守恢复为 accepted。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_admission_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"UPDATE transferpending "
"SET state = 'provider_pending', checkpoint_version = 1, "
"checkpoint_payload = '{\"schema_version\": 1}' "
"WHERE id = 1"
))
migration.downgrade()
assert connection.execute(sa.text(
"SELECT state FROM transferpending WHERE id = 1"
)).scalar_one() == "accepted"
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {
"id", "task_id", "storage", "src_path", "created_at",
"state", "updated_at", "last_error",
}
def test_partial_upgrade_preserves_existing_planning_json(monkeypatch) -> None:
"""迁移中断后重跑应补齐版本和指纹,不得覆盖已经写入的完整输入。"""
engine = sa.create_engine("sqlite://")
planning_input = TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": "/downloads/Movie.mkv",
"type": "file",
},
target_storage="local",
target_path="/library/Movies",
requested_transfer_type="copy",
options={"manual": True},
)
with engine.begin() as connection:
_create_admission_table(connection)
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN planning_input JSON"
))
pending = sa.table(
"transferpending",
sa.column("id", sa.Integer()),
sa.column("planning_input", sa.JSON()),
)
connection.execute(
pending.update()
.where(pending.c.id == 1)
.values(planning_input=planning_input.to_payload())
)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
upgraded = _planning_row(connection)
payload = upgraded["planning_input"]
if isinstance(payload, str):
payload = json.loads(payload)
assert TransferPlanningInput.from_payload(payload) == planning_input
assert upgraded["input_version"] == planning_input.schema_version
assert upgraded["input_fingerprint"] == planning_input.fingerprint
connection.execute(sa.text(
"UPDATE transferpending SET state = 'future-state' WHERE id = 1"
))
migration.downgrade()
assert connection.execute(sa.text(
"SELECT state FROM transferpending WHERE id = 1"
)).scalar_one() == "future-state"
def test_partial_upgrade_recomputes_inconsistent_planning_identity(monkeypatch) -> None:
"""部分升级留下的版本和指纹必须按最终 JSON 重算,不能保留伪身份。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_admission_table(connection)
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN input_version INTEGER"
))
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN input_fingerprint VARCHAR(64)"
))
connection.execute(sa.text(
"UPDATE transferpending SET input_version = 99, "
"input_fingerprint = 'bogus' WHERE id = 1"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
upgraded = _planning_row(connection)
payload = upgraded["planning_input"]
if isinstance(payload, str):
payload = json.loads(payload)
planning_input = TransferPlanningInput.from_payload(payload)
assert upgraded["input_version"] == planning_input.schema_version == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
assert upgraded["input_fingerprint"] != "bogus"
def test_replayed_upgrade_repairs_complete_but_mismatched_identity(monkeypatch) -> None:
"""重复执行升级也必须修复完整三元组中与 payload 不一致的旧值。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_admission_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"UPDATE transferpending SET input_version = 7, "
"input_fingerprint = 'stale' WHERE id = 1"
))
migration.upgrade()
upgraded = _planning_row(connection)
payload = upgraded["planning_input"]
if isinstance(payload, str):
payload = json.loads(payload)
planning_input = TransferPlanningInput.from_payload(payload)
assert upgraded["input_version"] == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
def test_transfer_planning_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"transfer_planning_{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:
_assert_upgrade_downgrade_reupgrade(connection, monkeypatch)
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)
)
)