2026-08-06 16:38:28 +08:00
|
|
|
"""AdminGateway 边界和 Mock 契约测试。"""
|
|
|
|
|
|
2026-08-17 17:43:25 +08:00
|
|
|
import hashlib
|
2026-08-06 16:38:28 +08:00
|
|
|
import unittest
|
2026-08-17 18:14:50 +08:00
|
|
|
from copy import deepcopy
|
2026-08-06 16:38:28 +08:00
|
|
|
|
|
|
|
|
from src.admin_gateway import (
|
|
|
|
|
AdminGateway,
|
|
|
|
|
AdminGatewayError,
|
|
|
|
|
AdminTask,
|
|
|
|
|
AndroidDeviceInfo,
|
|
|
|
|
ClaimCapabilities,
|
|
|
|
|
ClientInfo,
|
2026-08-17 17:43:25 +08:00
|
|
|
SpecResolutionMatch,
|
|
|
|
|
SpecResolutionReceipt,
|
2026-08-06 16:38:28 +08:00
|
|
|
)
|
|
|
|
|
from src.mock_admin_gateway import MockAdminGateway
|
|
|
|
|
from src.task_models import TaskType
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 18:14:50 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 16:38:28 +08:00
|
|
|
class MockAdminGatewayContractTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.gateway = MockAdminGateway()
|
|
|
|
|
self.client = ClientInfo("client-001")
|
|
|
|
|
self.all_capabilities = ClaimCapabilities(
|
2026-08-10 00:05:40 +08:00
|
|
|
device=AndroidDeviceInfo("192.168.0.173:5555"),
|
|
|
|
|
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
2026-08-06 16:38:28 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 17:43:25 +08:00
|
|
|
def test_gateway_has_only_expected_business_methods(self):
|
2026-08-06 16:38:28 +08:00
|
|
|
self.assertEqual(
|
|
|
|
|
AdminGateway.__abstractmethods__,
|
2026-08-06 17:57:49 +08:00
|
|
|
{
|
|
|
|
|
"register_client",
|
|
|
|
|
"claim_next",
|
|
|
|
|
"submit_result",
|
|
|
|
|
"submit_failure",
|
2026-08-17 17:43:25 +08:00
|
|
|
"resolve_purchase_spec",
|
2026-08-06 17:57:49 +08:00
|
|
|
},
|
2026-08-06 16:38:28 +08:00
|
|
|
)
|
|
|
|
|
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
|
|
|
|
self.assertFalse(hasattr(AdminGateway, forbidden))
|
|
|
|
|
|
2026-08-06 17:57:49 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-06 16:38:28 +08:00
|
|
|
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(
|
2026-08-12 09:21:21 +08:00
|
|
|
self._task("cj1", TaskType.COLLECT), "client-001"
|
2026-08-06 16:38:28 +08:00
|
|
|
)
|
|
|
|
|
self.gateway.enqueue_task(
|
2026-08-12 09:21:21 +08:00
|
|
|
self._task("cg1", TaskType.PURCHASE), "client-001"
|
2026-08-06 16:38:28 +08:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-12 09:21:21 +08:00
|
|
|
self.assertEqual(first.task_id, "cj1")
|
2026-08-06 16:38:28 +08:00
|
|
|
self.assertIsNone(self.gateway.claim_next(self.client, collect_only))
|
|
|
|
|
|
|
|
|
|
second = self.gateway.claim_next(self.client, self.all_capabilities)
|
2026-08-12 09:21:21 +08:00
|
|
|
self.assertEqual(second.task_id, "cg1")
|
2026-08-06 16:38:28 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-17 17:43:25 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-17 18:14:50 +08:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-06 16:38:28 +08:00
|
|
|
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()
|