feat: 安全应用采购规格解析结果 (#257)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""AdminGateway 边界和 Mock 契约测试。"""
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
|
||||
from src.admin_gateway import (
|
||||
@@ -9,6 +10,8 @@ from src.admin_gateway import (
|
||||
AndroidDeviceInfo,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
SpecResolutionMatch,
|
||||
SpecResolutionReceipt,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.task_models import TaskType
|
||||
@@ -61,7 +64,7 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
||||
"reported_at": "2026-08-06T08:03:00Z",
|
||||
}
|
||||
|
||||
def test_gateway_has_only_four_business_methods(self):
|
||||
def test_gateway_has_only_expected_business_methods(self):
|
||||
self.assertEqual(
|
||||
AdminGateway.__abstractmethods__,
|
||||
{
|
||||
@@ -69,6 +72,7 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
||||
"claim_next",
|
||||
"submit_result",
|
||||
"submit_failure",
|
||||
"resolve_purchase_spec",
|
||||
},
|
||||
)
|
||||
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
||||
@@ -162,6 +166,92 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
||||
)
|
||||
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_same_idempotency_key_and_content_reuses_receipt(self):
|
||||
self.gateway.enqueue_task(
|
||||
self._task("TASK-001", TaskType.COLLECT), "client-001"
|
||||
|
||||
+47
-6
@@ -6,7 +6,13 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3, MIGRATION_4
|
||||
from src.db_schema import (
|
||||
MIGRATION_1,
|
||||
MIGRATION_2,
|
||||
MIGRATION_3,
|
||||
MIGRATION_4,
|
||||
MIGRATION_5,
|
||||
)
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
@@ -14,6 +20,7 @@ EXPECTED_TABLES = {
|
||||
"task_runs",
|
||||
"outbox_events",
|
||||
"app_settings",
|
||||
"purchase_spec_resolutions",
|
||||
}
|
||||
|
||||
EXPECTED_INDEXES = {
|
||||
@@ -23,6 +30,7 @@ EXPECTED_INDEXES = {
|
||||
"idx_task_runs_task",
|
||||
"idx_outbox_pending",
|
||||
"idx_pdd_tasks_visible_list",
|
||||
"idx_purchase_spec_resolutions_attempt",
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +72,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||
self.assertEqual(version, 5)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -107,7 +115,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertIn("result_data", columns)
|
||||
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
||||
self.assertEqual(version, 5)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
@@ -170,7 +178,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row[0], "dry_run")
|
||||
self.assertEqual(version, 5)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -212,7 +220,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertIn("removed_at", columns)
|
||||
self.assertEqual(tuple(row), ("COL-V3", None))
|
||||
self.assertEqual(version, 5)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -245,7 +253,40 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row[0], "测试店铺")
|
||||
self.assertEqual(version, 5)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_v5_database_adds_purchase_spec_resolution_audit_table(self):
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
for statement in (
|
||||
MIGRATION_1
|
||||
+ MIGRATION_2
|
||||
+ MIGRATION_3
|
||||
+ MIGRATION_4
|
||||
+ MIGRATION_5
|
||||
):
|
||||
connection.execute(statement)
|
||||
connection.execute("PRAGMA user_version = 5")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
initialize_database(self.db_path)
|
||||
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute(
|
||||
"PRAGMA table_info(purchase_spec_resolutions)"
|
||||
)
|
||||
}
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIn("candidate_snapshot_hash", columns)
|
||||
self.assertIn("idempotency_key", columns)
|
||||
self.assertEqual(version, 6)
|
||||
|
||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
|
||||
@@ -206,6 +206,107 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
self.assertTrue(receipt.accepted)
|
||||
self.assertTrue(receipt.result_id.startswith("legacy-failure-"))
|
||||
|
||||
def test_resolve_purchase_spec_posts_once_and_parses_whitelisted_match(self):
|
||||
response = {
|
||||
"schema_version": 1,
|
||||
"resolution_id": "psr-001",
|
||||
"outcome": "matched",
|
||||
"source": "rule",
|
||||
"candidate_snapshot_hash": "a" * 64,
|
||||
"match": {
|
||||
"candidate_id": "c1",
|
||||
"raw_text": "120斤",
|
||||
"options": {"color": "黑色", "size": "120斤"},
|
||||
},
|
||||
"confidence_bps": 10000,
|
||||
"reason": "唯一等价",
|
||||
"resolved_at": "2026-08-17T08:00:01Z",
|
||||
"future_field": "ignored",
|
||||
}
|
||||
opener = RecordingOpener(FakeResponse(200, response))
|
||||
gateway = HttpAdminGateway(
|
||||
opener=opener, client_id="CLIENT-001", timeout_seconds=5
|
||||
)
|
||||
|
||||
receipt = gateway.resolve_purchase_spec(
|
||||
"PUR-001", "spec-resolution-v1:key", {"schema_version": 1}
|
||||
)
|
||||
|
||||
self.assertEqual(receipt.resolution_id, "psr-001")
|
||||
self.assertEqual(receipt.match.raw_text, "120斤")
|
||||
self.assertTrue(opener.request.full_url.endswith(
|
||||
"/api/v1/client/tasks/PUR-001/spec-resolution"
|
||||
))
|
||||
headers = {
|
||||
key.lower(): value for key, value in opener.request.header_items()
|
||||
}
|
||||
self.assertEqual(
|
||||
headers["idempotency-key"], "spec-resolution-v1:key"
|
||||
)
|
||||
self.assertEqual(opener.timeout, 5)
|
||||
|
||||
def test_resolve_purchase_spec_accepts_all_nonmatched_business_outcomes(self):
|
||||
for outcome in ("uncertain", "rejected", "failed"):
|
||||
with self.subTest(outcome=outcome):
|
||||
gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"resolution_id": f"psr-{outcome}",
|
||||
"outcome": outcome,
|
||||
"source": None,
|
||||
"candidate_snapshot_hash": "b" * 64,
|
||||
"match": None,
|
||||
"confidence_bps": None,
|
||||
"reason": "没有唯一安全候选",
|
||||
"resolved_at": "2026-08-17T08:00:01Z",
|
||||
},
|
||||
)
|
||||
),
|
||||
client_id="CLIENT-001",
|
||||
)
|
||||
|
||||
receipt = gateway.resolve_purchase_spec(
|
||||
"PUR-001", "spec-resolution-v1:key", {}
|
||||
)
|
||||
|
||||
self.assertEqual(receipt.outcome, outcome)
|
||||
self.assertIsNone(receipt.match)
|
||||
|
||||
def test_resolve_purchase_spec_rejects_match_on_nonmatched_outcome(self):
|
||||
gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"resolution_id": "psr-invalid",
|
||||
"outcome": "uncertain",
|
||||
"source": "ai",
|
||||
"candidate_snapshot_hash": "c" * 64,
|
||||
"match": {
|
||||
"candidate_id": "c1",
|
||||
"raw_text": "120斤",
|
||||
"options": {"color": "黑色", "size": "120斤"},
|
||||
},
|
||||
"confidence_bps": 5000,
|
||||
"reason": "响应自相矛盾",
|
||||
"resolved_at": "2026-08-17T08:00:01Z",
|
||||
},
|
||||
)
|
||||
),
|
||||
client_id="CLIENT-001",
|
||||
)
|
||||
|
||||
with self.assertRaises(AdminGatewayError) as raised:
|
||||
gateway.resolve_purchase_spec(
|
||||
"PUR-001", "spec-resolution-v1:key", {}
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.code, "ADMIN_INVALID_RESPONSE")
|
||||
|
||||
def test_malformed_2xx_submission_is_retryable_and_not_called_rejected(self):
|
||||
gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(FakeResponse(200, {"accepted": True})),
|
||||
|
||||
@@ -11,6 +11,8 @@ from src.pdd_collect_service import DimensionValue, PddCollectError, SpecDimensi
|
||||
from src.pdd_purchase_adapter import (
|
||||
PddPurchaseError,
|
||||
PddPurchaseSpecResolutionRequired,
|
||||
PurchaseSizeObservation,
|
||||
PurchaseSpecCandidateSnapshot,
|
||||
)
|
||||
from src.performance_timing import TaskPerformanceTrace
|
||||
from src.pdd_u2_purchase_adapter import (
|
||||
@@ -991,6 +993,76 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
)
|
||||
adapter.close()
|
||||
|
||||
def test_apply_resolved_size_rechecks_same_snapshot_before_exact_selection(self):
|
||||
device = FakeDevice()
|
||||
selected = []
|
||||
snapshot = PurchaseSpecCandidateSnapshot.build(
|
||||
goods_id="753136429979",
|
||||
selected_color="黑色",
|
||||
target_size="XXL",
|
||||
dimension_name="尺码",
|
||||
observations=(
|
||||
PurchaseSizeObservation("3XL【140-165斤】", True),
|
||||
),
|
||||
observed_at="2026-08-17T08:00:00Z",
|
||||
)
|
||||
adapter = U2PddPurchaseAdapter(
|
||||
"USB-001",
|
||||
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||
sleeper=lambda _seconds: None,
|
||||
select_size_fn=lambda _device, _xml, target, **_kwargs: (
|
||||
selected.append(target) or True
|
||||
),
|
||||
size_candidate_collector=lambda _device: SpecDimension(
|
||||
"size",
|
||||
"尺码",
|
||||
(DimensionValue("3XL【140-165斤】", True),),
|
||||
),
|
||||
)
|
||||
adapter.open_goods(GOODS_URL)
|
||||
device.click(790, 2214)
|
||||
|
||||
adapter.apply_resolved_size(snapshot, snapshot.candidates[0])
|
||||
|
||||
self.assertEqual(selected, ["3XL【140-165斤】"])
|
||||
self.assertEqual(
|
||||
adapter.read_state().selected_options,
|
||||
{"size": "3XL【140-165斤】"},
|
||||
)
|
||||
adapter.close()
|
||||
|
||||
def test_apply_resolved_size_stops_when_candidate_snapshot_changed(self):
|
||||
device = FakeDevice()
|
||||
selections = []
|
||||
snapshot = PurchaseSpecCandidateSnapshot.build(
|
||||
goods_id="753136429979",
|
||||
selected_color="黑色",
|
||||
target_size="XXL",
|
||||
dimension_name="尺码",
|
||||
observations=(PurchaseSizeObservation("M", True),),
|
||||
observed_at="2026-08-17T08:00:00Z",
|
||||
)
|
||||
adapter = U2PddPurchaseAdapter(
|
||||
"USB-001",
|
||||
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||
sleeper=lambda _seconds: None,
|
||||
select_size_fn=lambda *_args, **_kwargs: selections.append(True),
|
||||
size_candidate_collector=lambda _device: SpecDimension(
|
||||
"size", "尺码", (DimensionValue("L", True),)
|
||||
),
|
||||
)
|
||||
adapter.open_goods(GOODS_URL)
|
||||
device.click(790, 2214)
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.apply_resolved_size(snapshot, snapshot.candidates[0])
|
||||
|
||||
self.assertEqual(
|
||||
raised.exception.code, "PURCHASE_SPEC_CANDIDATES_CHANGED"
|
||||
)
|
||||
self.assertEqual(selections, [])
|
||||
adapter.close()
|
||||
|
||||
def test_candidate_snapshot_is_not_used_when_target_exists_but_click_fails(self):
|
||||
device = FakeDevice()
|
||||
adapter = U2PddPurchaseAdapter(
|
||||
|
||||
@@ -3,21 +3,30 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.admin_gateway import (
|
||||
AdminTask,
|
||||
AndroidDeviceInfo,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
SpecResolutionMatch,
|
||||
SpecResolutionReceipt,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.db import open_database
|
||||
from src.pdd_purchase_adapter import (
|
||||
PddLivePurchaseAdapter,
|
||||
PddPurchaseAdapter,
|
||||
PddPurchaseError,
|
||||
PddPurchaseSpecResolutionRequired,
|
||||
PurchaseSizeObservation,
|
||||
PurchaseSpecCandidateSnapshot,
|
||||
PurchasePageState,
|
||||
)
|
||||
from src.pdd_purchase_reconcile_adapter import PurchaseOrderCandidate
|
||||
from src.purchase_task_service import PurchaseTaskService
|
||||
from src.purchase_reconcile_service import PurchaseReconcileService
|
||||
from src.task_models import NewClaimedTask, TaskStatus, TaskType
|
||||
from src.task_repository import TaskRepository
|
||||
|
||||
@@ -135,12 +144,65 @@ class UnavailableGoodsAdapter(RecordingDryRunAdapter):
|
||||
)
|
||||
|
||||
|
||||
class ResolvingDryRunAdapter(RecordingDryRunAdapter):
|
||||
"""首次选择报告尺码不存在,随后记录审计候选的精确应用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.snapshot = PurchaseSpecCandidateSnapshot.build(
|
||||
goods_id=self.goods_id,
|
||||
selected_color="黑色",
|
||||
target_size="L",
|
||||
dimension_name="尺码",
|
||||
observations=(
|
||||
PurchaseSizeObservation("120斤", True),
|
||||
PurchaseSizeObservation("130斤", True),
|
||||
),
|
||||
observed_at="2026-08-17T08:00:00Z",
|
||||
)
|
||||
|
||||
def select_options(self, options) -> None:
|
||||
self.calls.append(("select_options", dict(options)))
|
||||
self.options = {
|
||||
key: value for key, value in options.items() if key != "size"
|
||||
}
|
||||
raise PddPurchaseSpecResolutionRequired(self.snapshot)
|
||||
|
||||
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
|
||||
self.calls.append(
|
||||
("apply_resolved_size", expected_snapshot.candidate_snapshot_hash,
|
||||
candidate.candidate_id, candidate.raw_text)
|
||||
)
|
||||
self.options["size"] = candidate.raw_text
|
||||
|
||||
|
||||
class ResolvingLiveAdapter(ResolvingDryRunAdapter, PddLivePurchaseAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.submit_count = 0
|
||||
|
||||
def update_shipping_address(self, purchase_number: str) -> None:
|
||||
self.calls.append(("update_shipping_address", purchase_number))
|
||||
|
||||
def submit_order_once(self) -> None:
|
||||
self.submit_count += 1
|
||||
self.calls.append(("submit_order_once",))
|
||||
|
||||
|
||||
class ChangedAfterResolutionAdapter(ResolvingDryRunAdapter):
|
||||
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_SPEC_CANDIDATES_CHANGED",
|
||||
"等待期间候选变化",
|
||||
step="purchase_resolve_options",
|
||||
)
|
||||
|
||||
|
||||
class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.repository = TaskRepository(
|
||||
Path(self.temp_dir.name) / "client.db"
|
||||
)
|
||||
self.db_path = Path(self.temp_dir.name) / "client.db"
|
||||
self.repository = TaskRepository(self.db_path)
|
||||
self.gateway = MockAdminGateway()
|
||||
self.client = ClientInfo("CLIENT-001", "测试客户端")
|
||||
|
||||
@@ -233,6 +295,229 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
self.assertFalse(purchase["order_submitted"])
|
||||
self.assertFalse(purchase["payment_attempted"])
|
||||
|
||||
def test_matched_spec_resolution_is_persisted_and_existing_flow_continues(self):
|
||||
self._prepare_task(task_id="PUR-RESOLVE")
|
||||
adapter = ResolvingDryRunAdapter()
|
||||
candidate = adapter.snapshot.candidates[0]
|
||||
self.gateway.set_next_spec_resolution(
|
||||
SpecResolutionReceipt(
|
||||
1,
|
||||
"psr-001",
|
||||
"matched",
|
||||
"rule",
|
||||
adapter.snapshot.candidate_snapshot_hash,
|
||||
SpecResolutionMatch(
|
||||
candidate.candidate_id,
|
||||
candidate.raw_text,
|
||||
candidate.options,
|
||||
),
|
||||
10000,
|
||||
"唯一重量等价",
|
||||
"2026-08-17T08:00:01Z",
|
||||
)
|
||||
)
|
||||
|
||||
outcome = self._service(adapter).execute_selected("PUR-RESOLVE")
|
||||
|
||||
self.assertEqual(outcome.kind, "succeeded")
|
||||
self.assertTrue(any(call[0] == "apply_resolved_size" for call in adapter.calls))
|
||||
detail = self.repository.get_task("PUR-RESOLVE")
|
||||
purchase = detail.pdd_data["purchase"]
|
||||
self.assertEqual(purchase["requested"]["options"], OPTIONS)
|
||||
self.assertEqual(purchase["confirmed"]["options"]["size"], "120斤")
|
||||
self.assertEqual(
|
||||
purchase["spec_resolution"]["resolution_id"], "psr-001"
|
||||
)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT status, resolution_id, outcome"
|
||||
" FROM purchase_spec_resolutions"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(tuple(row), ("resolved", "psr-001", "matched"))
|
||||
|
||||
def test_uncertain_spec_resolution_stops_before_quantity_and_submission(self):
|
||||
self._prepare_task(task_id="PUR-UNCERTAIN")
|
||||
adapter = ResolvingDryRunAdapter()
|
||||
self.gateway.set_next_spec_resolution(
|
||||
SpecResolutionReceipt(
|
||||
1,
|
||||
"psr-uncertain",
|
||||
"uncertain",
|
||||
"ai",
|
||||
adapter.snapshot.candidate_snapshot_hash,
|
||||
None,
|
||||
7000,
|
||||
"无法唯一判断",
|
||||
"2026-08-17T08:00:01Z",
|
||||
)
|
||||
)
|
||||
|
||||
outcome = self._service(adapter).execute_selected("PUR-UNCERTAIN")
|
||||
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertFalse(any(call[0] == "apply_resolved_size" for call in adapter.calls))
|
||||
self.assertNotIn(("set_quantity", 2), adapter.calls)
|
||||
detail = self.repository.get_task("PUR-UNCERTAIN")
|
||||
self.assertEqual(
|
||||
detail.last_error_code, "PURCHASE_SPEC_RESOLUTION_UNCERTAIN"
|
||||
)
|
||||
|
||||
def test_rejected_and_failed_spec_resolution_use_stable_failure_codes(self):
|
||||
for outcome, expected_code in (
|
||||
("rejected", "PURCHASE_SPEC_RESOLUTION_REJECTED"),
|
||||
("failed", "PURCHASE_SPEC_RESOLUTION_FAILED"),
|
||||
):
|
||||
with self.subTest(outcome=outcome):
|
||||
task_id = f"PUR-{outcome.upper()}"
|
||||
self._prepare_task(task_id=task_id)
|
||||
adapter = ResolvingDryRunAdapter()
|
||||
self.gateway.set_next_spec_resolution(
|
||||
SpecResolutionReceipt(
|
||||
1,
|
||||
f"psr-{outcome}",
|
||||
outcome,
|
||||
None,
|
||||
adapter.snapshot.candidate_snapshot_hash,
|
||||
None,
|
||||
None,
|
||||
"没有可安全采用的候选",
|
||||
"2026-08-17T08:00:01Z",
|
||||
)
|
||||
)
|
||||
|
||||
result = self._service(adapter).execute_selected(task_id)
|
||||
|
||||
self.assertEqual(result.kind, "task_failed")
|
||||
self.assertEqual(
|
||||
self.repository.get_task(task_id).last_error_code,
|
||||
expected_code,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(call[0] == "apply_resolved_size" for call in adapter.calls)
|
||||
)
|
||||
|
||||
def test_live_resolution_keeps_original_request_and_existing_safety_gates(self):
|
||||
self._prepare_task(task_id="PUR-RESOLVE-LIVE", execution_mode="live")
|
||||
adapter = ResolvingLiveAdapter()
|
||||
candidate = adapter.snapshot.candidates[0]
|
||||
self.gateway.set_next_spec_resolution(
|
||||
SpecResolutionReceipt(
|
||||
1,
|
||||
"psr-live",
|
||||
"matched",
|
||||
"ai",
|
||||
adapter.snapshot.candidate_snapshot_hash,
|
||||
SpecResolutionMatch(
|
||||
candidate.candidate_id,
|
||||
candidate.raw_text,
|
||||
candidate.options,
|
||||
),
|
||||
9300,
|
||||
"唯一候选",
|
||||
"2026-08-17T08:00:01Z",
|
||||
)
|
||||
)
|
||||
|
||||
outcome = self._service(adapter).execute_selected("PUR-RESOLVE-LIVE")
|
||||
|
||||
self.assertEqual(outcome.kind, "reconcile_pending")
|
||||
self.assertEqual(adapter.submit_count, 1)
|
||||
task = self.repository.get_task("PUR-RESOLVE-LIVE")
|
||||
run = self.repository.latest_task_run("PUR-RESOLVE-LIVE")
|
||||
self.assertIsNotNone(run.irreversible_action_at)
|
||||
self.assertEqual(
|
||||
run.diagnostics_json["final_confirmation"]["options"]["size"],
|
||||
"120斤",
|
||||
)
|
||||
self.assertEqual(
|
||||
run.diagnostics_json["final_confirmation"]["spec_resolution"]
|
||||
["resolution_id"],
|
||||
"psr-live",
|
||||
)
|
||||
query = PurchaseReconcileService._query(task, run)
|
||||
self.assertEqual(query.original_options, OPTIONS)
|
||||
self.assertEqual(query.options["size"], "120斤")
|
||||
self.assertEqual(query.spec_resolution["resolution_id"], "psr-live")
|
||||
result = PurchaseReconcileService._result_data(
|
||||
SimpleNamespace(_device_address="127.0.0.1:5555"),
|
||||
task,
|
||||
query,
|
||||
PurchaseOrderCandidate(
|
||||
order_no="ORDER-001",
|
||||
ordered_at="2026-08-17T08:00:03Z",
|
||||
ordered_at_raw="2026-08-17 16:00:03",
|
||||
payment_status="unpaid",
|
||||
),
|
||||
)
|
||||
self.assertEqual(result["purchase"]["requested"]["options"], OPTIONS)
|
||||
self.assertEqual(
|
||||
result["purchase"]["confirmed"]["options"]["size"], "120斤"
|
||||
)
|
||||
self.assertEqual(
|
||||
result["purchase"]["spec_resolution"]["resolution_id"],
|
||||
"psr-live",
|
||||
)
|
||||
|
||||
def test_spec_resolution_timeout_keeps_persisted_request_and_does_not_retry(self):
|
||||
self._prepare_task(task_id="PUR-RESOLVE-TIMEOUT")
|
||||
adapter = ResolvingDryRunAdapter()
|
||||
self.gateway.timeout_next_call()
|
||||
|
||||
outcome = self._service(adapter).execute_selected(
|
||||
"PUR-RESOLVE-TIMEOUT"
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertEqual(self.gateway.spec_resolution_count, 0)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT status, idempotency_key, request_json"
|
||||
" FROM purchase_spec_resolutions"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row["status"], "pending")
|
||||
self.assertTrue(row["idempotency_key"].startswith("spec-resolution-v1:"))
|
||||
self.assertIn('"candidate_snapshot_hash"', row["request_json"])
|
||||
|
||||
def test_matched_resolution_page_change_failure_keeps_resolution_audit(self):
|
||||
self._prepare_task(task_id="PUR-RESOLVE-CHANGED")
|
||||
adapter = ChangedAfterResolutionAdapter()
|
||||
candidate = adapter.snapshot.candidates[0]
|
||||
self.gateway.set_next_spec_resolution(
|
||||
SpecResolutionReceipt(
|
||||
1,
|
||||
"psr-changed",
|
||||
"matched",
|
||||
"ai",
|
||||
adapter.snapshot.candidate_snapshot_hash,
|
||||
SpecResolutionMatch(
|
||||
candidate.candidate_id,
|
||||
candidate.raw_text,
|
||||
candidate.options,
|
||||
),
|
||||
9000,
|
||||
"模型匹配",
|
||||
"2026-08-17T08:00:01Z",
|
||||
)
|
||||
)
|
||||
|
||||
outcome = self._service(adapter).execute_selected(
|
||||
"PUR-RESOLVE-CHANGED"
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
run = self.repository.latest_task_run("PUR-RESOLVE-CHANGED")
|
||||
self.assertEqual(
|
||||
run.diagnostics_json["spec_resolution"]["resolution_id"],
|
||||
"psr-changed",
|
||||
)
|
||||
self.assertIsNone(run.irreversible_action_at)
|
||||
|
||||
def test_started_callback_runs_after_database_enters_running(self):
|
||||
self._prepare_task(task_id="PUR-STARTED")
|
||||
observed = []
|
||||
|
||||
@@ -114,6 +114,60 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
self.assertEqual(detail.admin_payload["task_id"], "TASK-001")
|
||||
self.assertEqual(detail.pdd_data["schema_version"], 1)
|
||||
|
||||
def test_purchase_spec_resolution_request_is_persisted_before_response(self):
|
||||
self.repository.add_claimed_task(
|
||||
self._task("PUR-SPEC", TaskType.PURCHASE)
|
||||
)
|
||||
started = self.repository.start_purchase_run("PUR-SPEC", "USB-001")
|
||||
request = {
|
||||
"schema_version": 1,
|
||||
"candidate_snapshot_hash": "a" * 64,
|
||||
"candidates": [{"candidate_id": "c1", "raw_text": "L"}],
|
||||
}
|
||||
|
||||
first = self.repository.prepare_purchase_spec_resolution(
|
||||
"PUR-SPEC", started.attempt_id, "spec-resolution-v1:key", request
|
||||
)
|
||||
second = self.repository.prepare_purchase_spec_resolution(
|
||||
"PUR-SPEC", started.attempt_id, "spec-resolution-v1:key", request
|
||||
)
|
||||
|
||||
self.assertEqual(first.id, second.id)
|
||||
self.assertEqual(first.status, "pending")
|
||||
self.assertEqual(first.request_json, request)
|
||||
saved = self.repository.save_purchase_spec_resolution(
|
||||
first.id,
|
||||
{
|
||||
"resolution_id": "psr-001",
|
||||
"outcome": "matched",
|
||||
"source": "rule",
|
||||
"confidence_bps": 10000,
|
||||
"match": {
|
||||
"candidate_id": "c1",
|
||||
"raw_text": "L",
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
},
|
||||
"reason": "唯一匹配",
|
||||
"resolved_at": "2026-08-17T08:00:01Z",
|
||||
},
|
||||
)
|
||||
self.assertEqual(saved.status, "resolved")
|
||||
self.assertEqual(saved.resolution_id, "psr-001")
|
||||
self.assertEqual(saved.match_options["size"], "L")
|
||||
with self.assertRaisesRegex(ValueError, "不同响应"):
|
||||
self.repository.save_purchase_spec_resolution(
|
||||
first.id,
|
||||
{
|
||||
"resolution_id": "psr-other",
|
||||
"outcome": "rejected",
|
||||
"source": None,
|
||||
"confidence_bps": None,
|
||||
"match": None,
|
||||
"reason": "内容变化",
|
||||
"resolved_at": "2026-08-17T08:00:02Z",
|
||||
},
|
||||
)
|
||||
|
||||
def test_live_mode_and_irreversible_marker_are_persisted_once(self) -> None:
|
||||
self.repository.add_claimed_task(
|
||||
self._task(
|
||||
|
||||
Reference in New Issue
Block a user