feat: 实现受控真实下单安全边界 (#99)

This commit is contained in:
chengma
2026-08-10 16:35:38 +08:00
parent 7106f6b024
commit e960cab1fb
30 changed files with 1562 additions and 66 deletions
+65 -3
View File
@@ -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)
+20 -1
View File
@@ -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)
+61 -1
View File
@@ -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()
+7 -4
View File
@@ -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))
+95 -3
View File
@@ -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(
+61
View File
@@ -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(
+98 -3
View File
@@ -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)
+39
View File
@@ -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"))