Files
MoviePilot/tests/test_media_classification_transfer_checkpoint.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

506 lines
17 KiB
Python
Raw Permalink 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.
"""
Durable transfer 分类快照的版本化冻结与离线恢复合同。
"""
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from app.application.classification.reference import EffectiveClassificationSnapshot
from app.application.transfer import workflow as transfer_application
from app.application.transfer.execution import build_transfer_checkpoint_fingerprint
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.category import ClassificationResult, ClassificationSelection
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
from tests.test_transfer_planning_checkpoint import (
_bind_checkpoint,
_bind_planning_input,
_chain,
_checkpoint,
_planned_admission,
_planning_input,
_resolved_checkpoint,
_source_snapshot,
_task,
)
FROZEN_CLASSIFICATION = {
"category_id": "movie.frozen",
"library_category": "电影/动画",
"classification_rule_id": "rule.frozen",
"classification_policy_revision": 17,
"classification_source": "manual",
}
EMPTY_CLASSIFICATION = {
"category_id": None,
"library_category": None,
"classification_rule_id": None,
"classification_policy_revision": None,
"classification_source": None,
}
def _classified_media(
*,
category_id: str = "movie.effective",
category_path: tuple[str, ...] = ("电影", "生效分类"),
rule_id: str = "rule.effective",
revision: int = 42,
source: str = "automatic",
) -> MediaInfo:
"""
构造同时包含推荐、生效和描述性分类的最终媒体对象。
"""
return MediaInfo(
media_source=MediaSource.TMDB,
media_id="123",
type=MediaType.MOVIE,
title="Frozen Movie",
library_category="/".join(category_path),
metadata_category="metadata/不得冻结",
classification=ClassificationResult(
recommended=ClassificationSelection(
category_id="movie.recommended",
category_path=["电影", "推荐分类"],
rule_id="rule.recommended",
source="automatic",
),
effective=ClassificationSelection(
category_id=category_id,
category_path=list(category_path),
rule_id=rule_id,
source=source,
),
policy_revision=revision,
state="complete",
),
)
def _v2_checkpoint(
*,
classification_snapshot: dict[str, object] = FROZEN_CLASSIFICATION,
resolved_mediainfo: dict[str, object] | None = None,
):
"""
从现有 planned fixture 构造带顶层分类快照的 v2 检查点。
"""
payload = _resolved_checkpoint().to_payload()
payload["schema_version"] = 2
payload["classification_snapshot"] = dict(classification_snapshot)
if resolved_mediainfo is not None:
payload["resolved_mediainfo"] = resolved_mediainfo
return transfer_application.TransferPlanCheckpoint.from_payload(payload)
def _provider_v2_checkpoint(
classification_snapshot: dict[str, object] = FROZEN_CLASSIFICATION,
):
"""
构造冻结旧 provider 调用和分类事实的 provider_pending 检查点。
"""
resolved = _resolved_checkpoint()
invocation = transfer_application.TransferProviderInvocationSnapshot(
fileitem=_source_snapshot(),
meta=resolved.resolved_meta,
meta_kind=resolved.resolved_meta_kind,
mediainfo=_classified_media().to_dict(),
mediainfo_kind="MediaInfo",
target_directory={
"library_storage": "local",
"library_path": "/library",
"transfer_type": "copy",
},
target_storage="local",
target_path="/library",
transfer_type="copy",
scrape=True,
library_type_folder=False,
library_category_folder=True,
episodes_info=(),
preview=False,
)
provider = transfer_application.TransferProviderReference(
plugin_id="FrozenProvider",
plugin_name="冻结分类插件",
)
checkpoint = transfer_application.TransferPlanCheckpoint(
planning_input=resolved.planning_input,
target_storage="",
root_target_path="",
final_target_path="",
resolved_transfer_type="",
items=(),
resolved_meta=invocation.meta,
resolved_meta_kind=invocation.meta_kind,
resolved_mediainfo=invocation.mediainfo,
resolved_mediainfo_kind=invocation.mediainfo_kind,
legacy_transfer_providers=(provider,),
provider_invocation=invocation,
)
payload = checkpoint.to_payload()
payload["schema_version"] = 2
payload["classification_snapshot"] = dict(classification_snapshot)
return transfer_application.TransferPlanCheckpoint.from_payload(payload)
def _assert_classification_snapshot(
checkpoint,
expected: dict[str, object],
*,
expect_payload: bool = True,
) -> EffectiveClassificationSnapshot:
"""
断言类型化快照,并按版本要求核对五字段 JSON 投影。
"""
snapshot = getattr(checkpoint, "classification_snapshot")
assert isinstance(snapshot, EffectiveClassificationSnapshot)
assert {
"category_id": snapshot.category_id,
"library_category": snapshot.path,
"classification_rule_id": snapshot.rule_id,
"classification_policy_revision": snapshot.policy_revision,
"classification_source": snapshot.source,
} == expected
payload = checkpoint.to_payload()
if expect_payload:
assert payload["classification_snapshot"] == expected
else:
assert "classification_snapshot" not in payload
return snapshot
def _capture_checkpoint_repository(task, captured: list[object]) -> Mock:
"""
构造回读持久投影并记录每次 CAS 提交内容的仓储替身。
"""
repository = Mock()
def checkpoint_plan(**kwargs):
"""
记录待持久检查点,并模拟仓储 JSON 往返后的 planned 投影。
"""
checkpoint = kwargs["checkpoint"]
persisted = transfer_application.TransferPlanCheckpoint.from_payload(
checkpoint.to_payload()
)
captured.append(persisted)
return _planned_admission(task, persisted)
repository.checkpoint_plan.side_effect = checkpoint_plan
return repository
def test_v2_classification_snapshot_round_trip_preserves_all_five_fields() -> None:
"""
v2 JSON 往返必须保留分类 ID、路径、规则、revision 和来源。
"""
checkpoint = _v2_checkpoint()
restored = transfer_application.TransferPlanCheckpoint.from_payload(
checkpoint.to_payload()
)
assert restored.schema_version == 2
_assert_classification_snapshot(restored, FROZEN_CLASSIFICATION)
assert restored.fingerprint == checkpoint.fingerprint
@pytest.mark.parametrize(
"unsafe_path",
("../逃逸", "/绝对路径", "电影\\逃逸"),
)
def test_v2_classification_snapshot_rejects_unsafe_relative_paths(
unsafe_path: str,
) -> None:
"""
v2 快照不得接受目录穿越、绝对路径或非 POSIX 分隔符。
"""
payload = _resolved_checkpoint().to_payload()
payload["schema_version"] = 2
payload["classification_snapshot"] = {
**FROZEN_CLASSIFICATION,
"library_category": unsafe_path,
}
with pytest.raises(ValueError, match="分类.*路径|library_category|相对路径"):
transfer_application.TransferPlanCheckpoint.from_payload(payload)
def test_host_checkpoint_creation_freezes_effective_selection_only() -> None:
"""
宿主计划只冻结 effective,不得使用 recommended 或 metadata_category。
"""
captured: list[object] = []
task = _task()
task.meta = MetaBase("Frozen.Movie.2026.mkv")
task.mediainfo = _classified_media()
task.bind_admission_task_id("task-host-classification")
_bind_planning_input(task, _planning_input())
repository = _capture_checkpoint_repository(task, captured)
chain = _chain(repository=repository, checkpoint=_checkpoint())
chain._plan_checkpoint_and_execute(task)
assert len(captured) == 1
_assert_classification_snapshot(
captured[0],
{
"category_id": "movie.effective",
"library_category": "电影/生效分类",
"classification_rule_id": "rule.effective",
"classification_policy_revision": 42,
"classification_source": "automatic",
},
)
def test_provider_pending_creation_freezes_effective_selection_only() -> None:
"""
provider_pending 必须在插件副作用前冻结与宿主相同的 effective 分类。
"""
captured: list[object] = []
task = _task()
task.meta = MetaBase("Frozen.Movie.2026.mkv")
task.mediainfo = _classified_media(source="subscription")
task.bind_admission_task_id("task-provider-classification")
_bind_planning_input(task, _planning_input())
repository = _capture_checkpoint_repository(task, captured)
chain = _chain(repository=repository)
chain._module_dispatcher.freeze_plugin_providers.return_value = (
SimpleNamespace(
plugin_id="FrozenProvider",
plugin_name="冻结分类插件",
method="transfer",
),
)
chain._module_dispatcher.execute_frozen_plugin_providers.return_value = (
TransferInfo(
success=True,
fileitem=task.fileitem,
transfer_type="copy",
)
)
chain._plan_checkpoint_and_execute(task)
assert len(captured) == 1
assert captured[0].is_provider_pending is True
_assert_classification_snapshot(
captured[0],
{
"category_id": "movie.effective",
"library_category": "电影/生效分类",
"classification_rule_id": "rule.effective",
"classification_policy_revision": 42,
"classification_source": "subscription",
},
)
def test_planned_replay_restores_top_level_snapshot_without_live_dependencies() -> None:
"""
策略、识别和目录服务漂移或失效时,planned 恢复仍使用顶层旧分类。
"""
drifted_media = _classified_media(
category_id="movie.drifted",
category_path=("电影", "新策略"),
rule_id="rule.drifted",
revision=99,
)
checkpoint = _v2_checkpoint(resolved_mediainfo=drifted_media.to_dict())
task = _task()
task.meta = MetaBase("Drifted.Movie.2026.mkv")
task.mediainfo = drifted_media
task.bind_admission_task_id("task-classification-replay")
_bind_planning_input(task, checkpoint.planning_input)
_bind_checkpoint(task, checkpoint)
chain = _chain(repository=Mock(), checkpoint=checkpoint)
chain.jobview = Mock()
chain._TransferChain__finish_scrape_batch_task = Mock()
def unavailable(*_args, **_kwargs):
"""
确保 planned 恢复没有读取任何当前在线分类依赖。
"""
raise AssertionError("planned replay 不得读取当前识别、目录或分类策略")
with (
patch("app.chain.transfer.execution.MediaChain", side_effect=unavailable),
patch("app.chain.transfer.execution.DirectoryHelper", side_effect=unavailable),
patch(
"app.application.classification.reference.classification_category_resolver_snapshot",
side_effect=unavailable,
),
):
result = chain._TransferChain__handle_transfer(task)
assert result[0] is True
execute_call = chain.execute_transfer_plan.call_args
restored_media = execute_call.kwargs["mediainfo"]
assert restored_media.library_category == "电影/动画"
assert restored_media.classification is not None
assert restored_media.classification.policy_revision == 17
assert restored_media.classification.effective == ClassificationSelection(
category_id="movie.frozen",
category_path=["电影", "动画"],
rule_id="rule.frozen",
source="manual",
)
chain.plan_transfer.assert_not_called()
chain._transfer_admissions.checkpoint_plan.assert_not_called()
def test_provider_pending_host_promotion_preserves_original_snapshot() -> None:
"""
provider 未接管后的宿主升级只能沿用已提交分类,不得按新策略改写。
"""
provider_checkpoint = _provider_v2_checkpoint()
promoted: list[object] = []
task = _task()
task.meta = MetaBase("Drifted.Movie.2026.mkv")
task.mediainfo = _classified_media(
category_id="movie.drifted",
category_path=("电影", "漂移分类"),
rule_id="rule.drifted",
revision=99,
)
task.bind_admission_task_id("task-provider-promotion")
_bind_planning_input(task, provider_checkpoint.planning_input)
_bind_checkpoint(task, provider_checkpoint)
repository = _capture_checkpoint_repository(task, promoted)
host_checkpoint = replace(
_checkpoint(),
planning_input=provider_checkpoint.planning_input,
)
chain = _chain(repository=repository, checkpoint=host_checkpoint)
chain._module_dispatcher.execute_frozen_plugin_providers.return_value = None
chain._plan_checkpoint_and_execute(task)
assert len(promoted) == 1
assert promoted[0].is_provider_pending is False
_assert_classification_snapshot(promoted[0], FROZEN_CLASSIFICATION)
executed = chain.execute_transfer_plan.call_args.args[0]
_assert_classification_snapshot(executed, FROZEN_CLASSIFICATION)
def test_v1_payload_derives_snapshot_from_persisted_effective_media() -> None:
"""
v1 双读只能从自身媒体 payload 的 effective 构造冻结分类。
"""
payload = _resolved_checkpoint().to_payload()
payload["schema_version"] = 1
payload.pop("classification_snapshot", None)
payload["resolved_mediainfo"] = _classified_media(
category_id="movie.v1",
category_path=("电影", "旧检查点"),
rule_id="rule.v1",
revision=8,
source="subscription",
).to_dict()
restored = transfer_application.TransferPlanCheckpoint.from_payload(payload)
assert restored.schema_version == 1
_assert_classification_snapshot(
restored,
{
"category_id": "movie.v1",
"library_category": "电影/旧检查点",
"classification_rule_id": "rule.v1",
"classification_policy_revision": 8,
"classification_source": "subscription",
},
expect_payload=False,
)
def test_v1_payload_ignores_recommended_and_uses_legacy_library_path() -> None:
"""
v1 无 effective 时不得冻结推荐或元数据分类,只兼容安全库路径。
"""
media = _classified_media()
assert media.classification is not None
media.classification.effective = None
media.library_category = "电影/旧目录"
media.metadata_category = "metadata/禁止使用"
payload = _resolved_checkpoint().to_payload()
payload["schema_version"] = 1
payload.pop("classification_snapshot", None)
payload["resolved_mediainfo"] = media.to_dict()
restored = transfer_application.TransferPlanCheckpoint.from_payload(payload)
_assert_classification_snapshot(
restored,
{
"category_id": None,
"library_category": "电影/旧目录",
"classification_rule_id": None,
"classification_policy_revision": None,
"classification_source": "legacy",
},
expect_payload=False,
)
@pytest.mark.parametrize(
"unsafe_path",
("../逃逸", "/绝对路径", "电影\\逃逸"),
)
def test_v1_unsafe_classification_is_ignored_without_changing_plan_identity(
unsafe_path: str,
) -> None:
"""
v1 历史脏分类应降级为空快照,同时保持原 payload 与计划指纹。
"""
media = _classified_media()
assert media.classification is not None
media.classification.effective = None
media.library_category = unsafe_path
payload = _resolved_checkpoint().to_payload()
payload["schema_version"] = 1
payload.pop("classification_snapshot", None)
payload["resolved_mediainfo"] = media.to_dict()
expected_fingerprint = build_transfer_checkpoint_fingerprint(payload)
restored = transfer_application.TransferPlanCheckpoint.from_payload(payload)
_assert_classification_snapshot(
restored,
EMPTY_CLASSIFICATION,
expect_payload=False,
)
assert restored.to_payload() == payload
assert restored.fingerprint == expected_fingerprint
def test_recognition_rejection_persists_explicit_empty_classification_snapshot() -> None:
"""
尚无媒体对象的确定性拒绝也必须保存五字段显式空快照。
"""
captured: list[object] = []
task = _task()
task.meta = MetaBase("Unrecognized.Movie.2026.mkv")
task.mediainfo = None
task.bind_admission_task_id("task-empty-classification-rejection")
planning_input = replace(_planning_input(), mediainfo=None)
_bind_planning_input(task, planning_input)
repository = _capture_checkpoint_repository(task, captured)
chain = _chain(repository=repository)
result = chain._TransferChain__checkpoint_planning_rejection(
task,
"未识别到媒体信息",
)
assert result.success is False
assert len(captured) == 1
assert captured[0].rejection_error == "未识别到媒体信息"
_assert_classification_snapshot(captured[0], EMPTY_CLASSIFICATION)