feat: 实现受控真实下单安全边界 (#99)
This commit is contained in:
@@ -11,7 +11,12 @@ from src.admin_gateway import (
|
||||
ClientInfo,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
||||
from src.pdd_purchase_adapter import (
|
||||
PddLivePurchaseAdapter,
|
||||
PddPurchaseAdapter,
|
||||
PddPurchaseError,
|
||||
PurchasePageState,
|
||||
)
|
||||
from src.purchase_task_service import PurchaseTaskService
|
||||
from src.task_models import NewClaimedTask, TaskStatus, TaskType
|
||||
from src.task_repository import TaskRepository
|
||||
@@ -31,12 +36,16 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
||||
candidate_count: int = 1,
|
||||
forced_page: str = "",
|
||||
wrong_options: bool = False,
|
||||
in_stock: bool = True,
|
||||
submit_candidate_count: int = 1,
|
||||
) -> None:
|
||||
self.goods_id = goods_id
|
||||
self.price_cent = price_cent
|
||||
self.candidate_count = candidate_count
|
||||
self.forced_page = forced_page
|
||||
self.wrong_options = wrong_options
|
||||
self.in_stock = in_stock
|
||||
self.submit_candidate_count = submit_candidate_count
|
||||
self.options = {}
|
||||
self.quantity = 0
|
||||
self.page_kind = "goods"
|
||||
@@ -57,6 +66,8 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
||||
quantity=self.quantity,
|
||||
price_cent=self.price_cent,
|
||||
candidate_count=self.candidate_count,
|
||||
in_stock=self.in_stock,
|
||||
submit_candidate_count=self.submit_candidate_count,
|
||||
)
|
||||
|
||||
def select_options(self, options) -> None:
|
||||
@@ -78,6 +89,25 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
||||
self.calls.append(("close",))
|
||||
|
||||
|
||||
class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
|
||||
"""只记录一次提交调用的 live 测试 Adapter。"""
|
||||
|
||||
def __init__(self, *, submit_error: bool = False, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.submit_error = submit_error
|
||||
self.submit_count = 0
|
||||
|
||||
def submit_order_once(self) -> None:
|
||||
self.submit_count += 1
|
||||
self.calls.append(("submit_order_once",))
|
||||
if self.submit_error:
|
||||
raise PddPurchaseError(
|
||||
"DEVICE_DISCONNECTED",
|
||||
"点击后设备断开",
|
||||
step="purchase_submit_once",
|
||||
)
|
||||
|
||||
|
||||
class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -90,12 +120,18 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _prepare_task(self, *, task_id: str = "PUR-001") -> None:
|
||||
def _prepare_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str = "PUR-001",
|
||||
execution_mode: str = "dry_run",
|
||||
) -> None:
|
||||
task = AdminTask(
|
||||
task_id=task_id,
|
||||
task_type=TaskType.PURCHASE,
|
||||
version=1,
|
||||
priority=10,
|
||||
execution_mode=execution_mode,
|
||||
payload={
|
||||
"goods_url": (
|
||||
"https://mobile.yangkeduo.com/goods.html?"
|
||||
@@ -113,7 +149,9 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
ClaimCapabilities(
|
||||
device=AndroidDeviceInfo("USB-001"),
|
||||
supported_types=(TaskType.PURCHASE,),
|
||||
purchase_mode="dry_run",
|
||||
purchase_mode=(
|
||||
"live" if execution_mode == "live" else "dry_run"
|
||||
),
|
||||
),
|
||||
)
|
||||
assert claimed is not None
|
||||
@@ -122,6 +160,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
remote_task_id=claimed.task_id,
|
||||
task_type=claimed.task_type,
|
||||
goods_url=str(claimed.payload["goods_url"]),
|
||||
execution_mode=claimed.execution_mode,
|
||||
goods_id=str(claimed.payload["goods_id"]),
|
||||
quantity=int(claimed.payload["quantity"]),
|
||||
priority=claimed.priority,
|
||||
@@ -236,6 +275,59 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.last_error_code, expected_code)
|
||||
|
||||
def test_live_marks_irreversible_before_single_submit_and_enters_reconcile(self):
|
||||
self._prepare_task(execution_mode="live")
|
||||
adapter = RecordingLiveAdapter()
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "manual_review")
|
||||
self.assertEqual(adapter.submit_count, 1)
|
||||
detail = self.repository.get_task("PUR-001")
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
||||
self.assertEqual(detail.current_step, "reconcile_purchase")
|
||||
self.assertIsNotNone(run.irreversible_action_at)
|
||||
self.assertIsNotNone(run.order_submitted_at)
|
||||
self.assertEqual(run.current_step, "reconcile_purchase")
|
||||
self.assertIsNone(self.repository.next_pending_outbox())
|
||||
|
||||
def test_live_ambiguous_submit_target_stops_before_irreversible(self):
|
||||
self._prepare_task(execution_mode="live")
|
||||
adapter = RecordingLiveAdapter(submit_candidate_count=2)
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(adapter.submit_count, 0)
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertIsNone(run.irreversible_action_at)
|
||||
|
||||
def test_live_submit_error_never_retries_and_still_enters_reconcile(self):
|
||||
self._prepare_task(execution_mode="live")
|
||||
adapter = RecordingLiveAdapter(submit_error=True)
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "manual_review")
|
||||
self.assertEqual(adapter.submit_count, 1)
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertIsNotNone(run.irreversible_action_at)
|
||||
self.assertIsNone(run.order_submitted_at)
|
||||
self.assertEqual(run.current_step, "reconcile_purchase")
|
||||
self.assertIsNone(self.repository.next_pending_outbox())
|
||||
|
||||
def test_live_task_rejects_dry_adapter_before_device_action(self):
|
||||
self._prepare_task(execution_mode="live")
|
||||
adapter = RecordingDryRunAdapter()
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(adapter.calls, [("close",)])
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertIsNone(run.irreversible_action_at)
|
||||
|
||||
def test_purchase_task_with_missing_safety_fields_is_reported(self):
|
||||
task_id = "PUR-INVALID"
|
||||
task = AdminTask(
|
||||
|
||||
Reference in New Issue
Block a user