test: 增加采购规格跨端联合验收 (#258)
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
"""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 == "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()
|
||||
Reference in New Issue
Block a user