feat: 实现受控真实下单安全边界 (#99)
This commit is contained in:
+65
-3
@@ -6,7 +6,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||
from src.db_schema import MIGRATION_1
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
@@ -63,7 +63,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||
self.assertEqual(version, 2)
|
||||
self.assertEqual(version, 3)
|
||||
|
||||
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -106,7 +106,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertIn("result_data", columns)
|
||||
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
||||
self.assertEqual(version, 2)
|
||||
self.assertEqual(version, 3)
|
||||
|
||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
@@ -133,6 +133,44 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(value, "true")
|
||||
|
||||
def test_v2_database_adds_safe_execution_mode_default(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
for statement in MIGRATION_1 + MIGRATION_2:
|
||||
connection.execute(statement)
|
||||
connection.execute("PRAGMA user_version = 2")
|
||||
connection.execute(
|
||||
"INSERT INTO pdd_tasks"
|
||||
" (remote_task_id, task_type, goods_url, status, received_at,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"PUR-OLD",
|
||||
"purchase",
|
||||
"https://example.test",
|
||||
"claimed",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
initialize_database(self.db_path)
|
||||
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT execution_mode FROM pdd_tasks"
|
||||
" WHERE remote_task_id = 'PUR-OLD'"
|
||||
).fetchone()
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row[0], "dry_run")
|
||||
self.assertEqual(version, 3)
|
||||
|
||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
@@ -181,6 +219,30 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_execution_mode_constraint_is_enforced(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute(
|
||||
"INSERT INTO pdd_tasks"
|
||||
" (remote_task_id, task_type, execution_mode, goods_url,"
|
||||
" status, received_at, created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"PUR-INVALID-MODE",
|
||||
"purchase",
|
||||
"unknown",
|
||||
"https://example.test/goods",
|
||||
"claimed",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_task_run_and_outbox_foreign_keys_are_enforced(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
connection = open_database(self.db_path)
|
||||
|
||||
@@ -274,6 +274,7 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
"type": "purchase",
|
||||
"version": 1,
|
||||
"priority": 0,
|
||||
"execution_mode": "live",
|
||||
"payload": {
|
||||
"goods_url": "https://example.test/goods/PUR-001",
|
||||
"goods_id": "737116531267",
|
||||
@@ -292,11 +293,12 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
ClientInfo("CLIENT-001"),
|
||||
ClaimCapabilities(
|
||||
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
purchase_mode="dry_run",
|
||||
purchase_mode="live",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(task.task_type, TaskType.PURCHASE)
|
||||
self.assertEqual(task.execution_mode, "live")
|
||||
self.assertEqual(task.payload["max_price_cent"], 4200)
|
||||
|
||||
for missing in ("goods_id", "options", "quantity", "max_price_cent"):
|
||||
@@ -318,6 +320,23 @@ class HttpAdminGatewayTest(unittest.TestCase):
|
||||
raised.exception.code, "ADMIN_INVALID_RESPONSE"
|
||||
)
|
||||
|
||||
invalid_mode_task = dict(valid_task)
|
||||
invalid_mode_task["execution_mode"] = "unsafe"
|
||||
invalid_gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(
|
||||
FakeResponse(200, {"task": invalid_mode_task})
|
||||
)
|
||||
)
|
||||
with self.assertRaises(AdminGatewayError) as raised:
|
||||
invalid_gateway.claim_next(
|
||||
ClientInfo("CLIENT-001"),
|
||||
ClaimCapabilities(
|
||||
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
|
||||
purchase_mode="live",
|
||||
),
|
||||
)
|
||||
self.assertEqual(raised.exception.code, "ADMIN_INVALID_RESPONSE")
|
||||
|
||||
def test_claim_204_returns_none(self):
|
||||
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""真实下单授权服务测试;不连接 Admin 或手机。"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.live_purchase_authorization import (
|
||||
LIVE_CONFIRMATION_TEXT,
|
||||
LIVE_ENABLED_KEY,
|
||||
LivePurchaseAuthorizationService,
|
||||
)
|
||||
from src.settings_repository import SettingsRepository
|
||||
|
||||
|
||||
class LivePurchaseAuthorizationTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
repository = SettingsRepository(
|
||||
Path(self.temporary_directory.name) / "client.db"
|
||||
)
|
||||
self.repository = repository
|
||||
self.service = LivePurchaseAuthorizationService(repository)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_missing_setting_and_environment_always_default_to_dry_run(self):
|
||||
os.environ["CMAUTOBUY_PURCHASE_MODE"] = "live"
|
||||
try:
|
||||
self.assertFalse(self.service.load().enabled)
|
||||
self.assertEqual(
|
||||
self.service.purchase_mode_for(
|
||||
"CLIENT-1", "USB-1", live_adapter_ready=True
|
||||
),
|
||||
"dry_run",
|
||||
)
|
||||
finally:
|
||||
os.environ.pop("CMAUTOBUY_PURCHASE_MODE", None)
|
||||
|
||||
def test_enable_requires_exact_confirmation_and_binds_identity(self):
|
||||
with self.assertRaisesRegex(ValueError, LIVE_CONFIRMATION_TEXT):
|
||||
self.service.enable("CLIENT-1", "USB-1", "确认")
|
||||
self.assertIsNone(self.repository.get(LIVE_ENABLED_KEY))
|
||||
|
||||
authorization = self.service.enable(
|
||||
"CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT
|
||||
)
|
||||
|
||||
self.assertTrue(authorization.enabled)
|
||||
self.assertTrue(authorization.matches("CLIENT-1", "USB-1"))
|
||||
self.assertEqual(
|
||||
self.service.purchase_mode_for(
|
||||
"CLIENT-1", "USB-1", live_adapter_ready=True
|
||||
),
|
||||
"live",
|
||||
)
|
||||
|
||||
def test_binding_mismatch_or_missing_adapter_stays_dry_run(self):
|
||||
self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT)
|
||||
|
||||
self.assertEqual(
|
||||
self.service.purchase_mode_for(
|
||||
"CLIENT-2", "USB-1", live_adapter_ready=True
|
||||
),
|
||||
"dry_run",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.purchase_mode_for(
|
||||
"CLIENT-1", "USB-2", live_adapter_ready=True
|
||||
),
|
||||
"dry_run",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.purchase_mode_for(
|
||||
"CLIENT-1", "USB-1", live_adapter_ready=False
|
||||
),
|
||||
"dry_run",
|
||||
)
|
||||
|
||||
def test_disable_is_immediate_and_does_not_require_confirmation(self):
|
||||
self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT)
|
||||
|
||||
authorization = self.service.disable()
|
||||
|
||||
self.assertFalse(authorization.enabled)
|
||||
self.assertFalse(self.service.load().enabled)
|
||||
@@ -5,6 +5,7 @@ import unittest
|
||||
from src.pdd_device_service import PddDeviceService
|
||||
from src.pdd_purchase_adapter import PddPurchaseError
|
||||
from src.pdd_u2_purchase_adapter import U2PddPurchaseAdapter
|
||||
from src.pdd_u2_purchase_adapter import U2PddLivePurchaseAdapter
|
||||
|
||||
|
||||
GOODS_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=753136429979"
|
||||
@@ -35,7 +36,8 @@ def panel_xml(quantity: int = 1) -> str:
|
||||
<node content-desc="减少数量" clickable="true" bounds="[390,700][470,780]"/>
|
||||
<node content-desc="增加数量" clickable="true" bounds="[570,700][650,780]"/>
|
||||
<node clickable="true" bounds="[0,2181][1080,2328]">
|
||||
<node text="提交订单 ¥5.03" bounds="[380,2220][700,2290]"/>
|
||||
<node text="提交订单 ¥5.03" enabled="true" visible-to-user="true"
|
||||
bounds="[380,2220][700,2290]"/>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
@@ -103,6 +105,23 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
select_size_fn=select_size_fn,
|
||||
)
|
||||
|
||||
def _live_adapter(self, device, calls):
|
||||
def select_color_fn(_device, _xml, target, **_kwargs):
|
||||
calls.append(("color", target))
|
||||
return target == "黑色"
|
||||
|
||||
def select_size_fn(_device, _xml, target, **_kwargs):
|
||||
calls.append(("size", target))
|
||||
return target == "3XL【140-165斤】"
|
||||
|
||||
return U2PddLivePurchaseAdapter(
|
||||
"USB-001",
|
||||
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||
sleeper=lambda _seconds: None,
|
||||
select_color_fn=select_color_fn,
|
||||
select_size_fn=select_size_fn,
|
||||
)
|
||||
|
||||
def test_dry_run_reaches_confirmation_without_clicking_submit(self):
|
||||
device = FakeDevice()
|
||||
calls = []
|
||||
@@ -168,6 +187,47 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_GOODS_URL_INVALID")
|
||||
self.assertEqual(device.opened_urls, [])
|
||||
|
||||
def test_live_adapter_clicks_unique_submit_target_only_once(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._live_adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options(
|
||||
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||
)
|
||||
adapter.set_quantity(2)
|
||||
|
||||
state = adapter.read_state()
|
||||
adapter.submit_order_once()
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.submit_order_once()
|
||||
|
||||
self.assertEqual(state.submit_candidate_count, 1)
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED")
|
||||
# 第一次点击采购入口,第二次且仅一次点击最终提交。
|
||||
self.assertEqual(len(device.clicks), 2)
|
||||
adapter.close()
|
||||
|
||||
def test_live_adapter_rejects_multiple_submit_targets_without_click(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._live_adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options(
|
||||
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||
)
|
||||
device.special_xml = panel_xml().replace(
|
||||
"</hierarchy>",
|
||||
'<node text="确认购买" enabled="true" visible-to-user="true" '
|
||||
'bounds="[50,2200][300,2300]"/></hierarchy>',
|
||||
)
|
||||
device.mode = "special"
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.submit_order_once()
|
||||
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS")
|
||||
self.assertEqual(len(device.clicks), 1)
|
||||
adapter.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,7 @@ from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
||||
from src.db import open_database
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.pdd_purchase_adapter import (
|
||||
PddLivePurchaseAdapter,
|
||||
PddPurchaseAdapter,
|
||||
PddPurchaseError,
|
||||
PurchasePageState,
|
||||
@@ -279,11 +280,13 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
||||
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
||||
self.assertIn("核对设备已断开", run.diagnostics_json["error"])
|
||||
|
||||
def test_live_mode_and_order_submission_methods_are_unavailable(self):
|
||||
with self.assertRaisesRegex(ValueError, "dry_run"):
|
||||
ClaimCapabilities(purchase_mode="live")
|
||||
for name in ("submit_order", "pay", "payment"):
|
||||
def test_live_submit_is_isolated_and_payment_methods_are_unavailable(self):
|
||||
self.assertEqual(ClaimCapabilities(purchase_mode="live").purchase_mode, "live")
|
||||
self.assertFalse(hasattr(PddPurchaseAdapter, "submit_order_once"))
|
||||
self.assertTrue(hasattr(PddLivePurchaseAdapter, "submit_order_once"))
|
||||
for name in ("submit_order", "pay", "payment", "cancel_order"):
|
||||
self.assertFalse(hasattr(PddPurchaseAdapter, name))
|
||||
self.assertFalse(hasattr(PddLivePurchaseAdapter, name))
|
||||
self.assertFalse(hasattr(PddPurchaseReconcileAdapter, name))
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -19,6 +19,10 @@ from src.android_device_service import (
|
||||
AndroidWifiConversionResult,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.live_purchase_authorization import (
|
||||
LIVE_CONFIRMATION_TEXT,
|
||||
LIVE_ENABLED_KEY,
|
||||
)
|
||||
from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.settings_ui import AndroidDeviceRow, SettingsPage
|
||||
@@ -215,6 +219,63 @@ class SettingsPageEventTest(unittest.TestCase):
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_live_purchase_defaults_off_and_requires_exact_confirmation(self):
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
live_purchase_adapter_ready=True,
|
||||
)
|
||||
|
||||
self.assertIn("已关闭", page.livePurchaseStatusLabel.text())
|
||||
self.assertFalse(page.livePurchaseEnableButton.isEnabled())
|
||||
page.livePurchaseConfirmationInput.setText("确认")
|
||||
self.assertFalse(page.livePurchaseEnableButton.isEnabled())
|
||||
page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT)
|
||||
self.assertTrue(page.livePurchaseEnableButton.isEnabled())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_enable_and_disable_live_purchase_updates_admin_capability(self):
|
||||
self.repository.set_many(
|
||||
{
|
||||
"admin.client_id": "CLIENT-LIVE",
|
||||
"admin.client_name": "采购电脑",
|
||||
SELECTED_ANDROID_SERIAL_KEY: "USB-LIVE",
|
||||
}
|
||||
)
|
||||
gateway = MockAdminGateway()
|
||||
service = SequenceAndroidDeviceService([[]])
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=gateway,
|
||||
android_device_service=service,
|
||||
live_purchase_adapter_ready=True,
|
||||
)
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT)
|
||||
|
||||
page.livePurchaseEnableButton.click()
|
||||
self._wait_until(
|
||||
lambda: page.eventBinder._live_purchase_thread is None
|
||||
)
|
||||
|
||||
self.assertIs(self.repository.get(LIVE_ENABLED_KEY), True)
|
||||
self.assertIn("已启用", page.livePurchaseStatusLabel.text())
|
||||
registered = gateway.registered_client("CLIENT-LIVE")
|
||||
self.assertEqual(registered[1].purchase_mode, "live")
|
||||
|
||||
page.livePurchaseDisableButton.click()
|
||||
self._wait_until(
|
||||
lambda: page.eventBinder._live_purchase_thread is None
|
||||
)
|
||||
|
||||
self.assertIs(self.repository.get(LIVE_ENABLED_KEY), False)
|
||||
self.assertIn("已关闭", page.livePurchaseStatusLabel.text())
|
||||
registered = gateway.registered_client("CLIENT-LIVE")
|
||||
self.assertEqual(registered[1].purchase_mode, "dry_run")
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_save_persists_locally_and_registers_with_mock(self):
|
||||
gateway = MockAdminGateway()
|
||||
page = SettingsPage(
|
||||
|
||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
||||
from src.android_device_service import AndroidDeviceSearchError
|
||||
from src.admin_gateway import AdminTask, ClientInfo, SubmissionReceipt
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
||||
from src.pdd_purchase_adapter import (
|
||||
PddLivePurchaseAdapter,
|
||||
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
|
||||
@@ -70,12 +74,31 @@ class ReadyPurchaseAdapter(PddPurchaseAdapter):
|
||||
pass
|
||||
|
||||
|
||||
def purchase_task(task_id="PUR-001"):
|
||||
class ReadyLivePurchaseAdapter(ReadyPurchaseAdapter, PddLivePurchaseAdapter):
|
||||
def read_state(self):
|
||||
state = super().read_state()
|
||||
return PurchasePageState(
|
||||
page_kind=state.page_kind,
|
||||
goods_id=state.goods_id,
|
||||
selected_options=state.selected_options,
|
||||
quantity=state.quantity,
|
||||
price_cent=state.price_cent,
|
||||
candidate_count=state.candidate_count,
|
||||
in_stock=True,
|
||||
submit_candidate_count=1,
|
||||
)
|
||||
|
||||
def submit_order_once(self):
|
||||
self.calls.append(("purchase", "submitted_once"))
|
||||
|
||||
|
||||
def purchase_task(task_id="PUR-001", execution_mode="dry_run"):
|
||||
return AdminTask(
|
||||
task_id=task_id,
|
||||
task_type=TaskType.PURCHASE,
|
||||
version=1,
|
||||
priority=10,
|
||||
execution_mode=execution_mode,
|
||||
payload={
|
||||
"goods_url": "https://example.test/PUR-GOODS",
|
||||
"goods_id": "PUR-GOODS",
|
||||
@@ -103,7 +126,14 @@ class TaskDispatcherTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _dispatcher(self, *, purchase_ready, device_checker=lambda _serial: None):
|
||||
def _dispatcher(
|
||||
self,
|
||||
*,
|
||||
purchase_ready,
|
||||
live_ready=False,
|
||||
purchase_mode="dry_run",
|
||||
device_checker=lambda _serial: None,
|
||||
):
|
||||
purchase_factory = None
|
||||
if purchase_ready:
|
||||
purchase_factory = (
|
||||
@@ -118,6 +148,16 @@ class TaskDispatcherTest(unittest.TestCase):
|
||||
lambda *_args: RecordingCollector(self.calls)
|
||||
),
|
||||
purchase_adapter_factory=purchase_factory,
|
||||
live_purchase_adapter_factory=(
|
||||
(
|
||||
lambda _address, _cancelled: ReadyLivePurchaseAdapter(
|
||||
self.calls
|
||||
)
|
||||
)
|
||||
if live_ready
|
||||
else None
|
||||
),
|
||||
purchase_mode=purchase_mode,
|
||||
device_connection_checker=device_checker,
|
||||
)
|
||||
|
||||
@@ -137,6 +177,61 @@ class TaskDispatcherTest(unittest.TestCase):
|
||||
ready.claim_capabilities().purchase_mode, "dry_run"
|
||||
)
|
||||
|
||||
def test_live_capability_requires_authorization_and_live_factory(self):
|
||||
no_factory = self._dispatcher(
|
||||
purchase_ready=True, purchase_mode="live"
|
||||
)
|
||||
authorized = self._dispatcher(
|
||||
purchase_ready=True,
|
||||
live_ready=True,
|
||||
purchase_mode="live",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
no_factory.claim_capabilities().purchase_mode, "dry_run"
|
||||
)
|
||||
self.assertEqual(
|
||||
authorized.claim_capabilities().purchase_mode, "live"
|
||||
)
|
||||
|
||||
def test_local_live_task_never_runs_after_authorization_is_disabled(self):
|
||||
task = purchase_task(execution_mode="live")
|
||||
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "授权已关闭"):
|
||||
self._dispatcher(
|
||||
purchase_ready=True,
|
||||
live_ready=True,
|
||||
purchase_mode="dry_run",
|
||||
).execute_one()
|
||||
|
||||
self.assertNotIn(("purchase", "submitted_once"), self.calls)
|
||||
self.assertEqual(
|
||||
self.repository.get_task(task.task_id).status,
|
||||
TaskStatus.CLAIMED,
|
||||
)
|
||||
|
||||
def test_live_task_is_claimed_only_by_authorized_live_dispatcher(self):
|
||||
task = purchase_task(execution_mode="live")
|
||||
self.gateway.enqueue_task(task, self.client.client_id)
|
||||
|
||||
dry_outcome = self._dispatcher(purchase_ready=True).execute_one()
|
||||
live_outcome = self._dispatcher(
|
||||
purchase_ready=True,
|
||||
live_ready=True,
|
||||
purchase_mode="live",
|
||||
).execute_one()
|
||||
|
||||
self.assertEqual(dry_outcome.kind, "no_task")
|
||||
self.assertEqual(live_outcome.kind, "manual_review")
|
||||
self.assertEqual(
|
||||
self.repository.get_task(task.task_id).execution_mode,
|
||||
"live",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.calls.count(("purchase", "submitted_once")), 1
|
||||
)
|
||||
|
||||
def test_claims_saves_then_dispatches_purchase_dry_run(self):
|
||||
task = purchase_task()
|
||||
self.gateway.enqueue_task(task, self.client.client_id)
|
||||
|
||||
@@ -32,10 +32,12 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
task_type: TaskType = TaskType.COLLECT,
|
||||
title: str = "测试商品",
|
||||
goods_id: str = "10001",
|
||||
execution_mode: str = "dry_run",
|
||||
) -> NewClaimedTask:
|
||||
return NewClaimedTask(
|
||||
remote_task_id=remote_task_id,
|
||||
task_type=task_type,
|
||||
execution_mode=execution_mode,
|
||||
goods_id=goods_id,
|
||||
goods_url=f"https://example.test/goods/{goods_id}",
|
||||
title=title,
|
||||
@@ -96,6 +98,43 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
self.assertEqual(detail.admin_payload["task_id"], "TASK-001")
|
||||
self.assertEqual(detail.pdd_data["schema_version"], 1)
|
||||
|
||||
def test_live_mode_and_irreversible_marker_are_persisted_once(self) -> None:
|
||||
self.repository.add_claimed_task(
|
||||
self._task(
|
||||
"PURCHASE-LIVE",
|
||||
TaskType.PURCHASE,
|
||||
execution_mode="live",
|
||||
)
|
||||
)
|
||||
detail = self.repository.get_task("PURCHASE-LIVE")
|
||||
self.assertEqual(detail.execution_mode, "live")
|
||||
|
||||
started = self.repository.start_purchase_run(
|
||||
"PURCHASE-LIVE", "192.168.0.173:5555"
|
||||
)
|
||||
marked_at = self.repository.mark_purchase_irreversible(
|
||||
"PURCHASE-LIVE", started.attempt_id
|
||||
)
|
||||
run = self.repository.latest_task_run("PURCHASE-LIVE")
|
||||
self.assertEqual(run.irreversible_action_at, marked_at)
|
||||
with self.assertRaisesRegex(ValueError, "已经存在"):
|
||||
self.repository.mark_purchase_irreversible(
|
||||
"PURCHASE-LIVE", started.attempt_id
|
||||
)
|
||||
|
||||
def test_dry_run_cannot_enter_irreversible_stage(self) -> None:
|
||||
self.repository.add_claimed_task(
|
||||
self._task("PURCHASE-DRY", TaskType.PURCHASE)
|
||||
)
|
||||
started = self.repository.start_purchase_run(
|
||||
"PURCHASE-DRY", "USB-001"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "演练任务"):
|
||||
self.repository.mark_purchase_irreversible(
|
||||
"PURCHASE-DRY", started.attempt_id
|
||||
)
|
||||
|
||||
def test_duplicate_remote_task_id_does_not_overwrite(self) -> None:
|
||||
self.repository.add_claimed_task(self._task("TASK-001"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user