mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
279 lines
8.7 KiB
Python
279 lines
8.7 KiB
Python
"""
|
||
验证 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": "已放弃整理任务;未修改任何文件",
|
||
}
|