mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""验证旧插件同步整理 ABI 复用 canonical durable 终态写入口。"""
|
|
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
from app.application.transfer.execution import (
|
|
TransferExecutionCheckpoint,
|
|
TransferSettlementResult,
|
|
)
|
|
from app.chain.transfer import TransferChain
|
|
from app.domain.context import MediaInfo
|
|
from app.domain.meta.metabase import MetaBase
|
|
from app.schemas.file import FileItem
|
|
from app.schemas.transfer import TransferInfo
|
|
from app.schemas.types import MediaType
|
|
|
|
|
|
def _fileitem(*, fileid: str = "source-v1", size: int = 1024) -> FileItem:
|
|
"""构造可区分同路径版本的兼容调用文件项。"""
|
|
return FileItem(
|
|
storage="local",
|
|
path="/downloads/Movie.2026.mkv",
|
|
type="file",
|
|
name="Movie.2026.mkv",
|
|
basename="Movie.2026",
|
|
extension="mkv",
|
|
size=size,
|
|
modify_time=1770000000 + size,
|
|
fileid=fileid,
|
|
)
|
|
|
|
|
|
def _result(task, *, success: bool, overwrite_skipped: bool = False) -> TransferInfo:
|
|
"""按当前任务构造足以写历史的兼容整理结果。"""
|
|
return TransferInfo(
|
|
success=success,
|
|
overwrite_skipped=overwrite_skipped,
|
|
message=None if success else "copy failed",
|
|
fileitem=task.fileitem,
|
|
target_item=FileItem(
|
|
storage="local",
|
|
path="/library/Movie (2026)/Movie.mkv",
|
|
type="file",
|
|
name="Movie.mkv",
|
|
basename="Movie",
|
|
extension="mkv",
|
|
),
|
|
transfer_type="copy",
|
|
file_list=[task.fileitem.path],
|
|
)
|
|
|
|
|
|
def _compat_chain(result_factory):
|
|
"""构造只执行旧同步命令和 task-aware 结算的 TransferChain 骨架。"""
|
|
chain = object.__new__(TransferChain)
|
|
chain._worker_owner_id = "compat-owner"
|
|
chain._owned_leases = {}
|
|
chain._queued_lease_tokens = set()
|
|
chain._worker_state_lock = threading.RLock()
|
|
chain.durable_event_writer = Mock()
|
|
executed = []
|
|
|
|
def execute(task, **_kwargs):
|
|
"""模拟外部步骤已完成并建立可独立结算的执行检查点。"""
|
|
executed.append(task.fileitem.fileid)
|
|
task_id = f"task-{task.fileitem.fileid}"
|
|
lease_token = f"lease-{task.fileitem.fileid}"
|
|
task.bind_admission_task_id(task_id)
|
|
task.bind_execution_lease(
|
|
owner_id=chain._worker_owner_id,
|
|
lease_token=lease_token,
|
|
)
|
|
chain._owned_leases[task_id] = (lease_token, float("inf"))
|
|
result = result_factory(task)
|
|
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
|
|
payload={
|
|
"outcome": (
|
|
"overwrite_skipped"
|
|
if result.overwrite_skipped
|
|
else "succeeded" if result.success else "failed"
|
|
),
|
|
"transferinfo": result.model_dump(mode="json"),
|
|
},
|
|
operation_ids=(f"operation-{task.fileitem.fileid}",),
|
|
))
|
|
return result
|
|
|
|
chain._plan_checkpoint_and_execute = Mock(side_effect=execute)
|
|
return chain, executed
|
|
|
|
|
|
def _invoke(chain: TransferChain, fileitem: FileItem) -> TransferInfo:
|
|
"""以插件可见参数调用同步兼容入口。"""
|
|
return chain.execute_legacy_transfer_command(
|
|
fileitem=fileitem,
|
|
meta=MetaBase(fileitem.name),
|
|
mediainfo=MediaInfo(type=MediaType.MOVIE, title="Movie", year="2026"),
|
|
target_storage="local",
|
|
target_path="/library",
|
|
transfer_type="copy",
|
|
)
|
|
|
|
|
|
def _settlement_writer(*, status: bool, history_id: int = 41):
|
|
"""返回执行 stage_history 并产出 task-aware 结果的 writer side effect。"""
|
|
staged_payloads = []
|
|
|
|
def write(**kwargs):
|
|
"""暂存历史后返回与当前结算修订一致的投影。"""
|
|
staging = Mock()
|
|
|
|
def add_force(**payload):
|
|
"""保存历史 payload 并返回 writer 所需的最小记录。"""
|
|
staged_payloads.append(payload)
|
|
return SimpleNamespace(
|
|
id=history_id,
|
|
status=bool(payload["status"]),
|
|
src=payload["src"],
|
|
src_storage=payload["src_storage"],
|
|
src_fileitem=payload["src_fileitem"],
|
|
)
|
|
|
|
staging.add_force.side_effect = add_force
|
|
staging.get_success_by_src.return_value = SimpleNamespace(
|
|
id=history_id,
|
|
status=True,
|
|
src="/downloads/Movie.2026.mkv",
|
|
src_storage="local",
|
|
src_fileitem=_fileitem().model_dump(mode="json"),
|
|
)
|
|
history = kwargs["stage_history"](staging)
|
|
assert bool(history.status) is status
|
|
return TransferSettlementResult(
|
|
history_id=history.id,
|
|
settlement_revision=1,
|
|
pending_deleted=status,
|
|
)
|
|
|
|
return write, staged_payloads
|
|
|
|
|
|
def test_legacy_failed_result_uses_atomic_terminal_writer() -> None:
|
|
"""失败结果也必须原子写失败历史并保留 pending,不得直接注销。"""
|
|
chain, executed = _compat_chain(lambda task: _result(task, success=False))
|
|
write, staged_payloads = _settlement_writer(status=False)
|
|
chain.durable_event_writer.transfer_result.side_effect = write
|
|
|
|
returned = _invoke(chain, _fileitem())
|
|
|
|
assert returned.success is False
|
|
assert returned.message == "copy failed"
|
|
assert executed == ["source-v1"]
|
|
assert staged_payloads[0]["status"] == 0
|
|
call = chain.durable_event_writer.transfer_result.call_args.kwargs
|
|
assert call["topic"] is None
|
|
assert call["publish"] is None
|
|
assert call["settlement"].outcome == "failed"
|
|
assert call["settlement"].error == "copy failed"
|
|
|
|
|
|
def test_legacy_settlement_response_loss_replays_receipt_by_same_task_id() -> None:
|
|
"""首次提交后响应丢失只能用同一 task_id 回读,不得重做外部步骤。"""
|
|
chain, executed = _compat_chain(lambda task: _result(task, success=True))
|
|
write, staged_payloads = _settlement_writer(status=True)
|
|
writer_calls = 0
|
|
|
|
def response_lost_then_receipt(**kwargs):
|
|
"""首次提交历史后模拟响应丢失,第二次只返回 immutable receipt。"""
|
|
nonlocal writer_calls
|
|
writer_calls += 1
|
|
if writer_calls == 1:
|
|
write(**kwargs)
|
|
raise RuntimeError("response lost after commit")
|
|
return TransferSettlementResult(
|
|
history_id=41,
|
|
settlement_revision=1,
|
|
pending_deleted=True,
|
|
already_settled=True,
|
|
)
|
|
|
|
chain.durable_event_writer.transfer_result.side_effect = response_lost_then_receipt
|
|
|
|
returned = _invoke(chain, _fileitem())
|
|
|
|
assert returned.success is True
|
|
assert executed == ["source-v1"]
|
|
assert writer_calls == 2
|
|
assert len(staged_payloads) == 1
|
|
first = chain.durable_event_writer.transfer_result.call_args_list[0].kwargs
|
|
second = chain.durable_event_writer.transfer_result.call_args_list[1].kwargs
|
|
assert second["settlement"] == first["settlement"]
|
|
|
|
|
|
def test_legacy_settlement_double_failure_releases_claim_without_deleting_evidence(
|
|
) -> None:
|
|
"""两次 writer 均失败时必须释放 lease,并保留 pending 与步骤供恢复。"""
|
|
chain, executed = _compat_chain(lambda task: _result(task, success=True))
|
|
chain._transfer_admissions = Mock()
|
|
chain._transfer_admissions.release_claim.return_value = True
|
|
chain._TransferChain__ensure_recovery_scheduler = Mock()
|
|
chain.durable_event_writer.transfer_result.side_effect = RuntimeError(
|
|
"writer unavailable"
|
|
)
|
|
|
|
returned = _invoke(chain, _fileitem())
|
|
|
|
assert returned.success is False
|
|
assert "writer unavailable" in (returned.message or "")
|
|
assert executed == ["source-v1"]
|
|
assert chain.durable_event_writer.transfer_result.call_count == 2
|
|
chain._transfer_admissions.release_claim.assert_called_once_with(
|
|
task_id="task-source-v1",
|
|
lease_token="lease-source-v1",
|
|
error=(
|
|
"旧整理兼容命令 durable 终态结算失败:writer unavailable"
|
|
),
|
|
)
|
|
chain._transfer_admissions.abandon_unstarted.assert_not_called()
|
|
assert chain._owned_leases == {}
|
|
|
|
|
|
def test_legacy_overwrite_skip_binds_existing_success_in_atomic_writer() -> None:
|
|
"""覆盖跳过复用既有成功历史并以 succeeded 终态结算。"""
|
|
chain, executed = _compat_chain(
|
|
lambda task: _result(task, success=False, overwrite_skipped=True)
|
|
)
|
|
write, staged_payloads = _settlement_writer(status=True)
|
|
chain.durable_event_writer.transfer_result.side_effect = write
|
|
|
|
success_history = SimpleNamespace(id=41, status=True)
|
|
history_port = SimpleNamespace(
|
|
get_by_src=lambda _src, storage=None: success_history,
|
|
)
|
|
with patch(
|
|
"app.chain.transfer.get_chain_transfer_history_port",
|
|
return_value=history_port,
|
|
):
|
|
returned = _invoke(chain, _fileitem())
|
|
|
|
assert returned.success is False
|
|
assert returned.overwrite_skipped is True
|
|
assert executed == ["source-v1"]
|
|
assert staged_payloads == []
|
|
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
|
|
"settlement"
|
|
]
|
|
assert settlement.outcome == "succeeded"
|
|
assert settlement.error is None
|
|
|
|
|
|
def test_legacy_overwrite_skip_without_success_history_settles_failed() -> None:
|
|
"""兼容入口没有既有成功历史时按失败结算并保留 pending。"""
|
|
chain, executed = _compat_chain(
|
|
lambda task: _result(task, success=False, overwrite_skipped=True)
|
|
)
|
|
write, staged_payloads = _settlement_writer(status=False)
|
|
chain.durable_event_writer.transfer_result.side_effect = write
|
|
history_port = SimpleNamespace(
|
|
get_by_src=lambda _src, storage=None: None,
|
|
)
|
|
|
|
with patch(
|
|
"app.chain.transfer.get_chain_transfer_history_port",
|
|
return_value=history_port,
|
|
):
|
|
returned = _invoke(chain, _fileitem())
|
|
|
|
assert returned.success is False
|
|
assert returned.overwrite_skipped is True
|
|
assert executed == ["source-v1"]
|
|
assert len(staged_payloads) == 1
|
|
assert staged_payloads[0]["status"] == 0
|
|
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
|
|
"settlement"
|
|
]
|
|
assert settlement.outcome == "failed"
|
|
assert settlement.error == "copy failed"
|
|
|
|
|
|
def test_legacy_same_path_changed_version_still_executes_new_task() -> None:
|
|
"""兼容入口不能仅凭同源历史吞掉 fileid/size/mtime 已变化的新版本。"""
|
|
chain, executed = _compat_chain(lambda task: _result(task, success=True))
|
|
write, _staged_payloads = _settlement_writer(status=True)
|
|
chain.durable_event_writer.transfer_result.side_effect = write
|
|
|
|
first = _invoke(chain, _fileitem(fileid="source-v1", size=1024))
|
|
second = _invoke(chain, _fileitem(fileid="source-v2", size=2048))
|
|
|
|
assert first.success is True
|
|
assert second.success is True
|
|
assert executed == ["source-v1", "source-v2"]
|