feat(client): add Admin Gateway mock contract (#10)
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
"""Client 访问 Admin 的稳定边界和简单数据对象。
|
||||||
|
|
||||||
|
AdminGateway 只有领取任务、提交成功结果、提交失败结果三个业务方法。
|
||||||
|
业务层不应直接依赖 HTTP 请求或 Mock 的内部实现。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from .task_models import TaskType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClientInfo:
|
||||||
|
"""发起领取请求的 Client 身份,不保存访问令牌。"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.client_id.strip():
|
||||||
|
raise ValueError("client_id 不能为空")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AndroidDeviceInfo:
|
||||||
|
"""领取任务时上报的 Android 设备信息。"""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
platform: str = "android"
|
||||||
|
pdd_package: str = "com.xunmeng.pinduoduo"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.address.strip():
|
||||||
|
raise ValueError("设备地址不能为空")
|
||||||
|
if self.platform != "android":
|
||||||
|
raise ValueError("当前只支持 android 平台")
|
||||||
|
if not self.pdd_package.strip():
|
||||||
|
raise ValueError("PDD 包名不能为空")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClaimCapabilities:
|
||||||
|
"""Client 领取任务时声明的设备与执行能力。"""
|
||||||
|
|
||||||
|
device: AndroidDeviceInfo
|
||||||
|
supported_types: Tuple[TaskType, ...] = (
|
||||||
|
TaskType.COLLECT,
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
)
|
||||||
|
purchase_mode: str = "dry_run"
|
||||||
|
schema_versions: Tuple[int, ...] = (1,)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.supported_types:
|
||||||
|
raise ValueError("supported_types 不能为空")
|
||||||
|
if any(not isinstance(value, TaskType) for value in self.supported_types):
|
||||||
|
raise ValueError("supported_types 必须使用 TaskType")
|
||||||
|
if self.purchase_mode not in {"dry_run", "live"}:
|
||||||
|
raise ValueError("purchase_mode 只能是 dry_run 或 live")
|
||||||
|
if not self.schema_versions or any(
|
||||||
|
version <= 0 for version in self.schema_versions
|
||||||
|
):
|
||||||
|
raise ValueError("schema_versions 必须是正整数")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdminTask:
|
||||||
|
"""Admin 派发给 Client 的一个任务。"""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
task_type: TaskType
|
||||||
|
version: int
|
||||||
|
priority: int
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = ""
|
||||||
|
updated_at: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.task_id.strip():
|
||||||
|
raise ValueError("task_id 不能为空")
|
||||||
|
if not isinstance(self.task_type, TaskType):
|
||||||
|
raise ValueError("task_type 必须使用 TaskType")
|
||||||
|
if self.version <= 0:
|
||||||
|
raise ValueError("version 必须大于 0")
|
||||||
|
if not isinstance(self.payload, Mapping):
|
||||||
|
raise ValueError("payload 必须是对象")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubmissionReceipt:
|
||||||
|
"""Admin 已接收并保存一次提交的确认。"""
|
||||||
|
|
||||||
|
accepted: bool
|
||||||
|
result_id: str
|
||||||
|
accepted_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGatewayError(RuntimeError):
|
||||||
|
"""带稳定错误代码和可重试标志的 Admin 边界错误。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
retryable: bool,
|
||||||
|
request_id: str = "",
|
||||||
|
details: Optional[Mapping[str, Any]] = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.retryable = retryable
|
||||||
|
self.request_id = request_id
|
||||||
|
self.details = dict(details or {})
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGateway(ABC):
|
||||||
|
"""Admin 边界;不得增加状态查询、心跳或租约方法。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def claim_next(
|
||||||
|
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||||
|
) -> Optional[AdminTask]:
|
||||||
|
"""领取至多一个分配给当前 Client 的任务。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def submit_result(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
result: Mapping[str, Any],
|
||||||
|
) -> SubmissionReceipt:
|
||||||
|
"""幂等提交采集或采购成功结果。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def submit_failure(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
failure: Mapping[str, Any],
|
||||||
|
) -> SubmissionReceipt:
|
||||||
|
"""幂等提交失败、取消或人工处理结果。"""
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""不访问网络的 AdminGateway 测试实现。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .admin_gateway import (
|
||||||
|
AdminGateway,
|
||||||
|
AdminGatewayError,
|
||||||
|
AdminTask,
|
||||||
|
ClaimCapabilities,
|
||||||
|
ClientInfo,
|
||||||
|
SubmissionReceipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FAILURE_STATUSES = {"retry_wait", "manual_review", "failed", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
"""返回精确到秒的 UTC 时间。"""
|
||||||
|
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _QueuedTask:
|
||||||
|
task: AdminTask
|
||||||
|
assigned_client_id: str
|
||||||
|
claimed: bool = False
|
||||||
|
cancelled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MockAdminGateway(AdminGateway):
|
||||||
|
"""支持任务派发、故障模拟和幂等提交的内存 Mock。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tasks: List[_QueuedTask] = []
|
||||||
|
self._claimed_task_ids = set()
|
||||||
|
self._submissions: Dict[
|
||||||
|
str, Tuple[str, SubmissionReceipt]
|
||||||
|
] = {}
|
||||||
|
self._next_error: Optional[AdminGatewayError] = None
|
||||||
|
self._reject_next_submission = False
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def enqueue_task(self, task: AdminTask, assigned_client_id: str) -> None:
|
||||||
|
"""测试辅助:加入一条分配给指定 Client 的任务。"""
|
||||||
|
|
||||||
|
if not assigned_client_id.strip():
|
||||||
|
raise ValueError("assigned_client_id 不能为空")
|
||||||
|
with self._lock:
|
||||||
|
if any(item.task.task_id == task.task_id for item in self._tasks):
|
||||||
|
raise ValueError(f"任务 {task.task_id} 已存在")
|
||||||
|
self._tasks.append(
|
||||||
|
_QueuedTask(deepcopy(task), assigned_client_id.strip())
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel_task(self, task_id: str) -> None:
|
||||||
|
"""测试辅助:模拟 Admin 在任务派发后取消任务。"""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
item = self._find_task(task_id)
|
||||||
|
if item is None:
|
||||||
|
raise ValueError(f"任务 {task_id} 不存在")
|
||||||
|
item.cancelled = True
|
||||||
|
|
||||||
|
def timeout_next_call(self) -> None:
|
||||||
|
"""测试辅助:让下一次 Gateway 调用模拟网络超时。"""
|
||||||
|
|
||||||
|
self._next_error = AdminGatewayError(
|
||||||
|
"ADMIN_TIMEOUT", "Admin 请求超时", True
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_next_call_temporarily(self) -> None:
|
||||||
|
"""测试辅助:让下一次调用模拟 Admin 暂时故障。"""
|
||||||
|
|
||||||
|
self._next_error = AdminGatewayError(
|
||||||
|
"ADMIN_UNAVAILABLE", "Admin 暂时不可用", True
|
||||||
|
)
|
||||||
|
|
||||||
|
def reject_next_submission(self) -> None:
|
||||||
|
"""测试辅助:让下一次提交模拟结果校验失败。"""
|
||||||
|
|
||||||
|
self._reject_next_submission = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def submission_count(self) -> int:
|
||||||
|
"""返回已接受的不同幂等提交数量。"""
|
||||||
|
|
||||||
|
return len(self._submissions)
|
||||||
|
|
||||||
|
def claim_next(
|
||||||
|
self, client: ClientInfo, capabilities: ClaimCapabilities
|
||||||
|
) -> Optional[AdminTask]:
|
||||||
|
with self._lock:
|
||||||
|
self._raise_forced_error()
|
||||||
|
for item in self._tasks:
|
||||||
|
if item.claimed:
|
||||||
|
continue
|
||||||
|
if item.assigned_client_id != client.client_id:
|
||||||
|
continue
|
||||||
|
if item.task.task_type not in capabilities.supported_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item.claimed = True
|
||||||
|
self._claimed_task_ids.add(item.task.task_id)
|
||||||
|
return deepcopy(item.task)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def submit_result(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
result: Mapping[str, Any],
|
||||||
|
) -> SubmissionReceipt:
|
||||||
|
return self._submit("result", task_id, idempotency_key, result)
|
||||||
|
|
||||||
|
def submit_failure(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
failure: Mapping[str, Any],
|
||||||
|
) -> SubmissionReceipt:
|
||||||
|
return self._submit("failure", task_id, idempotency_key, failure)
|
||||||
|
|
||||||
|
def _submit(
|
||||||
|
self,
|
||||||
|
submission_type: str,
|
||||||
|
task_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> SubmissionReceipt:
|
||||||
|
with self._lock:
|
||||||
|
self._raise_forced_error()
|
||||||
|
if self._reject_next_submission:
|
||||||
|
self._reject_next_submission = False
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "Admin 拒绝了无效结果", False
|
||||||
|
)
|
||||||
|
|
||||||
|
task = self._validate_submission_target(task_id, idempotency_key)
|
||||||
|
if submission_type == "result":
|
||||||
|
self._validate_result(task, payload)
|
||||||
|
else:
|
||||||
|
self._validate_failure(task, payload)
|
||||||
|
|
||||||
|
fingerprint = self._fingerprint(
|
||||||
|
submission_type, task_id, payload
|
||||||
|
)
|
||||||
|
previous = self._submissions.get(idempotency_key)
|
||||||
|
if previous is not None:
|
||||||
|
old_fingerprint, receipt = previous
|
||||||
|
if old_fingerprint != fingerprint:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"IDEMPOTENCY_CONFLICT",
|
||||||
|
"相同幂等键提交了不同内容",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
receipt = SubmissionReceipt(
|
||||||
|
accepted=True,
|
||||||
|
result_id=str(uuid4()),
|
||||||
|
accepted_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
self._submissions[idempotency_key] = (fingerprint, receipt)
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
def _validate_submission_target(
|
||||||
|
self, task_id: str, idempotency_key: str
|
||||||
|
) -> AdminTask:
|
||||||
|
if not task_id.strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_TASK_INVALID", "task_id 不能为空", False
|
||||||
|
)
|
||||||
|
if not idempotency_key.strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_IDEMPOTENCY_KEY_INVALID", "幂等键不能为空", False
|
||||||
|
)
|
||||||
|
item = self._find_task(task_id)
|
||||||
|
if item is None or task_id not in self._claimed_task_ids:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_TASK_NOT_ASSIGNED",
|
||||||
|
"任务未派发给当前 Client",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
# item.cancelled 故意不参与判断:已派发任务即使取消也必须接收。
|
||||||
|
return item.task
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_result(task: AdminTask, result: Mapping[str, Any]) -> None:
|
||||||
|
MockAdminGateway._require_mapping(result, "result")
|
||||||
|
MockAdminGateway._validate_common_submission(task, result)
|
||||||
|
if result.get("result_type") != task.task_type.value:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "result_type 与任务类型不一致", False
|
||||||
|
)
|
||||||
|
if not str(result.get("completed_at", "")).strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "completed_at 不能为空", False
|
||||||
|
)
|
||||||
|
if not isinstance(result.get("pdd_data"), Mapping):
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "pdd_data 必须是对象", False
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_failure(task: AdminTask, failure: Mapping[str, Any]) -> None:
|
||||||
|
MockAdminGateway._require_mapping(failure, "failure")
|
||||||
|
MockAdminGateway._validate_common_submission(task, failure)
|
||||||
|
if failure.get("status") not in FAILURE_STATUSES:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "失败状态无效", False
|
||||||
|
)
|
||||||
|
error = failure.get("error")
|
||||||
|
if not isinstance(error, Mapping) or not str(error.get("code", "")).strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "error.code 不能为空", False
|
||||||
|
)
|
||||||
|
if not str(failure.get("reported_at", "")).strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "reported_at 不能为空", False
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_common_submission(
|
||||||
|
task: AdminTask, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
version = payload.get("task_version")
|
||||||
|
if isinstance(version, bool) or version != task.version:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "task_version 与任务不一致", False
|
||||||
|
)
|
||||||
|
if not str(payload.get("attempt_id", "")).strip():
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "attempt_id 不能为空", False
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_mapping(value: Mapping[str, Any], name: str) -> None:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", f"{name} 必须是对象", False
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fingerprint(
|
||||||
|
submission_type: str, task_id: str, payload: Mapping[str, Any]
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
canonical = json.dumps(
|
||||||
|
{
|
||||||
|
"submission_type": submission_type,
|
||||||
|
"task_id": task_id,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise AdminGatewayError(
|
||||||
|
"ADMIN_RESULT_INVALID", "提交内容不是有效 JSON", False
|
||||||
|
) from exc
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _raise_forced_error(self) -> None:
|
||||||
|
error = self._next_error
|
||||||
|
self._next_error = None
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def _find_task(self, task_id: str) -> Optional[_QueuedTask]:
|
||||||
|
for item in self._tasks:
|
||||||
|
if item.task.task_id == task_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""AdminGateway 边界和 Mock 契约测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.admin_gateway import (
|
||||||
|
AdminGateway,
|
||||||
|
AdminGatewayError,
|
||||||
|
AdminTask,
|
||||||
|
AndroidDeviceInfo,
|
||||||
|
ClaimCapabilities,
|
||||||
|
ClientInfo,
|
||||||
|
)
|
||||||
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
|
from src.task_models import TaskType
|
||||||
|
|
||||||
|
|
||||||
|
class MockAdminGatewayContractTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gateway = MockAdminGateway()
|
||||||
|
self.client = ClientInfo("client-001")
|
||||||
|
self.all_capabilities = ClaimCapabilities(
|
||||||
|
device=AndroidDeviceInfo("192.168.0.173:5555")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task(task_id: str, task_type: TaskType) -> AdminTask:
|
||||||
|
return AdminTask(
|
||||||
|
task_id=task_id,
|
||||||
|
task_type=task_type,
|
||||||
|
version=3,
|
||||||
|
priority=10,
|
||||||
|
payload={"goods_id": "737116531267"},
|
||||||
|
created_at="2026-08-06T07:00:00Z",
|
||||||
|
updated_at="2026-08-06T07:05:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _result(result_type: str = "collect"):
|
||||||
|
return {
|
||||||
|
"task_version": 3,
|
||||||
|
"attempt_id": "attempt-001",
|
||||||
|
"result_type": result_type,
|
||||||
|
"completed_at": "2026-08-06T08:03:00Z",
|
||||||
|
"pdd_data": {"title": "测试商品"},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure():
|
||||||
|
return {
|
||||||
|
"task_version": 3,
|
||||||
|
"attempt_id": "attempt-001",
|
||||||
|
"status": "manual_review",
|
||||||
|
"error": {
|
||||||
|
"code": "AMBIGUOUS_ORDER_MATCH",
|
||||||
|
"message": "发现多个候选订单",
|
||||||
|
"retryable": False,
|
||||||
|
"step": "reconcile_order",
|
||||||
|
},
|
||||||
|
"diagnostics": {"artifact_ids": ["artifact-001"]},
|
||||||
|
"reported_at": "2026-08-06T08:03:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_gateway_has_only_three_business_methods(self):
|
||||||
|
self.assertEqual(
|
||||||
|
AdminGateway.__abstractmethods__,
|
||||||
|
{"claim_next", "submit_result", "submit_failure"},
|
||||||
|
)
|
||||||
|
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
||||||
|
self.assertFalse(hasattr(AdminGateway, forbidden))
|
||||||
|
|
||||||
|
def test_claim_returns_none_when_no_task_exists(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claim_respects_client_and_supported_types(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("COLLECT-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("PURCHASE-001", TaskType.PURCHASE), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("COLLECT-OTHER", TaskType.COLLECT), "client-002"
|
||||||
|
)
|
||||||
|
collect_only = ClaimCapabilities(
|
||||||
|
device=AndroidDeviceInfo("emulator-5554"),
|
||||||
|
supported_types=(TaskType.COLLECT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = self.gateway.claim_next(self.client, collect_only)
|
||||||
|
|
||||||
|
self.assertEqual(first.task_id, "COLLECT-001")
|
||||||
|
self.assertIsNone(self.gateway.claim_next(self.client, collect_only))
|
||||||
|
|
||||||
|
second = self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.assertEqual(second.task_id, "PURCHASE-001")
|
||||||
|
self.assertIsNone(
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claimed_task_is_not_returned_twice(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_can_simulate_timeout_and_temporary_failure_once(self):
|
||||||
|
self.gateway.timeout_next_call()
|
||||||
|
with self.assertRaises(AdminGatewayError) as timeout_context:
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.assertEqual(timeout_context.exception.code, "ADMIN_TIMEOUT")
|
||||||
|
self.assertTrue(timeout_context.exception.retryable)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.gateway.fail_next_call_temporarily()
|
||||||
|
with self.assertRaises(AdminGatewayError) as unavailable_context:
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.assertEqual(
|
||||||
|
unavailable_context.exception.code, "ADMIN_UNAVAILABLE"
|
||||||
|
)
|
||||||
|
self.assertTrue(unavailable_context.exception.retryable)
|
||||||
|
|
||||||
|
def test_same_idempotency_key_and_content_reuses_receipt(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
result = self._result()
|
||||||
|
|
||||||
|
first = self.gateway.submit_result("TASK-001", "stable-key", result)
|
||||||
|
second = self.gateway.submit_result("TASK-001", "stable-key", result)
|
||||||
|
|
||||||
|
self.assertTrue(first.accepted)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(self.gateway.submission_count, 1)
|
||||||
|
|
||||||
|
def test_same_idempotency_key_with_different_content_conflicts(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.gateway.submit_result("TASK-001", "stable-key", self._result())
|
||||||
|
changed = self._result()
|
||||||
|
changed["pdd_data"] = {"title": "另一个商品"}
|
||||||
|
|
||||||
|
with self.assertRaises(AdminGatewayError) as context:
|
||||||
|
self.gateway.submit_result("TASK-001", "stable-key", changed)
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.code, "IDEMPOTENCY_CONFLICT")
|
||||||
|
self.assertFalse(context.exception.retryable)
|
||||||
|
self.assertEqual(self.gateway.submission_count, 1)
|
||||||
|
|
||||||
|
def test_cancelled_task_still_accepts_result_and_failure(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.gateway.cancel_task("TASK-001")
|
||||||
|
|
||||||
|
result_receipt = self.gateway.submit_result(
|
||||||
|
"TASK-001", "result-key", self._result()
|
||||||
|
)
|
||||||
|
failure_receipt = self.gateway.submit_failure(
|
||||||
|
"TASK-001", "failure-key", self._failure()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result_receipt.accepted)
|
||||||
|
self.assertTrue(failure_receipt.accepted)
|
||||||
|
self.assertEqual(self.gateway.submission_count, 2)
|
||||||
|
|
||||||
|
def test_unclaimed_task_submission_is_rejected(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(AdminGatewayError) as context:
|
||||||
|
self.gateway.submit_result("TASK-001", "result-key", self._result())
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.code, "ADMIN_TASK_NOT_ASSIGNED")
|
||||||
|
self.assertFalse(context.exception.retryable)
|
||||||
|
|
||||||
|
def test_can_simulate_result_validation_failure(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
self.gateway.reject_next_submission()
|
||||||
|
|
||||||
|
with self.assertRaises(AdminGatewayError) as context:
|
||||||
|
self.gateway.submit_result("TASK-001", "result-key", self._result())
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.code, "ADMIN_RESULT_INVALID")
|
||||||
|
self.assertFalse(context.exception.retryable)
|
||||||
|
self.assertEqual(self.gateway.submission_count, 0)
|
||||||
|
|
||||||
|
def test_payload_validation_rejects_wrong_result_type(self):
|
||||||
|
self.gateway.enqueue_task(
|
||||||
|
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||||
|
)
|
||||||
|
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||||
|
|
||||||
|
with self.assertRaises(AdminGatewayError) as context:
|
||||||
|
self.gateway.submit_result(
|
||||||
|
"TASK-001", "result-key", self._result("purchase")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.code, "ADMIN_RESULT_INVALID")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user