"""AdminGateway 边界和 Mock 契约测试。""" import hashlib import unittest 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 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_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()