"""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()