514 lines
18 KiB
Python
514 lines
18 KiB
Python
"""采购崩溃恢复和安全门禁测试;不连接真实手机。"""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
|
from src.mock_admin_gateway import MockAdminGateway
|
|
from src.pdd_purchase_adapter import (
|
|
PddLivePurchaseAdapter,
|
|
PddPurchaseAdapter,
|
|
PddPurchaseError,
|
|
PurchasePageState,
|
|
)
|
|
from src.pdd_purchase_reconcile_adapter import (
|
|
PddPurchaseReconcileAdapter,
|
|
PurchaseOrderCandidate,
|
|
PurchaseReconcileScan,
|
|
)
|
|
from src.purchase_task_service import PurchaseTaskService
|
|
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
|
from src.task_models import RunStatus, TaskStatus, TaskType
|
|
from src.task_repository import TaskRepository
|
|
|
|
|
|
OPTIONS = {"color": "黑色", "size": "L"}
|
|
|
|
|
|
def purchase_admin_task(
|
|
task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
|
|
) -> AdminTask:
|
|
return AdminTask(
|
|
task_id,
|
|
TaskType.PURCHASE,
|
|
1,
|
|
0,
|
|
{
|
|
"goods_url": "https://example.test/goods/737116531267",
|
|
"goods_id": "737116531267",
|
|
"options": dict(OPTIONS),
|
|
"quantity": 2,
|
|
"max_price_cent": 5000,
|
|
},
|
|
execution_mode=execution_mode,
|
|
)
|
|
|
|
|
|
class FaultAdapter(PddPurchaseAdapter):
|
|
"""在指定动作抛错,用来模拟设备断开或进程崩溃。"""
|
|
|
|
def __init__(self, fail_action: str = "") -> None:
|
|
self.fail_action = fail_action
|
|
self.options = {}
|
|
self.quantity = 0
|
|
self.page_kind = "goods"
|
|
|
|
def _fail(self, action: str) -> None:
|
|
if self.fail_action == action:
|
|
raise PddPurchaseError(
|
|
"DEVICE_DISCONNECTED",
|
|
"Android 设备连接中断",
|
|
step=action,
|
|
retryable=True,
|
|
diagnostics={"action": action},
|
|
)
|
|
|
|
def open_goods(self, _goods_url: str) -> None:
|
|
self._fail("open_goods")
|
|
|
|
def read_state(self) -> PurchasePageState:
|
|
self._fail("read_state")
|
|
return PurchasePageState(
|
|
self.page_kind,
|
|
"737116531267",
|
|
dict(self.options),
|
|
self.quantity,
|
|
4200,
|
|
1,
|
|
)
|
|
|
|
def select_options(self, options) -> None:
|
|
self._fail("select_options")
|
|
self.options = dict(options)
|
|
|
|
def set_quantity(self, quantity: int) -> None:
|
|
self._fail("set_quantity")
|
|
self.quantity = quantity
|
|
|
|
def enter_confirmation(self) -> None:
|
|
self._fail("enter_confirmation")
|
|
self.page_kind = "order_confirmation"
|
|
|
|
def stop_before_submit(self) -> None:
|
|
self._fail("stop_before_submit")
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
|
|
def __init__(
|
|
self, calls, candidates=None, error=None, preserve_times=False
|
|
) -> None:
|
|
self.calls = calls
|
|
self.candidates = candidates
|
|
self.error = error
|
|
self.preserve_times = preserve_times
|
|
|
|
def read_order_candidates(self, query):
|
|
self.calls.append(("reconcile", query.goods_id))
|
|
if self.error is not None:
|
|
raise self.error
|
|
candidates = self.candidates
|
|
if candidates is None:
|
|
candidates = (
|
|
PurchaseOrderCandidate(
|
|
order_no="ORDER-001",
|
|
ordered_at=query.order_submitted_at,
|
|
ordered_at_raw="2026-08-10 16:00:00",
|
|
payment_status="unpaid",
|
|
),
|
|
)
|
|
elif not self.preserve_times:
|
|
candidates = tuple(
|
|
replace(candidate, ordered_at=query.order_submitted_at)
|
|
for candidate in candidates
|
|
)
|
|
return PurchaseReconcileScan(
|
|
candidates=candidates,
|
|
diagnostics={"pages_scanned": 1},
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self.calls.append(("reconcile_close",))
|
|
|
|
|
|
class PurchaseRecoveryTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.db_path = Path(self.temporary.name) / "client.db"
|
|
self.repository = TaskRepository(self.db_path)
|
|
self.gateway = MockAdminGateway()
|
|
self.client = ClientInfo("CLIENT-001")
|
|
|
|
def tearDown(self) -> None:
|
|
self.temporary.cleanup()
|
|
|
|
def _add(
|
|
self, task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
|
|
) -> None:
|
|
task = purchase_admin_task(task_id, execution_mode)
|
|
self.gateway.enqueue_task(task, self.client.client_id)
|
|
claimed = self.gateway.claim_next(
|
|
self.client,
|
|
ClaimCapabilities(
|
|
supported_types=(TaskType.PURCHASE,),
|
|
purchase_mode=(
|
|
"live" if execution_mode == "live" else "dry_run"
|
|
),
|
|
),
|
|
)
|
|
assert claimed is not None
|
|
self.repository.add_claimed_task(
|
|
admin_task_to_new_claimed_task(claimed)
|
|
)
|
|
|
|
def _service(self, adapter, *, cancelled=lambda: False):
|
|
return PurchaseTaskService(
|
|
self.gateway,
|
|
self.repository,
|
|
self.client,
|
|
"USB-001",
|
|
lambda _address, _cancelled: adapter,
|
|
cancelled=cancelled,
|
|
)
|
|
|
|
def _interrupt_after_irreversible(self, task_id: str) -> None:
|
|
self._add(task_id, "live")
|
|
started = self.repository.start_purchase_run(task_id, "USB-001")
|
|
self.repository.mark_purchase_irreversible(
|
|
task_id,
|
|
started.attempt_id,
|
|
{
|
|
"options": dict(OPTIONS),
|
|
"quantity": 2,
|
|
"unit_price_cent": 4200,
|
|
"total_price_cent": 8400,
|
|
},
|
|
)
|
|
self.repository.recover_interrupted_work()
|
|
|
|
def test_critical_action_failure_keeps_last_persisted_step(self):
|
|
cases = {
|
|
"open_goods": "purchase_open_goods",
|
|
"select_options": "purchase_select_options",
|
|
"set_quantity": "purchase_set_quantity",
|
|
"enter_confirmation": "purchase_enter_confirmation",
|
|
"stop_before_submit": "purchase_dry_run_stopped",
|
|
}
|
|
for index, (action, expected_step) in enumerate(cases.items()):
|
|
with self.subTest(action=action):
|
|
task_id = f"PUR-FAULT-{index}"
|
|
self._add(task_id)
|
|
|
|
self._service(FaultAdapter(action)).execute_selected(task_id)
|
|
|
|
run = self.repository.latest_task_run(task_id)
|
|
assert run is not None
|
|
self.assertEqual(run.current_step, expected_step)
|
|
self.assertEqual(run.error_code, "DEVICE_DISCONNECTED")
|
|
self.assertEqual(run.diagnostics_json["action"], action)
|
|
detail = self.repository.get_task(task_id)
|
|
assert detail is not None
|
|
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
|
|
|
def test_stop_request_is_saved_before_any_device_action(self):
|
|
self._add("PUR-STOP")
|
|
checks = iter((False, True))
|
|
adapter = FaultAdapter()
|
|
|
|
outcome = self._service(
|
|
adapter, cancelled=lambda: next(checks, True)
|
|
).execute_selected("PUR-STOP")
|
|
|
|
self.assertEqual(outcome.kind, "failed")
|
|
detail = self.repository.get_task("PUR-STOP")
|
|
run = self.repository.latest_task_run("PUR-STOP")
|
|
assert detail is not None and run is not None
|
|
self.assertEqual(detail.status, TaskStatus.CANCELLED)
|
|
self.assertEqual(run.current_step, "purchase_open_goods")
|
|
self.assertEqual(run.run_status, RunStatus.CANCELLED)
|
|
|
|
def test_restart_before_irreversible_closes_old_run_then_retries(self):
|
|
self._add()
|
|
first = self.repository.start_purchase_run("PUR-RECOVER", "USB-001")
|
|
self.repository.update_purchase_step(
|
|
"PUR-RECOVER", first.attempt_id, "purchase_select_options"
|
|
)
|
|
|
|
self.repository.recover_interrupted_work()
|
|
|
|
old_run = self.repository.latest_task_run("PUR-RECOVER")
|
|
detail = self.repository.get_task("PUR-RECOVER")
|
|
assert old_run is not None and detail is not None
|
|
self.assertEqual(old_run.run_status, RunStatus.FAILED)
|
|
self.assertEqual(old_run.current_step, "purchase_select_options")
|
|
self.assertEqual(detail.status, TaskStatus.CLAIMED)
|
|
second = self.repository.start_purchase_run(
|
|
"PUR-RECOVER", "USB-001"
|
|
)
|
|
self.assertEqual(second.attempt_no, 2)
|
|
with self.assertRaisesRegex(ValueError, "不能开始演练"):
|
|
self.repository.start_purchase_run("PUR-RECOVER", "USB-001")
|
|
|
|
def test_irreversible_restart_only_reconciles_and_never_purchases(self):
|
|
self._interrupt_after_irreversible("PUR-RECOVER")
|
|
calls = []
|
|
|
|
def purchase_factory(_address, _cancelled):
|
|
calls.append(("purchase",))
|
|
return FaultAdapter()
|
|
|
|
dispatcher = TaskDispatcher(
|
|
self.gateway,
|
|
self.repository,
|
|
self.client,
|
|
"USB-001",
|
|
purchase_adapter_factory=purchase_factory,
|
|
purchase_reconcile_factory=(
|
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls)
|
|
),
|
|
device_connection_checker=lambda _serial: None,
|
|
)
|
|
|
|
first = dispatcher.execute_one()
|
|
pending = self.repository.next_pending_outbox()
|
|
matched_detail = self.repository.get_task("PUR-RECOVER")
|
|
matched_run = self.repository.latest_task_run("PUR-RECOVER")
|
|
duplicate = self.repository.save_matched_purchase_reconciliation(
|
|
"PUR-RECOVER",
|
|
matched_run.attempt_id,
|
|
matched_detail.pdd_data,
|
|
{"mode": "reconcile_only"},
|
|
)
|
|
self.assertEqual(duplicate.id, pending.id)
|
|
second = dispatcher.execute_one()
|
|
third = dispatcher.execute_one()
|
|
|
|
self.assertEqual(first.kind, "result_pending")
|
|
self.assertEqual(second.kind, "succeeded")
|
|
self.assertEqual(third.kind, "no_task")
|
|
self.assertNotIn(("purchase",), calls)
|
|
self.assertEqual(calls.count(("reconcile", "737116531267")), 1)
|
|
detail = self.repository.get_task("PUR-RECOVER")
|
|
run = self.repository.latest_task_run("PUR-RECOVER")
|
|
assert detail is not None and run is not None
|
|
self.assertEqual(detail.current_step, "completed")
|
|
self.assertEqual(
|
|
run.diagnostics_json["reconciliation"]["mode"],
|
|
"reconcile_only",
|
|
)
|
|
self.assertEqual(
|
|
detail.pdd_data["purchase"]["payment_status"], "unpaid"
|
|
)
|
|
self.assertEqual(detail.pdd_data["purchase"]["order_no"], "ORDER-001")
|
|
self.assertEqual(
|
|
detail.pdd_data["purchase"]["confirmed"],
|
|
{
|
|
"options": OPTIONS,
|
|
"quantity": 2,
|
|
"unit_price_cent": 4200,
|
|
"total_price_cent": 8400,
|
|
},
|
|
)
|
|
|
|
def test_reconcile_device_failure_is_recorded_as_unknown(self):
|
|
task_id = "PUR-RECONCILE-OFFLINE"
|
|
self._interrupt_after_irreversible(task_id)
|
|
purchase_calls = []
|
|
|
|
def unavailable_reconcile(_address, _cancelled):
|
|
raise ConnectionError("核对设备已断开")
|
|
|
|
outcome = TaskDispatcher(
|
|
self.gateway,
|
|
self.repository,
|
|
self.client,
|
|
"USB-001",
|
|
purchase_adapter_factory=(
|
|
lambda _address, _cancelled: purchase_calls.append("purchase")
|
|
),
|
|
purchase_reconcile_factory=unavailable_reconcile,
|
|
device_connection_checker=lambda _serial: None,
|
|
).execute_one()
|
|
|
|
self.assertEqual(outcome.kind, "manual_review")
|
|
self.assertIn("核对设备已断开", outcome.message)
|
|
self.assertNotIn("时间范围", outcome.message)
|
|
self.assertEqual(purchase_calls, [])
|
|
detail = self.repository.get_task(task_id)
|
|
run = self.repository.latest_task_run(task_id)
|
|
assert detail is not None and run is not None
|
|
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
|
self.assertEqual(run.error_code, "ORDER_MATCH_UNCERTAIN")
|
|
self.assertIn(
|
|
"核对设备已断开",
|
|
run.diagnostics_json["reconciliation"]["error_message"],
|
|
)
|
|
|
|
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
|
|
cases = {
|
|
"EMPTY": ((), "ORDER_NOT_FOUND", False),
|
|
"MULTIPLE": (
|
|
(
|
|
PurchaseOrderCandidate(
|
|
"ORDER-A",
|
|
"737116531267",
|
|
OPTIONS,
|
|
2,
|
|
8400,
|
|
"2026-08-10T08:00:00Z",
|
|
"2026-08-10 16:00:00",
|
|
"unpaid",
|
|
),
|
|
PurchaseOrderCandidate(
|
|
"ORDER-B",
|
|
"737116531267",
|
|
OPTIONS,
|
|
2,
|
|
8400,
|
|
"2026-08-10T08:00:00Z",
|
|
"2026-08-10 16:00:00",
|
|
"unpaid",
|
|
),
|
|
),
|
|
"AMBIGUOUS_ORDER_MATCH",
|
|
False,
|
|
),
|
|
"OUT_OF_TIME": (
|
|
(
|
|
PurchaseOrderCandidate(
|
|
order_no="ORDER-C",
|
|
ordered_at="2020-01-01T00:00:00Z",
|
|
ordered_at_raw="2020-01-01 08:00:00",
|
|
payment_status="unpaid",
|
|
),
|
|
),
|
|
"ORDER_MATCH_UNCERTAIN",
|
|
True,
|
|
),
|
|
"PAID": (
|
|
(
|
|
PurchaseOrderCandidate(
|
|
"ORDER-D",
|
|
"737116531267",
|
|
OPTIONS,
|
|
2,
|
|
8400,
|
|
"2026-08-10T08:00:00Z",
|
|
"2026-08-10 16:00:00",
|
|
"paid",
|
|
),
|
|
),
|
|
"ORDER_MATCH_UNCERTAIN",
|
|
False,
|
|
),
|
|
"MISSING_ORDER_NO": (
|
|
(
|
|
PurchaseOrderCandidate(
|
|
order_no="",
|
|
payment_status="unpaid",
|
|
),
|
|
),
|
|
"ORDER_MATCH_UNCERTAIN",
|
|
False,
|
|
),
|
|
"MISSING_TIME": (
|
|
(
|
|
PurchaseOrderCandidate(
|
|
order_no="ORDER-E",
|
|
ordered_at="",
|
|
payment_status="unpaid",
|
|
),
|
|
),
|
|
"ORDER_MATCH_UNCERTAIN",
|
|
True,
|
|
),
|
|
}
|
|
for suffix, (candidates, error_code, preserve_times) in cases.items():
|
|
with self.subTest(suffix=suffix):
|
|
task_id = f"PUR-{suffix}"
|
|
self._interrupt_after_irreversible(task_id)
|
|
calls = []
|
|
outcome = TaskDispatcher(
|
|
self.gateway,
|
|
self.repository,
|
|
self.client,
|
|
"USB-001",
|
|
purchase_adapter_factory=lambda *_args: calls.append(
|
|
"purchase"
|
|
),
|
|
purchase_reconcile_factory=(
|
|
lambda _address, _cancelled, values=candidates,
|
|
keep_times=preserve_times:
|
|
ReadOnlyReconcileAdapter(
|
|
calls,
|
|
values,
|
|
preserve_times=keep_times,
|
|
)
|
|
),
|
|
device_connection_checker=lambda _serial: None,
|
|
).execute_one()
|
|
|
|
self.assertEqual(outcome.kind, "manual_review")
|
|
self.assertNotIn("purchase", calls)
|
|
detail = self.repository.get_task(task_id)
|
|
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
|
self.assertEqual(detail.last_error_code, error_code)
|
|
self.assertIsNone(self.repository.next_pending_outbox())
|
|
|
|
def test_restart_after_match_only_submits_outbox_without_reading_phone(self):
|
|
task_id = "PUR-OUTBOX-RESTART"
|
|
self._interrupt_after_irreversible(task_id)
|
|
first_calls = []
|
|
first = TaskDispatcher(
|
|
self.gateway,
|
|
self.repository,
|
|
self.client,
|
|
"USB-001",
|
|
purchase_reconcile_factory=(
|
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
|
|
first_calls
|
|
)
|
|
),
|
|
device_connection_checker=lambda _serial: None,
|
|
).execute_one()
|
|
self.assertEqual(first.kind, "result_pending")
|
|
|
|
restarted_repository = TaskRepository(self.db_path)
|
|
restarted_calls = []
|
|
second = TaskDispatcher(
|
|
self.gateway,
|
|
restarted_repository,
|
|
self.client,
|
|
"",
|
|
purchase_adapter_factory=lambda *_args: restarted_calls.append(
|
|
"purchase"
|
|
),
|
|
purchase_reconcile_factory=lambda *_args: restarted_calls.append(
|
|
"reconcile"
|
|
),
|
|
).execute_one()
|
|
|
|
self.assertEqual(second.kind, "succeeded")
|
|
self.assertEqual(restarted_calls, [])
|
|
self.assertEqual(
|
|
restarted_repository.get_task(task_id).status,
|
|
TaskStatus.SUCCEEDED,
|
|
)
|
|
|
|
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))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|