Files
cmautobuy/client/test/test_purchase_spec_resolution_contract_vectors.py
T

414 lines
15 KiB
Python
Raw Normal View History

"""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)
2026-08-17 18:37:39 +08:00
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()