"""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"), 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_four_business_methods(self): self.assertEqual( AdminGateway.__abstractmethods__, { "register_client", "claim_next", "submit_result", "submit_failure", }, ) 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("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()