mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""
|
|
分类持久化字段 Alembic 迁移测试。
|
|
"""
|
|
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
from typing import Any, Protocol, cast
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
from sqlalchemy.engine import Connection
|
|
|
|
from app.application.history import DownloadHistoryWrite, TransferHistoryWrite
|
|
from app.application.server.share import ServerSharingService
|
|
from app.application.subscription.contract import (
|
|
SubscriptionHistoryPatch,
|
|
SubscriptionHistorySnapshot,
|
|
SubscriptionPatch,
|
|
SubscriptionSnapshot,
|
|
)
|
|
from app.db.adapters.history.download import _project_history as project_download_history
|
|
from app.db.adapters.history.transfer import project_transfer_history
|
|
from app.db.adapters.subscription import _project_history as project_subscription_history
|
|
from app.db.adapters.subscription import _project_subscription
|
|
from app.db.models.downloadhistory import DownloadHistory
|
|
from app.db.models.subscribe import Subscribe
|
|
from app.db.models.subscribehistory import SubscribeHistory
|
|
from app.db.models.transferhistory import TransferHistory
|
|
from app.schemas.history import (
|
|
DownloadHistory as DownloadHistorySchema,
|
|
)
|
|
from app.schemas.history import (
|
|
TransferHistory as TransferHistorySchema,
|
|
)
|
|
from app.schemas.query import DownloadHistorySnapshot as DownloadHistoryQuery
|
|
from app.schemas.query import SubscriptionHistorySnapshot as SubscriptionHistoryQuery
|
|
from app.schemas.query import SubscriptionSnapshot as SubscriptionQuery
|
|
from app.schemas.query import TransferHistorySnapshot as TransferHistoryQuery
|
|
from app.schemas.subscribe import Subscribe as SubscribeSchema
|
|
from app.schemas.subscribe import SubscribeShare
|
|
|
|
_MIGRATION = "database.versions.c9a4d7e2f1b6_3_0_27"
|
|
_HISTORY_FIELDS = {
|
|
"media_category_id",
|
|
"classification_rule_id",
|
|
"classification_policy_revision",
|
|
"classification_source",
|
|
}
|
|
_EXPECTED_FIELDS = {
|
|
"subscribe": {"media_category_id"},
|
|
"subscribehistory": _HISTORY_FIELDS,
|
|
"downloadhistory": _HISTORY_FIELDS,
|
|
"transferhistory": _HISTORY_FIELDS,
|
|
}
|
|
_LEGACY_PATH_FIELDS = {
|
|
"subscribe": "media_category",
|
|
"subscribehistory": "media_category",
|
|
"downloadhistory": "media_category",
|
|
"transferhistory": "category",
|
|
}
|
|
|
|
|
|
class _MigrationModule(Protocol):
|
|
"""
|
|
声明测试使用的分类字段迁移接口。
|
|
|
|
:ivar revision: 待验证迁移的版本标识
|
|
:ivar down_revision: 该迁移依赖的上一版本标识
|
|
:ivar op: 绑定测试数据库连接的 Alembic 操作入口
|
|
"""
|
|
|
|
revision: str
|
|
down_revision: str
|
|
op: Any
|
|
|
|
def upgrade(self) -> None:
|
|
"""
|
|
执行升级迁移。
|
|
"""
|
|
|
|
def downgrade(self) -> None:
|
|
"""
|
|
执行降级迁移。
|
|
"""
|
|
|
|
|
|
def _bind_migration(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
connection: Connection,
|
|
) -> _MigrationModule:
|
|
"""
|
|
把分类字段迁移绑定到隔离 SQLite 连接。
|
|
"""
|
|
migration = cast(_MigrationModule, importlib.import_module(_MIGRATION))
|
|
monkeypatch.setattr(
|
|
migration,
|
|
"op",
|
|
Operations(MigrationContext.configure(connection)),
|
|
)
|
|
return migration
|
|
|
|
|
|
def _create_legacy_tables(connection: Connection) -> None:
|
|
"""
|
|
创建仅含兼容路径字段和旧记录的四张历史版本表。
|
|
"""
|
|
metadata = sa.MetaData()
|
|
for table_name, path_field in _LEGACY_PATH_FIELDS.items():
|
|
table = sa.Table(
|
|
table_name,
|
|
metadata,
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(path_field, sa.String(), nullable=True),
|
|
)
|
|
connection.execute(sa.schema.CreateTable(table))
|
|
connection.execute(table.insert().values(id=1, **{path_field: f"legacy/{table_name}"}))
|
|
|
|
|
|
def _columns(connection: Connection, table_name: str) -> dict[str, dict[str, Any]]:
|
|
"""
|
|
按字段名返回指定表的反射结构。
|
|
"""
|
|
return {str(column["name"]): column for column in sa.inspect(connection).get_columns(table_name)}
|
|
|
|
|
|
def _legacy_row(connection: Connection, table_name: str) -> dict[str, Any]:
|
|
"""
|
|
返回迁移前写入的单条兼容路径记录。
|
|
"""
|
|
table = sa.Table(table_name, sa.MetaData(), autoload_with=connection)
|
|
return dict(connection.execute(sa.select(table)).mappings().one())
|
|
|
|
|
|
def test_classification_persistence_migration_round_trip_is_replay_safe(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
迁移应可重复升降级,并始终保留旧路径且不伪造分类事实。
|
|
"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
_create_legacy_tables(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
for table_name, expected_fields in _EXPECTED_FIELDS.items():
|
|
columns = _columns(connection, table_name)
|
|
assert expected_fields.issubset(columns)
|
|
assert all(columns[field]["nullable"] for field in expected_fields)
|
|
row = _legacy_row(connection, table_name)
|
|
assert row[_LEGACY_PATH_FIELDS[table_name]] == f"legacy/{table_name}"
|
|
assert all(row[field] is None for field in expected_fields)
|
|
|
|
migration.downgrade()
|
|
migration.downgrade()
|
|
|
|
for table_name, removed_fields in _EXPECTED_FIELDS.items():
|
|
columns = _columns(connection, table_name)
|
|
assert removed_fields.isdisjoint(columns)
|
|
row = _legacy_row(connection, table_name)
|
|
assert row[_LEGACY_PATH_FIELDS[table_name]] == f"legacy/{table_name}"
|
|
|
|
migration.upgrade()
|
|
|
|
for table_name, expected_fields in _EXPECTED_FIELDS.items():
|
|
row = _legacy_row(connection, table_name)
|
|
assert all(row[field] is None for field in expected_fields)
|
|
|
|
|
|
def test_classification_persistence_models_match_nullable_schema() -> None:
|
|
"""
|
|
ORM 应声明活动稳定引用和三类历史的完整可空分类快照。
|
|
"""
|
|
models = {
|
|
"subscribe": Subscribe,
|
|
"subscribehistory": SubscribeHistory,
|
|
"downloadhistory": DownloadHistory,
|
|
"transferhistory": TransferHistory,
|
|
}
|
|
|
|
for table_name, expected_fields in _EXPECTED_FIELDS.items():
|
|
columns = models[table_name].__table__.c
|
|
assert expected_fields.issubset(columns.keys())
|
|
assert all(columns[field].nullable for field in expected_fields)
|
|
|
|
|
|
def test_classification_persistence_application_contracts_are_complete() -> None:
|
|
"""
|
|
活动订阅只接收稳定引用,历史与写 DTO 保留完整分类事实。
|
|
"""
|
|
active_patch = SubscriptionPatch(
|
|
{
|
|
"media_category_id": "movie.drama",
|
|
"media_category": "电影/剧情",
|
|
}
|
|
)
|
|
assert active_patch.to_payload() == {
|
|
"media_category_id": "movie.drama",
|
|
"media_category": "电影/剧情",
|
|
}
|
|
with pytest.raises(ValueError, match="未知字段"):
|
|
SubscriptionPatch({"classification_rule_id": "rule.drama"})
|
|
|
|
history_values = {
|
|
"name": "示例电影",
|
|
"media_category_id": "movie.drama",
|
|
"media_category": "电影/剧情",
|
|
"classification_rule_id": "rule.drama",
|
|
"classification_policy_revision": 7,
|
|
"classification_source": "automatic",
|
|
}
|
|
history_patch = SubscriptionHistoryPatch.from_subscription(history_values)
|
|
assert history_patch.to_payload() == history_values
|
|
|
|
active_snapshot = SubscriptionSnapshot(
|
|
id=1,
|
|
name="示例电影",
|
|
media_category_id="movie.drama",
|
|
media_category="电影/剧情",
|
|
)
|
|
history_snapshot = SubscriptionHistorySnapshot(id=1, **history_values)
|
|
assert active_snapshot.media_category_id == "movie.drama"
|
|
assert history_snapshot.classification_policy_revision == 7
|
|
|
|
download_payload = DownloadHistoryWrite(
|
|
path="/downloads/example",
|
|
type="电影",
|
|
title="示例电影",
|
|
media_category_id="movie.drama",
|
|
media_category="电影/剧情",
|
|
classification_rule_id="rule.drama",
|
|
classification_policy_revision=7,
|
|
classification_source="automatic",
|
|
).to_payload()
|
|
transfer_payload = TransferHistoryWrite(
|
|
src="/downloads/example.mkv",
|
|
media_category_id="movie.drama",
|
|
category="电影/剧情",
|
|
classification_rule_id="rule.drama",
|
|
classification_policy_revision=7,
|
|
classification_source="automatic",
|
|
).to_payload()
|
|
assert _HISTORY_FIELDS.issubset(download_payload)
|
|
assert _HISTORY_FIELDS.issubset(transfer_payload)
|
|
|
|
|
|
def test_classification_persistence_adapters_project_all_fields() -> None:
|
|
"""
|
|
四个 ORM 投影边界应原样复制稳定引用和历史分类事实。
|
|
"""
|
|
classification_values = {
|
|
"media_category_id": "movie.drama",
|
|
"classification_rule_id": "rule.drama",
|
|
"classification_policy_revision": 7,
|
|
"classification_source": "automatic",
|
|
}
|
|
active = _project_subscription(
|
|
Subscribe(
|
|
id=1,
|
|
name="示例电影",
|
|
state="N",
|
|
media_category_id="movie.drama",
|
|
media_category="电影/剧情",
|
|
)
|
|
)
|
|
subscription_history = project_subscription_history(
|
|
SubscribeHistory(
|
|
id=1,
|
|
name="示例电影",
|
|
media_category="电影/剧情",
|
|
**classification_values,
|
|
)
|
|
)
|
|
download_history = project_download_history(
|
|
SimpleNamespace(
|
|
id=1,
|
|
path="/downloads/example",
|
|
type="电影",
|
|
title="示例电影",
|
|
media_category="电影/剧情",
|
|
**classification_values,
|
|
)
|
|
)
|
|
transfer_history = project_transfer_history(
|
|
SimpleNamespace(
|
|
id=1,
|
|
category="电影/剧情",
|
|
**classification_values,
|
|
)
|
|
)
|
|
|
|
assert active.media_category_id == "movie.drama"
|
|
for snapshot in (subscription_history, download_history, transfer_history):
|
|
assert snapshot.media_category_id == "movie.drama"
|
|
assert snapshot.classification_rule_id == "rule.drama"
|
|
assert snapshot.classification_policy_revision == 7
|
|
assert snapshot.classification_source == "automatic"
|
|
|
|
|
|
def test_classification_persistence_public_schemas_are_complete() -> None:
|
|
"""
|
|
REST、分享和插件查询模型应公开各自需要的分类字段。
|
|
"""
|
|
history_schemas = (
|
|
SubscribeSchema,
|
|
DownloadHistorySchema,
|
|
TransferHistorySchema,
|
|
SubscriptionHistoryQuery,
|
|
DownloadHistoryQuery,
|
|
TransferHistoryQuery,
|
|
)
|
|
for schema in history_schemas:
|
|
assert _HISTORY_FIELDS.issubset(schema.model_fields)
|
|
|
|
assert "media_category_id" in SubscribeSchema.model_fields
|
|
assert "media_category_id" in SubscribeShare.model_fields
|
|
assert "media_category_id" in SubscriptionQuery.model_fields
|
|
assert "media_category_id" in ServerSharingService.SUBSCRIBE_FIELDS
|
|
assert (_HISTORY_FIELDS - {"media_category_id"}).issubset(SubscribeSchema.PUBLIC_WRITE_EXCLUDED_FIELDS)
|
|
|
|
|
|
def test_classification_persistence_migration_revision_chain() -> None:
|
|
"""
|
|
迁移版本应接在系统配置唯一性 3.0.26 revision 之后。
|
|
"""
|
|
migration = cast(_MigrationModule, importlib.import_module(_MIGRATION))
|
|
|
|
assert migration.revision == "c9a4d7e2f1b6"
|
|
assert migration.down_revision == "b6e1f8a3c9d2"
|