feat: 安全领取并分派采购任务 (#72)
This commit is contained in:
@@ -19,7 +19,8 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
||||
self.gateway = MockAdminGateway()
|
||||
self.client = ClientInfo("client-001")
|
||||
self.all_capabilities = ClaimCapabilities(
|
||||
device=AndroidDeviceInfo("192.168.0.173:5555")
|
||||
device=AndroidDeviceInfo("192.168.0.173:5555"),
|
||||
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -14,6 +14,7 @@ from src.admin_gateway import (
|
||||
ClientInfo,
|
||||
)
|
||||
from src.http_admin_gateway import HttpAdminGateway
|
||||
from src.task_models import TaskType
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
@@ -88,7 +89,7 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
self.assertEqual(headers["authorization"], "Bearer secret-token")
|
||||
body = json.loads(opener.request.data.decode("utf-8"))
|
||||
self.assertEqual(body["client"]["name"], "办公室电脑")
|
||||
self.assertEqual(body["supported_types"], ["collect", "purchase"])
|
||||
self.assertEqual(body["supported_types"], ["collect"])
|
||||
self.assertEqual(body["device"]["platform"], "android")
|
||||
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
|
||||
self.assertEqual(opener.timeout, 2.5)
|
||||
@@ -218,7 +219,7 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
self.assertTrue(raised.exception.retryable)
|
||||
self.assertIn("可能已接收", str(raised.exception))
|
||||
|
||||
def test_claim_maps_real_admin_payload_and_only_reports_collect(self):
|
||||
def test_claim_maps_payload_and_serializes_confirmed_capabilities(self):
|
||||
opener = RecordingOpener(
|
||||
FakeResponse(
|
||||
200,
|
||||
@@ -267,6 +268,56 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
self.assertEqual(body["supported_types"], ["collect"])
|
||||
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
|
||||
|
||||
def test_claim_purchase_requires_all_safety_fields(self):
|
||||
valid_task = {
|
||||
"id": "PUR-001",
|
||||
"type": "purchase",
|
||||
"version": 1,
|
||||
"priority": 0,
|
||||
"payload": {
|
||||
"goods_url": "https://example.test/goods/PUR-001",
|
||||
"goods_id": "737116531267",
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
"quantity": 2,
|
||||
"max_price_cent": 4200,
|
||||
},
|
||||
"created_at": "2026-08-09T08:00:00Z",
|
||||
"updated_at": "2026-08-09T08:00:00Z",
|
||||
}
|
||||
gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(FakeResponse(200, {"task": valid_task}))
|
||||
)
|
||||
|
||||
task = gateway.claim_next(
|
||||
ClientInfo("CLIENT-001"),
|
||||
ClaimCapabilities(
|
||||
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
purchase_mode="dry_run",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(task.task_type, TaskType.PURCHASE)
|
||||
self.assertEqual(task.payload["max_price_cent"], 4200)
|
||||
|
||||
for missing in ("goods_id", "options", "quantity", "max_price_cent"):
|
||||
with self.subTest(missing=missing):
|
||||
invalid_task = dict(valid_task)
|
||||
invalid_payload = dict(valid_task["payload"])
|
||||
invalid_payload.pop(missing)
|
||||
invalid_task["payload"] = invalid_payload
|
||||
invalid_gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(
|
||||
FakeResponse(200, {"task": invalid_task})
|
||||
)
|
||||
)
|
||||
with self.assertRaises(AdminGatewayError) as raised:
|
||||
invalid_gateway.claim_next(
|
||||
ClientInfo("CLIENT-001"), self._capabilities()
|
||||
)
|
||||
self.assertEqual(
|
||||
raised.exception.code, "ADMIN_INVALID_RESPONSE"
|
||||
)
|
||||
|
||||
def test_claim_204_returns_none(self):
|
||||
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.pdd_ui_event import (
|
||||
summary_to_row,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
|
||||
from src.task_repository import TaskRepository
|
||||
@@ -45,6 +46,12 @@ class BrokenSaveRepository(BrokenRepository):
|
||||
def next_collect_task(self):
|
||||
return None
|
||||
|
||||
def next_runnable_task(self, *, include_purchase):
|
||||
return None
|
||||
|
||||
def next_purchase_task(self):
|
||||
return None
|
||||
|
||||
|
||||
class RecordingClaimGateway:
|
||||
"""记录领取参数并返回预设结果。"""
|
||||
@@ -148,6 +155,63 @@ def collect_admin_task(task_id="COL-001"):
|
||||
)
|
||||
|
||||
|
||||
def purchase_admin_task(task_id="PUR-001"):
|
||||
return AdminTask(
|
||||
task_id=task_id,
|
||||
task_type=TaskType.PURCHASE,
|
||||
version=1,
|
||||
priority=10,
|
||||
payload={
|
||||
"goods_id": "737116531267",
|
||||
"goods_url": "https://example.test/737116531267",
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
"quantity": 2,
|
||||
"max_price_cent": 1200,
|
||||
},
|
||||
created_at="2026-08-09T08:00:00Z",
|
||||
updated_at="2026-08-09T08:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
class FakePurchaseAdapter(PddPurchaseAdapter):
|
||||
def __init__(self):
|
||||
self.options = {}
|
||||
self.quantity = 0
|
||||
self.page_kind = "goods"
|
||||
|
||||
def open_goods(self, _goods_url):
|
||||
pass
|
||||
|
||||
def read_state(self):
|
||||
return PurchasePageState(
|
||||
self.page_kind,
|
||||
"737116531267",
|
||||
dict(self.options),
|
||||
self.quantity,
|
||||
990,
|
||||
1,
|
||||
)
|
||||
|
||||
def select_options(self, options):
|
||||
self.options = dict(options)
|
||||
|
||||
def set_quantity(self, quantity):
|
||||
self.quantity = quantity
|
||||
|
||||
def enter_confirmation(self):
|
||||
self.page_kind = "order_confirmation"
|
||||
|
||||
def stop_before_submit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def fake_purchase_factory(_address, _cancelled):
|
||||
return FakePurchaseAdapter()
|
||||
|
||||
|
||||
def wait_until(application, predicate, timeout=3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -442,6 +506,35 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_ready_runtime_claims_and_dispatches_purchase_in_worker(self):
|
||||
gateway = RecordingClaimGateway(purchase_admin_task())
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(
|
||||
page,
|
||||
self.repository,
|
||||
claim_gateway=gateway,
|
||||
settings_repository=self._saved_settings(),
|
||||
collect_service_factory=fake_collect_factory,
|
||||
purchase_adapter_factory=fake_purchase_factory,
|
||||
)
|
||||
|
||||
page.autoFetchRequested.emit()
|
||||
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
|
||||
_, capabilities = gateway.calls[0]
|
||||
self.assertEqual(
|
||||
capabilities.supported_types,
|
||||
(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
)
|
||||
self.assertEqual(capabilities.purchase_mode, "dry_run")
|
||||
detail = self.repository.get_task("PUR-001")
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.task_type, TaskType.PURCHASE)
|
||||
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
|
||||
self.assertIn("演练完成", page.statusLabel.text())
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_no_task_schedules_next_claim_and_stop_cancels_timer(self):
|
||||
gateway = RecordingClaimGateway(None)
|
||||
page = PDDTaskPage()
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""统一任务分派器测试;不连接真实 Admin 或手机。"""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.admin_gateway import AdminTask, ClientInfo
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
||||
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
||||
from src.task_models import TaskStatus, TaskType
|
||||
from src.task_repository import TaskRepository
|
||||
|
||||
|
||||
class FakeCollectResult:
|
||||
def to_pdd_data(self):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"goods_id": "COL-GOODS",
|
||||
"title": "采集测试商品",
|
||||
"price_granularity": "color",
|
||||
"dimensions": [],
|
||||
"skus": [],
|
||||
}
|
||||
|
||||
|
||||
class RecordingCollector:
|
||||
def __init__(self, calls):
|
||||
self.calls = calls
|
||||
|
||||
def collect(self, task):
|
||||
self.calls.append(("collect", task.remote_task_id))
|
||||
return FakeCollectResult()
|
||||
|
||||
|
||||
class ReadyPurchaseAdapter(PddPurchaseAdapter):
|
||||
def __init__(self, calls):
|
||||
self.calls = calls
|
||||
self.options = {}
|
||||
self.quantity = 0
|
||||
self.page_kind = "goods"
|
||||
|
||||
def open_goods(self, goods_url):
|
||||
self.calls.append(("purchase", "open", goods_url))
|
||||
|
||||
def read_state(self):
|
||||
return PurchasePageState(
|
||||
self.page_kind,
|
||||
"PUR-GOODS",
|
||||
dict(self.options),
|
||||
self.quantity,
|
||||
990,
|
||||
1,
|
||||
)
|
||||
|
||||
def select_options(self, options):
|
||||
self.options = dict(options)
|
||||
|
||||
def set_quantity(self, quantity):
|
||||
self.quantity = quantity
|
||||
|
||||
def enter_confirmation(self):
|
||||
self.page_kind = "order_confirmation"
|
||||
|
||||
def stop_before_submit(self):
|
||||
self.calls.append(("purchase", "stopped"))
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def purchase_task(task_id="PUR-001"):
|
||||
return AdminTask(
|
||||
task_id=task_id,
|
||||
task_type=TaskType.PURCHASE,
|
||||
version=1,
|
||||
priority=10,
|
||||
payload={
|
||||
"goods_url": "https://example.test/PUR-GOODS",
|
||||
"goods_id": "PUR-GOODS",
|
||||
"options": {
|
||||
"color": "黑色",
|
||||
"size": "L",
|
||||
"bundle": "标准版",
|
||||
},
|
||||
"quantity": 2,
|
||||
"max_price_cent": 1200,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskDispatcherTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.repository = TaskRepository(
|
||||
Path(self.temporary.name) / "client.db"
|
||||
)
|
||||
self.gateway = MockAdminGateway()
|
||||
self.client = ClientInfo("CLIENT-001", "测试客户端")
|
||||
self.calls = []
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _dispatcher(self, *, purchase_ready):
|
||||
purchase_factory = None
|
||||
if purchase_ready:
|
||||
purchase_factory = (
|
||||
lambda _address, _cancelled: ReadyPurchaseAdapter(self.calls)
|
||||
)
|
||||
return TaskDispatcher(
|
||||
self.gateway,
|
||||
self.repository,
|
||||
self.client,
|
||||
"USB-001",
|
||||
collect_service_factory=(
|
||||
lambda *_args: RecordingCollector(self.calls)
|
||||
),
|
||||
purchase_adapter_factory=purchase_factory,
|
||||
)
|
||||
|
||||
def test_capability_only_includes_purchase_when_adapter_is_ready(self):
|
||||
collect_only = self._dispatcher(purchase_ready=False)
|
||||
ready = self._dispatcher(purchase_ready=True)
|
||||
|
||||
self.assertEqual(
|
||||
collect_only.claim_capabilities().supported_types,
|
||||
(TaskType.COLLECT,),
|
||||
)
|
||||
self.assertEqual(
|
||||
ready.claim_capabilities().supported_types,
|
||||
(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
)
|
||||
self.assertEqual(
|
||||
ready.claim_capabilities().purchase_mode, "dry_run"
|
||||
)
|
||||
|
||||
def test_claims_saves_then_dispatches_purchase_dry_run(self):
|
||||
task = purchase_task()
|
||||
self.gateway.enqueue_task(task, self.client.client_id)
|
||||
|
||||
outcome = self._dispatcher(purchase_ready=True).execute_one()
|
||||
|
||||
self.assertEqual(outcome.kind, "succeeded")
|
||||
detail = self.repository.get_task(task.task_id)
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.task_type, TaskType.PURCHASE)
|
||||
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
|
||||
self.assertEqual(
|
||||
detail.admin_payload["payload"]["options"]["bundle"],
|
||||
"标准版",
|
||||
)
|
||||
self.assertIn(("purchase", "stopped"), self.calls)
|
||||
|
||||
def test_purchase_is_not_claimed_when_runtime_is_not_ready(self):
|
||||
self.gateway.enqueue_task(purchase_task(), self.client.client_id)
|
||||
|
||||
outcome = self._dispatcher(purchase_ready=False).execute_one()
|
||||
|
||||
self.assertEqual(outcome.kind, "no_task")
|
||||
self.assertEqual(self.repository.count_tasks(), 0)
|
||||
|
||||
def test_local_purchase_stops_dispatch_when_runtime_is_not_ready(self):
|
||||
task = purchase_task()
|
||||
self.repository.add_claimed_task(
|
||||
admin_task_to_new_claimed_task(task)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "停止领取新任务"):
|
||||
self._dispatcher(purchase_ready=False).execute_one()
|
||||
|
||||
detail = self.repository.get_task(task.task_id)
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.status, TaskStatus.CLAIMED)
|
||||
|
||||
def test_purchase_mapper_rejects_missing_or_invalid_safety_fields(self):
|
||||
invalid_payloads = (
|
||||
{"goods_url": "https://example.test"},
|
||||
{
|
||||
"goods_url": "https://example.test",
|
||||
"goods_id": "G",
|
||||
"options": {},
|
||||
"quantity": 1,
|
||||
"max_price_cent": 100,
|
||||
},
|
||||
{
|
||||
"goods_url": "https://example.test",
|
||||
"goods_id": "G",
|
||||
"options": {"size": "L"},
|
||||
"quantity": 0,
|
||||
"max_price_cent": 100,
|
||||
},
|
||||
{
|
||||
"goods_url": "https://example.test",
|
||||
"goods_id": "G",
|
||||
"options": {"size": "L"},
|
||||
"quantity": 1,
|
||||
"max_price_cent": 0,
|
||||
},
|
||||
)
|
||||
for index, payload in enumerate(invalid_payloads):
|
||||
with self.subTest(index=index):
|
||||
task = AdminTask(
|
||||
f"PUR-BAD-{index}",
|
||||
TaskType.PURCHASE,
|
||||
1,
|
||||
0,
|
||||
payload,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
admin_task_to_new_claimed_task(task)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user