2026-08-06 16:38:28 +08:00
|
|
|
"""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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 17:57:49 +08:00
|
|
|
def test_gateway_has_only_four_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-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(
|
|
|
|
|
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()
|