Files
MoviePilot/tests/test_transfer_manual_review_api.py

279 lines
8.7 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 整理人工复核 API 的鉴权和公开契约。
"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.api.dependencies.auth import get_current_active_manage_user
from app.api.endpoints import transfer as transfer_endpoint
from app.application.transfer.execution import (
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewResult,
TransferStepResult,
)
from app.schemas.transfer import (
TransferManualReviewDiscardRequest,
TransferManualReviewRequest,
)
class _ManualReviewCommand:
"""
记录人工复核调用并返回可控结果。
"""
calls: list[tuple[object, dict]] = []
result: TransferManualReviewResult | None = None
error: Exception | None = None
def __init__(self, repository: object) -> None:
"""
保存端点取得的仓储替身。
"""
self._repository = repository
def resolve_manual_review(self, **kwargs) -> TransferManualReviewResult:
"""
记录参数,并按测试配置返回或抛出结果。
"""
self.calls.append((self._repository, kwargs))
if self.error:
raise self.error
assert self.result is not None
return self.result
def discard_manual_review(self, **kwargs) -> object:
"""记录不会触碰文件的放弃请求。"""
self.calls.append((self._repository, kwargs))
if self.error:
raise self.error
assert self.result is not None
return self.result
def _install_command(monkeypatch) -> object:
"""
构造不接触数据库的人工复核仓储并安装命令替身。
"""
repository = object()
_ManualReviewCommand.calls = []
_ManualReviewCommand.error = None
monkeypatch.setattr(
transfer_endpoint,
"TransferExecutionCommand",
_ManualReviewCommand,
)
return repository
@pytest.mark.parametrize(
"endpoint",
[
transfer_endpoint.list_transfer_manual_reviews,
transfer_endpoint.get_transfer_manual_review,
transfer_endpoint.resolve_transfer_manual_review,
],
)
def test_manual_review_endpoints_require_manage_permission(endpoint) -> None:
"""
人工复核发现、详情与判定必须复用全局 manage 权限依赖。
"""
dependency = inspect.signature(
endpoint
).parameters["current_user"].default.dependency
assert dependency is get_current_active_manage_user
@pytest.mark.parametrize(
("payload", "message"),
[
(
{"operation_id": "op-1", "decision": "failed", "reason": "确认失败"},
"Input should be 'not_applied' or 'applied'",
),
(
{"operation_id": "op-1", "decision": "applied", "reason": "已完成"},
"必须提供 result_payload",
),
(
{"operation_id": "op-1", "decision": "not_applied", "reason": "x" * 2001},
"String should have at most 2000 characters",
),
],
)
def test_manual_review_request_rejects_unsafe_decisions(
payload: dict,
message: str,
) -> None:
"""
公开 schema 不允许 FAILED,且 APPLIED 必须携带结果证据。
"""
with pytest.raises(ValidationError, match=message):
TransferManualReviewRequest.model_validate(payload)
@pytest.mark.parametrize("notes", [{}, {"reason": ""}, {"reason": " "}])
def test_manual_review_request_allows_optional_reason(notes: dict) -> None:
"""
省略、空白或空字符串说明均可提交,空白输入统一为空字符串。
"""
request = TransferManualReviewRequest.model_validate({
"operation_id": "op-1",
"decision": "not_applied",
**notes,
})
assert request.reason == ""
def test_manual_review_applied_wraps_result_and_hides_internal_state(
monkeypatch,
) -> None:
"""
APPLIED 应包装版本化结果,响应不得泄漏 lease 或 attempt。
"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-1",
operation_id="op-1",
decision=TransferManualReviewDecision.APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=4,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="applied",
reason="目标摘要匹配",
result_payload={"dest_exists": True, "hash_match": True},
),
current_user=SimpleNamespace(name=" admin ", username="other", id=7),
repository=repository,
)
assert _ManualReviewCommand.calls == [
(
repository,
{
"task_id": "task-1",
"operation_id": "op-1",
"decision": TransferManualReviewDecision.APPLIED,
"actor": "admin",
"reason": "目标摘要匹配",
"result": TransferStepResult(
payload={"dest_exists": True, "hash_match": True}
),
},
)
]
assert response.data is not None
assert response.data.model_dump() == {
"task_id": "task-1",
"operation_id": "op-1",
"decision": "applied",
"state": "retry_wait",
"review_revision": 4,
}
assert "lease" not in response.model_dump_json()
assert "attempt" not in response.model_dump_json()
def test_manual_review_not_applied_uses_username_fallback(monkeypatch) -> None:
"""
名称为空时应稳定回退到 username,并允许安全重新调度。
"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-2",
operation_id="op-2",
decision=TransferManualReviewDecision.NOT_APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=2,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-2",
review=TransferManualReviewRequest(
operation_id="op-2",
decision="not_applied",
reason="确认源文件仍存在",
),
current_user=SimpleNamespace(name="", username="reviewer", id=8),
repository=repository,
)
assert _ManualReviewCommand.calls[0][1]["actor"] == "reviewer"
assert _ManualReviewCommand.calls[0][1]["result"] is None
assert response.data is not None
assert response.data.state == "retry_wait"
def test_manual_review_conflict_returns_http_409(monkeypatch) -> None:
"""
重复或过期人工判定必须返回资源冲突而非伪成功。
"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.error = TransferExecutionConflictError("步骤已被判定")
with pytest.raises(HTTPException) as error:
transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="not_applied",
reason="重复判定",
),
current_user=SimpleNamespace(name=None, username=None, id=11),
repository=repository,
)
assert error.value.status_code == 409
assert error.value.detail == "整理任务状态已变化,请刷新后重试"
assert _ManualReviewCommand.calls[0][1]["actor"] == "11"
def test_discard_manual_review_only_releases_the_durable_task(monkeypatch) -> None:
"""放弃端点必须携带当前 operation,且不接受任何文件操作参数。"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-3",
operation_id="op-3",
discarded=True,
message="已放弃整理任务;未修改任何文件",
)
response = transfer_endpoint.discard_transfer_manual_review(
task_id="task-3",
review=TransferManualReviewDiscardRequest(operation_id="op-3", reason="外部状态无法确认"),
current_user=SimpleNamespace(name="reviewer", username=None, id=9),
repository=repository,
)
assert _ManualReviewCommand.calls == [
(repository, {
"task_id": "task-3",
"operation_id": "op-3",
"actor": "reviewer",
"reason": "外部状态无法确认",
}),
]
assert response.data is not None
assert response.data.model_dump() == {
"task_id": "task-3",
"operation_id": "op-3",
"discarded": True,
"message": "已放弃整理任务;未修改任何文件",
}