2026-08-17 18:34:35 +08:00
|
|
|
"""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
|
2026-08-18 08:56:56 +08:00
|
|
|
self.timeout = None
|
2026-08-17 18:34:35 +08:00
|
|
|
|
|
|
|
|
def __call__(self, request, timeout):
|
|
|
|
|
self.request = request
|
2026-08-18 08:56:56 +08:00
|
|
|
self.timeout = timeout
|
2026-08-17 18:34:35 +08:00
|
|
|
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)
|
2026-08-18 08:56:56 +08:00
|
|
|
self.assertEqual(opener.timeout, 60.0)
|
2026-08-17 18:34:35 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-08-17 18:34:35 +08:00
|
|
|
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()
|