Files
cmautobuy/client/test/test_admin_gateway_contract.py
T

511 lines
18 KiB
Python

"""AdminGateway 边界和 Mock 契约测试。"""
import hashlib
import unittest
from copy import deepcopy
from src.admin_gateway import (
AdminGateway,
AdminGatewayError,
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
)
from src.mock_admin_gateway import MockAdminGateway
from src.task_models import TaskType
def _frame(value: str) -> str:
return f"{len(value.encode('utf-8'))}:{value}"
def _finalize_spec_observation(task_id: str, observation: dict) -> str:
candidates = observation["candidates"]
snapshot_values = [
"spec-resolution-v1",
observation["pdd_goods_id"],
observation["selected_color"],
str(len(candidates)),
]
for candidate in candidates:
snapshot_values.extend(
(
candidate["candidate_id"],
candidate["raw_text"],
candidate["options"]["color"],
candidate["options"]["size"],
)
)
observation["candidate_snapshot_hash"] = hashlib.sha256(
"".join(_frame(value) for value in snapshot_values).encode("utf-8")
).hexdigest()
identity_values = (
task_id,
observation["attempt_id"],
observation["candidate_snapshot_hash"],
"spec-resolution-v1",
)
return "spec-resolution-v1:" + hashlib.sha256(
"".join(_frame(value) for value in identity_values).encode("utf-8")
).hexdigest()
def _spec_observation(task_id: str = "PUR-SPEC") -> tuple[dict, str]:
observation = {
"schema_version": 1,
"task_version": 3,
"attempt_id": "attempt-001",
"pdd_goods_id": "737116531267",
"original_options": {"color": "黑色", "size": "60公斤"},
"selected_color": "黑色",
"target_size": "60公斤",
"candidates": [
{
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"},
}
],
"candidate_snapshot_hash": "",
"observed_at": "2026-08-17T08:00:00Z",
}
return observation, _finalize_spec_observation(task_id, observation)
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"),
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
)
@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_expected_business_methods(self):
self.assertEqual(
AdminGateway.__abstractmethods__,
{
"register_client",
"claim_next",
"submit_result",
"submit_failure",
"resolve_purchase_spec",
},
)
for forbidden in ("get_status", "heartbeat", "renew_lease"):
self.assertFalse(hasattr(AdminGateway, forbidden))
def test_registration_is_idempotent_and_keeps_latest_profile(self):
first = self.gateway.register_client(
ClientInfo("client-001", "办公室电脑"), self.all_capabilities
)
second = self.gateway.register_client(
ClientInfo("client-001", "仓库电脑"), self.all_capabilities
)
self.assertTrue(first.registered)
self.assertEqual(second.client_id, "client-001")
self.assertEqual(self.gateway.registration_count, 1)
saved_client, saved_capabilities = self.gateway.registered_client(
"client-001"
)
self.assertEqual(saved_client.name, "仓库电脑")
self.assertEqual(saved_capabilities, self.all_capabilities)
def test_registration_can_simulate_admin_failure(self):
self.gateway.fail_next_call_temporarily()
with self.assertRaises(AdminGatewayError) as context:
self.gateway.register_client(self.client, self.all_capabilities)
self.assertEqual(context.exception.code, "ADMIN_UNAVAILABLE")
self.assertTrue(context.exception.retryable)
self.assertEqual(self.gateway.registration_count, 0)
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("cj1", TaskType.COLLECT), "client-001"
)
self.gateway.enqueue_task(
self._task("cg1", 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, "cj1")
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, "cg1")
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_spec_resolution_is_idempotent_and_returns_configured_match(self):
task = AdminTask(
task_id="PUR-SPEC",
task_type=TaskType.PURCHASE,
version=3,
priority=1,
payload={
"goods_id": "737116531267",
"options": {"color": "黑色", "size": "60公斤"},
},
)
self.gateway.enqueue_task(task, self.client.client_id)
self.gateway.claim_next(self.client, self.all_capabilities)
frame = lambda value: f"{len(value.encode('utf-8'))}:{value}"
snapshot_material = "".join(
frame(value)
for value in (
"spec-resolution-v1",
"737116531267",
"黑色",
"1",
"c1",
"120斤",
"黑色",
"120斤",
)
)
snapshot_hash = hashlib.sha256(
snapshot_material.encode("utf-8")
).hexdigest()
observation = {
"schema_version": 1,
"task_version": 3,
"attempt_id": "attempt-001",
"pdd_goods_id": "737116531267",
"original_options": {"color": "黑色", "size": "60公斤"},
"selected_color": "黑色",
"target_size": "60公斤",
"candidates": [
{
"candidate_id": "c1",
"raw_text": "120斤",
"options": {"color": "黑色", "size": "120斤"},
}
],
"candidate_snapshot_hash": snapshot_hash,
"observed_at": "2026-08-17T08:00:00Z",
}
material = "".join(
frame(value)
for value in (
"PUR-SPEC",
"attempt-001",
snapshot_hash,
"spec-resolution-v1",
)
)
key = "spec-resolution-v1:" + hashlib.sha256(
material.encode("utf-8")
).hexdigest()
configured = SpecResolutionReceipt(
1,
"psr-001",
"matched",
"rule",
snapshot_hash,
SpecResolutionMatch(
"c1", "120斤", {"color": "黑色", "size": "120斤"}
),
10000,
"唯一重量等价",
"2026-08-17T08:00:01Z",
)
self.gateway.set_next_spec_resolution(configured)
first = self.gateway.resolve_purchase_spec(
"PUR-SPEC", key, observation
)
second = self.gateway.resolve_purchase_spec(
"PUR-SPEC", key, observation
)
self.assertEqual(first, configured)
self.assertEqual(second, configured)
self.assertEqual(self.gateway.spec_resolution_count, 1)
def test_spec_resolution_validation_matches_admin_contract(self):
task_id = "PUR-SPEC-VALIDATION"
task = AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=3,
priority=1,
payload={
"goods_id": "737116531267",
"options": {"color": "黑色", "size": "60公斤"},
},
)
self.gateway.enqueue_task(task, self.client.client_id)
self.gateway.claim_next(self.client, self.all_capabilities)
original, _key = _spec_observation(task_id)
cases = []
wrong_color = deepcopy(original)
wrong_color["selected_color"] = "白色"
wrong_color["candidates"][0]["options"]["color"] = "白色"
cases.append((
"已选颜色不是领取规格",
wrong_color,
_finalize_spec_observation(task_id, wrong_color),
"INVALID_SPEC_RESOLUTION_REQUEST",
))
wrong_size = deepcopy(original)
wrong_size["target_size"] = "70公斤"
cases.append((
"目标尺码不是领取规格",
wrong_size,
_finalize_spec_observation(task_id, wrong_size),
"INVALID_SPEC_RESOLUTION_REQUEST",
))
missing_timezone = deepcopy(original)
missing_timezone["observed_at"] = "2026-08-17T08:00:00"
cases.append((
"观测时间没有时区",
missing_timezone,
_finalize_spec_observation(task_id, missing_timezone),
"INVALID_SPEC_RESOLUTION_REQUEST",
))
oversized_attempt = deepcopy(original)
oversized_attempt["attempt_id"] = "a" * 192
cases.append((
"执行尝试编号超长",
oversized_attempt,
_finalize_spec_observation(task_id, oversized_attempt),
"INVALID_SPEC_RESOLUTION_REQUEST",
))
control_character = deepcopy(original)
control_character["target_size"] = "60\n公斤"
cases.append((
"字段包含控制字符",
control_character,
_finalize_spec_observation(task_id, control_character),
"INVALID_SPEC_RESOLUTION_REQUEST",
))
oversized_body = deepcopy(original)
oversized_body["future_padding"] = "x" * (64 * 1024)
cases.append((
"请求体超过上限",
oversized_body,
_finalize_spec_observation(task_id, oversized_body),
"INVALID_BODY",
))
for name, request, key, expected_code in cases:
with self.subTest(name=name):
with self.assertRaises(AdminGatewayError) as raised:
self.gateway.resolve_purchase_spec(task_id, key, request)
self.assertEqual(raised.exception.code, expected_code)
self.assertFalse(raised.exception.retryable)
def test_spec_resolution_uses_admin_task_and_claim_error_codes(self):
observation, key = _spec_observation("PUR-MISSING")
with self.assertRaises(AdminGatewayError) as missing:
self.gateway.resolve_purchase_spec(
"PUR-MISSING", key, observation
)
self.assertEqual(missing.exception.code, "TASK_NOT_FOUND")
task_id = "PUR-UNCLAIMED"
self.gateway.enqueue_task(
AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=3,
priority=1,
payload={
"goods_id": "737116531267",
"options": {"color": "黑色", "size": "60公斤"},
},
),
self.client.client_id,
)
observation, key = _spec_observation(task_id)
with self.assertRaises(AdminGatewayError) as unclaimed:
self.gateway.resolve_purchase_spec(task_id, key, observation)
self.assertEqual(
unclaimed.exception.code, "TASK_NOT_CLAIMED_BY_CLIENT"
)
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()