"""整理规划输入与原子检查点持久化测试。""" from dataclasses import replace import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from app.application.transfer import ( TRANSFER_ADMISSION_ACCEPTED, TRANSFER_ADMISSION_PLANNED, TRANSFER_ADMISSION_PROVIDER_PENDING, TransferAdmissionConflictError, TransferAdmissionProjectionError, TransferPlanCheckpoint, TransferPlanItem, TransferPlanningInput, TransferPlanningStateError, TransferProviderInvocationSnapshot, TransferProviderReference, ) from app.db.adapters.transfer import TransactionalTransferAdmissionRepository from app.db.models.transferpending import TransferPending @pytest.fixture def repository(tmp_path): """创建只服务单个测试的 SQLite 整理计划仓储。""" engine = create_engine(f"sqlite:///{tmp_path / 'transfer-planning.db'}") TransferPending.__table__.create(engine) return TransactionalTransferAdmissionRepository(sessionmaker(bind=engine)) def _claim(repository, task_id: str): """为需要变更规划状态的测试取得独占租约。""" claimed = repository.claim_task( task_id=task_id, owner_id="planning-test-worker", lease_seconds=3600, ) assert claimed is not None assert claimed.lease_token return claimed def _pending_snapshot(repository, task_id: str) -> dict[str, object]: """使用隔离 Session 冻结测试所需的持久状态字段。""" with repository._session_factory() as session: # noqa: SLF001 pending = session.execute( select(TransferPending).where(TransferPending.task_id == task_id) ).scalar_one() return { "state": pending.state, "last_error": pending.last_error, "checkpoint_payload": pending.checkpoint_payload, } def _planning_input(*, target_path: str = "/library/Movies") -> TransferPlanningInput: """构造包含恢复所需媒体上下文的完整规划输入。""" return TransferPlanningInput( source_fileitem={ "storage": "local", "path": "/downloads/Movie.2026.mkv", "type": "file", "size": 1024, }, meta={"name": "Movie", "year": 2026}, mediainfo={"title": "Movie", "tmdb_id": 42}, target_directory={"storage": "local", "path": "/library"}, target_storage="local", target_path=target_path, requested_transfer_type="copy", media_source="themoviedb", media_id="42", media_type="电影", need_scrape=True, need_rename=True, need_notify=True, overwrite_mode="always", episodes_info=({"season_number": 1, "episode_number": 1},), options={"username": "admin", "download_hash": "hash-1"}, ) def _checkpoint(planning_input: TransferPlanningInput) -> TransferPlanCheckpoint: """构造可直接执行且不会再次触发 rename 的计划检查点。""" return TransferPlanCheckpoint( planning_input=planning_input, target_storage="local", root_target_path="/library", final_target_path="/library/Movies/Movie (2026)/Movie.mkv", resolved_transfer_type="copy", items=( TransferPlanItem( sequence=0, source_fileitem=planning_input.source_fileitem, target_storage="local", target_path="/library/Movies/Movie (2026)/Movie.mkv", ), ), resolved_meta=planning_input.meta, resolved_meta_kind="MetaVideo", resolved_mediainfo=planning_input.mediainfo, resolved_mediainfo_kind="MediaInfo", resolved_episodes_info=planning_input.episodes_info, legacy_transfer_providers=( TransferProviderReference( plugin_id="builtin-filemanager", plugin_name="FileManager", ), TransferProviderReference( plugin_id="plugin-provider-a", plugin_name="Provider A", ), ), need_scrape=True, need_rename=False, need_notify=True, overwrite_mode="always", ) def _provider_checkpoint( planning_input: TransferPlanningInput, ) -> TransferPlanCheckpoint: """构造只冻结旧 ABI、尚未生成宿主文件计划的检查点。""" invocation = TransferProviderInvocationSnapshot( fileitem=planning_input.source_fileitem, meta=planning_input.meta, meta_kind="MetaVideo", mediainfo=planning_input.mediainfo, mediainfo_kind="MediaInfo", target_directory={ "library_storage": "local", "library_path": "/library/Movies", "transfer_type": "copy", }, target_storage="local", target_path=None, transfer_type=None, scrape=None, library_type_folder=False, library_category_folder=None, episodes_info=planning_input.episodes_info, preview=False, ) return TransferPlanCheckpoint( planning_input=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, resolved_episodes_info=invocation.episodes_info, legacy_transfer_providers=( TransferProviderReference( plugin_id="plugin-provider-a", plugin_name="Provider A", ), ), provider_invocation=invocation, ) def test_planning_dtos_round_trip_versioned_json() -> None: """输入和检查点应完整往返 JSON,并保留有序叶操作。""" planning_input = _planning_input() checkpoint = _checkpoint(planning_input) restored_input = TransferPlanningInput.from_payload(planning_input.to_payload()) restored_checkpoint = TransferPlanCheckpoint.from_payload(checkpoint.to_payload()) assert restored_input == planning_input assert restored_input.fingerprint == planning_input.fingerprint assert restored_checkpoint == checkpoint assert [item.sequence for item in restored_checkpoint.items] == [0] assert restored_checkpoint.planning_input == planning_input assert restored_checkpoint.resolved_mediainfo["tmdb_id"] == 42 assert restored_checkpoint.resolved_meta_kind == "MetaVideo" assert restored_checkpoint.resolved_mediainfo_kind == "MediaInfo" assert restored_checkpoint.resolved_episodes_info == planning_input.episodes_info assert restored_checkpoint.legacy_transfer_providers == ( TransferProviderReference("builtin-filemanager", "FileManager"), TransferProviderReference("plugin-provider-a", "Provider A"), ) assert restored_checkpoint.legacy_transfer_providers[0].method == "transfer" def test_provider_invocation_snapshot_round_trip_preserves_optional_values() -> None: """旧 ABI 快照往返 JSON 后必须保留 None、False 和自动目录原始值。""" checkpoint = _provider_checkpoint(_planning_input()) restored = TransferPlanCheckpoint.from_payload(checkpoint.to_payload()) assert restored == checkpoint assert restored.is_provider_pending is True assert restored.provider_invocation.target_path is None assert restored.provider_invocation.transfer_type is None assert restored.provider_invocation.scrape is None assert restored.provider_invocation.library_type_folder is False assert restored.provider_invocation.library_category_folder is None invalid_payload = checkpoint.to_payload() invalid_payload["provider_invocation"]["schema_version"] = 0 with pytest.raises(ValueError, match="调用快照版本"): TransferPlanCheckpoint.from_payload(invalid_payload) def test_legacy_checkpoint_payload_defaults_resolved_context() -> None: """旧检查点缺少 resolved 字段时仍应恢复为兼容空快照。""" payload = _checkpoint(_planning_input()).to_payload() for key in ( "resolved_meta", "resolved_meta_kind", "resolved_mediainfo", "resolved_mediainfo_kind", "resolved_episodes_info", "legacy_transfer_providers", ): payload.pop(key) restored = TransferPlanCheckpoint.from_payload(payload) assert restored.resolved_meta is None assert restored.resolved_meta_kind is None assert restored.resolved_mediainfo is None assert restored.resolved_mediainfo_kind is None assert restored.resolved_episodes_info == () assert restored.legacy_transfer_providers == () @pytest.mark.parametrize( ("plugin_id", "plugin_name", "method"), [ ("", "Provider A", "transfer"), (" ", "Provider A", "transfer"), ("provider-a", "", "transfer"), ("provider-a", " ", "transfer"), ("provider-a", "Provider A", "delete"), ], ) def test_transfer_provider_reference_rejects_invalid_fields( plugin_id, plugin_name, method, ) -> None: """旧 provider 引用必须具有稳定插件身份且只能指向 transfer 方法。""" with pytest.raises(ValueError, match="provider"): TransferProviderReference( plugin_id=plugin_id, plugin_name=plugin_name, method=method, ) def test_checkpoint_rejects_duplicate_legacy_provider_plugin_id() -> None: """同一 checkpoint 不得以不同名称重复冻结同一插件身份。""" with pytest.raises(ValueError, match="plugin_id.*重复"): replace( _checkpoint(_planning_input()), legacy_transfer_providers=( TransferProviderReference("provider-a", "Provider A"), TransferProviderReference("provider-a", "Provider A Renamed"), ), ) def test_checkpoint_rejects_non_array_legacy_provider_payload() -> None: """JSON 恢复边界不得把单个对象等非数组值当成 provider 序列。""" payload = _checkpoint(_planning_input()).to_payload() payload["legacy_transfer_providers"] = { "plugin_id": "provider-a", "plugin_name": "Provider A", } with pytest.raises(ValueError, match="legacy_transfer_providers"): TransferPlanCheckpoint.from_payload(payload) def test_resolved_context_does_not_change_admission_fingerprint(repository) -> None: """规划后上下文属于 checkpoint,不得反向改变已提交的准入指纹。""" planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) checkpoint = replace( _checkpoint(planning_input), resolved_meta={"name": "Resolved Movie", "year": 2026}, resolved_meta_kind="MetaAnime", resolved_mediainfo={"title": "Resolved Movie", "tmdb_id": 84}, resolved_mediainfo_kind="MediaInfo", resolved_episodes_info=({"season_number": 2, "episode_number": 3},), ) planned = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=checkpoint, ) assert planned.input_fingerprint == planning_input.fingerprint assert planned.planning_input == planning_input assert planned.checkpoint.resolved_meta_kind == "MetaAnime" assert planned.checkpoint.resolved_mediainfo["tmdb_id"] == 84 with pytest.raises(TransferPlanningStateError): repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=replace( checkpoint, resolved_mediainfo={"title": "Different", "tmdb_id": 85}, ), ) def test_admit_reuses_identical_input_and_rejects_conflict(repository) -> None: """同一源文件只允许复用完全相同的规划输入。""" planning_input = _planning_input() first = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) repeated = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=TransferPlanningInput.from_payload(planning_input.to_payload()), ) assert repeated == first with pytest.raises(TransferAdmissionConflictError): repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=_planning_input(target_path="/other-library"), ) def test_checkpoint_atomically_advances_and_is_idempotent(repository) -> None: """完整计划和 planned 状态应同事务提交且允许相同检查点重试。""" planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) checkpoint = _checkpoint(planning_input) planned = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=checkpoint, ) repeated = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=checkpoint, ) assert planned.state == TRANSFER_ADMISSION_PLANNED assert planned.checkpoint == checkpoint assert planned.checkpoint.items[0].target_path.endswith("Movie.mkv") assert tuple( provider.plugin_id for provider in planned.checkpoint.legacy_transfer_providers ) == ( "builtin-filemanager", "plugin-provider-a", ) assert repeated == planned assert _pending_snapshot(repository, admitted.task_id)["state"] == ( TRANSFER_ADMISSION_PLANNED ) def test_provider_pending_checkpoint_atomically_upgrades_to_host_plan(repository) -> None: """崩溃可恢复的 provider 快照只能经 CAS 升级为宿主 planned 计划。""" planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) provider_checkpoint = _provider_checkpoint(planning_input) provider_pending = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=provider_checkpoint, ) assert provider_pending.state == TRANSFER_ADMISSION_PROVIDER_PENDING assert provider_pending.checkpoint == provider_checkpoint repository.record_planning_failure( task_id=admitted.task_id, lease_token=claimed.lease_token, error="host planning unavailable", ) assert repository.release_claim( task_id=admitted.task_id, lease_token=claimed.lease_token, error="host planning unavailable", ) failed = repository.claim_recoverable( owner_id="planning-recovery-worker", limit=1, lease_seconds=3600, )[0] assert failed.state == TRANSFER_ADMISSION_PROVIDER_PENDING assert failed.checkpoint == provider_checkpoint assert failed.last_error == "host planning unavailable" host_checkpoint = _checkpoint(planning_input) planned = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=failed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=host_checkpoint, ) repeated = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=failed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=host_checkpoint, ) assert planned.state == TRANSFER_ADMISSION_PLANNED assert planned.checkpoint == host_checkpoint assert planned.last_error is None assert repeated == planned with pytest.raises(TransferPlanningStateError): repository.checkpoint_plan( task_id=admitted.task_id, lease_token=failed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=provider_checkpoint, ) def test_checkpoint_rejects_fingerprint_without_partial_state(repository) -> None: """错误输入指纹不能写入部分计划或改变 accepted 状态。""" planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) with pytest.raises(TransferAdmissionConflictError): repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint="0" * 64, checkpoint=_checkpoint(planning_input), ) recovered = _pending_snapshot(repository, admitted.task_id) assert recovered["state"] == TRANSFER_ADMISSION_ACCEPTED assert recovered["checkpoint_payload"] is None def test_planning_failure_stays_accepted_until_success(repository) -> None: """规划失败只留痕,后续成功规划应清错并原子推进状态。""" planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) repository.record_planning_failure( task_id=admitted.task_id, lease_token=claimed.lease_token, error="rename failed", ) failed = _pending_snapshot(repository, admitted.task_id) assert failed["state"] == TRANSFER_ADMISSION_ACCEPTED assert failed["last_error"] == "rename failed" assert failed["checkpoint_payload"] is None planned = repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=_checkpoint(planning_input), ) assert planned.state == TRANSFER_ADMISSION_PLANNED assert planned.last_error is None def test_checkpoint_rejects_missing_task(repository) -> None: """不存在的稳定任务身份不能凭空创建已规划记录。""" planning_input = _planning_input() with pytest.raises(TransferPlanningStateError): repository.checkpoint_plan( task_id="missing", lease_token="missing-token", input_fingerprint=planning_input.fingerprint, checkpoint=_checkpoint(planning_input), ) def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None: """兼容直接构造 ORM 行时也必须生成匹配路径的版本化输入与指纹。""" engine = create_engine(f"sqlite:///{tmp_path / 'orm-defaults.db'}") factory = sessionmaker(bind=engine) TransferPending.__table__.create(engine) with factory() as session: pending = TransferPending( storage="local", src_path="/downloads/legacy.mkv", state=TRANSFER_ADMISSION_ACCEPTED, created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", ) session.add(pending) session.commit() task_id = pending.task_id repository = TransactionalTransferAdmissionRepository(factory) admitted = repository.claim_task( task_id=task_id, owner_id="legacy-projection-worker", lease_seconds=3600, ) assert admitted is not None assert admitted.planning_input == TransferPlanningInput.legacy( storage="local", src_path="/downloads/legacy.mkv", ) assert admitted.input_fingerprint == admitted.planning_input.fingerprint engine.dispose() def test_projection_rejects_input_version_and_fingerprint_corruption(tmp_path) -> None: """列版本、JSON 和指纹任一不一致时都不得返回伪冻结 DTO。""" engine = create_engine(f"sqlite:///{tmp_path / 'input-corruption.db'}") factory = sessionmaker(bind=engine) TransferPending.__table__.create(engine) repository = TransactionalTransferAdmissionRepository(factory) planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) with factory() as session: row = session.execute( select(TransferPending).where(TransferPending.task_id == admitted.task_id) ).scalar_one() row.input_version = 2 session.commit() with pytest.raises(TransferAdmissionProjectionError, match="版本"): repository.claim_task( task_id=admitted.task_id, owner_id="corruption-worker", lease_seconds=3600, ) with factory() as session: row = session.execute( select(TransferPending).where(TransferPending.task_id == admitted.task_id) ).scalar_one() row.input_version = 1 corrupted = planning_input.to_payload() corrupted["media_id"] = "different" row.planning_input = corrupted session.commit() with pytest.raises(TransferAdmissionProjectionError, match="指纹"): repository.claim_task( task_id=admitted.task_id, owner_id="corruption-worker", lease_seconds=3600, ) engine.dispose() def test_projection_rejects_checkpoint_version_corruption(tmp_path) -> None: """planned 行的列版本与自包含 checkpoint JSON 必须严格一致。""" engine = create_engine(f"sqlite:///{tmp_path / 'checkpoint-corruption.db'}") factory = sessionmaker(bind=engine) TransferPending.__table__.create(engine) repository = TransactionalTransferAdmissionRepository(factory) planning_input = _planning_input() admitted = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = _claim(repository, admitted.task_id) repository.checkpoint_plan( task_id=admitted.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=_checkpoint(planning_input), ) with factory() as session: row = session.execute( select(TransferPending).where(TransferPending.task_id == admitted.task_id) ).scalar_one() row.checkpoint_version = 2 row.lease_expires_at = "2000-01-01 00:00:00.000000" session.commit() with pytest.raises(TransferAdmissionProjectionError, match="版本"): repository.claim_task( task_id=admitted.task_id, owner_id="direct-corruption-test-worker", lease_seconds=3600, ) assert repository.claim_recoverable( owner_id="batch-corruption-test-worker", limit=1, lease_seconds=3600, ) == [] engine.dispose()