"""Admin、HTTP Gateway 与 Mock 共用的运行时规格解析契约向量。""" import hashlib import io import json import unittest from copy import deepcopy from pathlib import Path from urllib.error import HTTPError from src.admin_gateway import ( AdminGatewayError, AdminTask, AndroidDeviceInfo, ClaimCapabilities, ClientInfo, SpecResolutionMatch, SpecResolutionReceipt, ) from src.http_admin_gateway import HttpAdminGateway from src.mock_admin_gateway import MockAdminGateway from src.task_models import TaskType CONTRACT_PATH = ( Path(__file__).resolve().parents[2] / "testdata" / "contracts" / "purchase_spec_resolution_v1.json" ) class _FakeResponse: def __init__(self, status: int, payload: dict): self.status = status self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8") def getcode(self): return self.status def read(self): return self._body def __enter__(self): return self def __exit__(self, *_args): return False class _RecordingOpener: def __init__(self, response): self.response = response self.request = None def __call__(self, request, timeout): self.request = request if isinstance(self.response, Exception): raise self.response return self.response def _frame(value: str) -> str: return f"{len(value.encode('utf-8'))}:{value}" def _snapshot_hash(request: dict) -> str: values = [ "spec-resolution-v1", request["pdd_goods_id"], request["selected_color"], str(len(request["candidates"])), ] for candidate in request["candidates"]: values.extend( ( candidate["candidate_id"], candidate["raw_text"], candidate["options"]["color"], candidate["options"]["size"], ) ) return hashlib.sha256( "".join(_frame(value) for value in values).encode("utf-8") ).hexdigest() def _idempotency_key(task_id: str, request: dict) -> str: values = ( task_id, request["attempt_id"], request["candidate_snapshot_hash"], "spec-resolution-v1", ) return "spec-resolution-v1:" + hashlib.sha256( "".join(_frame(value) for value in values).encode("utf-8") ).hexdigest() def _build_request(defaults: dict, case: dict) -> tuple[dict, str]: candidates = [ { "candidate_id": f"c{index}", "raw_text": value, "options": { "color": defaults["selected_color"], "size": value, }, } for index, value in enumerate(case["candidates"], start=1) ] request = { "schema_version": 1, "task_version": defaults["task_version"], "attempt_id": defaults["attempt_id"], "pdd_goods_id": defaults["pdd_goods_id"], "original_options": { "color": defaults["selected_color"], "size": case["target_size"], }, "selected_color": defaults["selected_color"], "target_size": case["target_size"], "candidates": candidates, "candidate_snapshot_hash": case["candidate_snapshot_hash"], "observed_at": defaults["observed_at"], } if _snapshot_hash(request) != case["candidate_snapshot_hash"]: raise AssertionError(f"{case['name']} 的候选哈希与共享向量不一致") key = _idempotency_key(case["task_id"], request) if key != case["idempotency_key"]: raise AssertionError(f"{case['name']} 的幂等键与共享向量不一致") return request, key def _receipt_for_case(case: dict, request: dict) -> SpecResolutionReceipt: expected = case["expected"] match = None if expected["candidate_id"] is not None: candidate = next( item for item in request["candidates"] if item["candidate_id"] == expected["candidate_id"] ) match = SpecResolutionMatch( candidate["candidate_id"], candidate["raw_text"], dict(candidate["options"]), ) return SpecResolutionReceipt( 1, f"psr-vector-{case['name']}", expected["outcome"], expected["source"], request["candidate_snapshot_hash"], match, expected["confidence_bps"], f"共享向量 {case['name']}", "2026-08-17T08:00:01Z", ) def _receipt_payload(receipt: SpecResolutionReceipt) -> dict: return { "schema_version": receipt.schema_version, "resolution_id": receipt.resolution_id, "outcome": receipt.outcome, "source": receipt.source, "candidate_snapshot_hash": receipt.candidate_snapshot_hash, "match": ( { "candidate_id": receipt.match.candidate_id, "raw_text": receipt.match.raw_text, "options": dict(receipt.match.options), } if receipt.match is not None else None ), "confidence_bps": receipt.confidence_bps, "reason": receipt.reason, "resolved_at": receipt.resolved_at, } class PurchaseSpecResolutionContractVectorTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.fixture = json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) if ( cls.fixture.get("contract") != "purchase-spec-resolution-v1" or cls.fixture.get("schema_version") != 1 ): raise AssertionError("共享规格解析契约向量头无效") cls.cases = { case["name"]: case for case in cls.fixture["cases"] } def test_fixture_contains_only_synthetic_business_identifiers(self): serialized = json.dumps(self.fixture, ensure_ascii=False).lower() for forbidden_key in ( '"order_no"', '"phone"', '"address"', '"cookie"', '"token"', '"password"', '"api_key"', '"device_serial"', ): self.assertNotIn(forbidden_key, serialized) defaults = self.fixture["defaults"] self.assertTrue(defaults["client_id"].startswith("CLIENT-CONTRACT")) self.assertTrue(defaults["pdd_goods_id"].startswith("PDD-CONTRACT")) for case in self.fixture["cases"]: self.assertTrue(case["task_id"].startswith("cg-contract-")) def _task(self, case: dict, task_type=TaskType.PURCHASE) -> AdminTask: defaults = self.fixture["defaults"] return AdminTask( task_id=case["task_id"], task_type=task_type, version=defaults["task_version"], priority=1, payload={ "goods_id": defaults["pdd_goods_id"], "options": { "color": defaults["selected_color"], "size": case["target_size"], }, }, ) @staticmethod def _claim(gateway: MockAdminGateway): return gateway.claim_next( ClientInfo("CLIENT-CONTRACT"), ClaimCapabilities( device=AndroidDeviceInfo("TEST-DEVICE"), supported_types=(TaskType.COLLECT, TaskType.PURCHASE), ), ) def test_valid_vectors_are_identical_for_http_and_mock(self): defaults = self.fixture["defaults"] for case in self.fixture["cases"]: with self.subTest(case=case["name"]): request, key = _build_request(defaults, case) expected = _receipt_for_case(case, request) mock = MockAdminGateway() mock.enqueue_task(self._task(case), defaults["client_id"]) self._claim(mock) mock.set_next_spec_resolution(expected) first = mock.resolve_purchase_spec( case["task_id"], key, request ) second = mock.resolve_purchase_spec( case["task_id"], key, request ) self.assertEqual(first, expected) self.assertEqual(second, expected) self.assertEqual(mock.spec_resolution_count, 1) opener = _RecordingOpener( _FakeResponse(200, _receipt_payload(expected)) ) http = HttpAdminGateway( opener=opener, client_id=defaults["client_id"], ) parsed = http.resolve_purchase_spec( case["task_id"], key, request ) self.assertEqual(parsed, expected) self.assertEqual( json.loads(opener.request.data.decode("utf-8")), request ) headers = { name.lower(): value for name, value in opener.request.header_items() } self.assertEqual(headers["idempotency-key"], key) def test_error_vectors_are_identical_for_http_and_mock(self): defaults = self.fixture["defaults"] for error_case in self.fixture["error_cases"]: with self.subTest(case=error_case["name"]): case = self.cases[error_case["base_case"]] request, key = _build_request(defaults, case) task_id = case["task_id"] task_id, key = self._mutate( error_case["mutation"], task_id, request, key ) mock = MockAdminGateway() if error_case["mutation"] != "task_not_found": task_type = ( TaskType.COLLECT if error_case["mutation"] == "task_not_purchase" else TaskType.PURCHASE ) mock.enqueue_task( self._task(case, task_type), defaults["client_id"] ) if error_case["mutation"] != "task_not_claimed": self._claim(mock) if error_case["mutation"] == "idempotency_conflict": original, original_key = _build_request(defaults, case) mock.set_next_spec_resolution( _receipt_for_case(case, original) ) mock.resolve_purchase_spec( case["task_id"], original_key, original ) with self.assertRaises(AdminGatewayError) as mock_error: mock.resolve_purchase_spec(task_id, key, request) self.assertEqual( mock_error.exception.code, error_case["expected_error_code"], ) self.assertFalse(mock_error.exception.retryable) error_payload = { "error": { "code": error_case["expected_error_code"], "message": "共享契约错误向量", "retryable": False, "request_id": "request-vector", "details": {}, } } http_error = HTTPError( "http://127.0.0.1/spec-resolution", error_case["expected_http_status"], "contract error", hdrs=None, fp=io.BytesIO( json.dumps(error_payload).encode("utf-8") ), ) http = HttpAdminGateway( opener=_RecordingOpener(http_error), client_id=defaults["client_id"], ) with self.assertRaises(AdminGatewayError) as http_raised: http.resolve_purchase_spec(task_id, key, request) self.assertEqual( http_raised.exception.code, error_case["expected_error_code"], ) self.assertFalse(http_raised.exception.retryable) @staticmethod def _mutate( mutation: str, task_id: str, request: dict, key: str ) -> tuple[str, str]: if mutation == "schema_version_2": request["schema_version"] = 2 elif mutation == "candidate_id_gap": request["candidates"][0]["candidate_id"] = "c2" request["candidate_snapshot_hash"] = _snapshot_hash(request) key = _idempotency_key(task_id, request) elif mutation == "snapshot_hash_mismatch": request["candidate_snapshot_hash"] = "0" * 64 key = _idempotency_key(task_id, request) elif mutation == "selected_color_not_claimed": request["selected_color"] = "测试白" for candidate in request["candidates"]: candidate["options"]["color"] = "测试白" request["candidate_snapshot_hash"] = _snapshot_hash(request) key = _idempotency_key(task_id, request) elif mutation == "target_size_not_claimed": request["target_size"] = "70公斤" elif mutation == "observed_at_without_timezone": request["observed_at"] = "2026-08-17T08:00:00" elif mutation == "attempt_id_too_long": request["attempt_id"] = "a" * 192 key = _idempotency_key(task_id, request) elif mutation == "candidate_count_101": request["candidates"] = [ { "candidate_id": f"c{index}", "raw_text": f"测试尺码{index}", "options": { "color": "测试黑", "size": f"测试尺码{index}", }, } for index in range(1, 102) ] request["candidate_snapshot_hash"] = _snapshot_hash(request) key = _idempotency_key(task_id, request) elif mutation == "body_too_large": request["future_padding"] = "x" * (64 * 1024) elif mutation == "task_not_found": task_id = "cg-contract-missing" key = _idempotency_key(task_id, request) elif mutation in {"task_not_purchase", "task_not_claimed"}: pass elif mutation == "task_version_conflict": request["task_version"] = 4 elif mutation == "pdd_goods_mismatch": request["pdd_goods_id"] = "PDD-CONTRACT-OTHER" request["candidate_snapshot_hash"] = _snapshot_hash(request) key = _idempotency_key(task_id, request) elif mutation == "idempotency_conflict": request["observed_at"] = "2026-08-17T08:00:01Z" else: raise AssertionError(f"未知契约变体 {mutation}") return task_id, key if __name__ == "__main__": unittest.main()