feat(client): add Admin Gateway mock contract (#10)

This commit is contained in:
chengma
2026-08-06 16:38:28 +08:00
parent 799ee33045
commit c412e8afe2
3 changed files with 647 additions and 0 deletions
+285
View File
@@ -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