Files
cmautobuy/client/test/test_purchase_task_service.py
T

730 lines
27 KiB
Python
Raw Normal View History

2026-08-09 23:39:37 +08:00
"""采购任务演练应用服务测试;全部使用 Mock,不连接真实手机。"""
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
2026-08-09 23:39:37 +08:00
from src.admin_gateway import (
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
2026-08-09 23:39:37 +08:00
)
from src.mock_admin_gateway import MockAdminGateway
from src.db import open_database
from src.pdd_purchase_adapter import (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation,
PurchaseSpecCandidateSnapshot,
PurchasePageState,
)
from src.pdd_purchase_reconcile_adapter import PurchaseOrderCandidate
2026-08-09 23:39:37 +08:00
from src.purchase_task_service import PurchaseTaskService
from src.purchase_reconcile_service import PurchaseReconcileService
2026-08-09 23:39:37 +08:00
from src.task_models import NewClaimedTask, TaskStatus, TaskType
from src.task_repository import TaskRepository
OPTIONS = {"color": "黑色", "size": "L", "bundle": "标准版"}
class RecordingDryRunAdapter(PddPurchaseAdapter):
"""只记录调用的演练适配器,不提供提交订单能力。"""
def __init__(
self,
*,
goods_id: str = "737116531267",
price_cent: int = 4200,
candidate_count: int = 1,
forced_page: str = "",
wrong_options: bool = False,
in_stock: bool = True,
submit_candidate_count: int = 1,
2026-08-09 23:39:37 +08:00
) -> 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
2026-08-09 23:39:37 +08:00
self.options = {}
self.quantity = 0
self.page_kind = "goods"
self.calls = []
def open_goods(self, goods_url: str) -> None:
self.calls.append(("open_goods", goods_url))
def read_state(self) -> PurchasePageState:
self.calls.append(("read_state",))
options = self.options
if self.wrong_options and options:
options = {**options, "size": "XL"}
return PurchasePageState(
page_kind=self.forced_page or self.page_kind,
goods_id=self.goods_id,
selected_options=dict(options),
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,
2026-08-09 23:39:37 +08:00
)
def select_options(self, options) -> None:
self.calls.append(("select_options", dict(options)))
self.options = dict(options)
def set_quantity(self, quantity: int) -> None:
self.calls.append(("set_quantity", quantity))
self.quantity = quantity
def enter_confirmation(self) -> None:
self.calls.append(("enter_confirmation",))
self.page_kind = "order_confirmation"
def stop_before_submit(self) -> None:
self.calls.append(("stop_before_submit",))
def close(self) -> None:
self.calls.append(("close",))
class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
"""只记录一次提交调用的 live 测试 Adapter。"""
def __init__(
self,
*,
submit_error: bool = False,
address_error: bool = False,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.submit_error = submit_error
self.address_error = address_error
self.submit_count = 0
def update_shipping_address(self, purchase_number: str) -> None:
self.calls.append(("update_shipping_address", purchase_number))
if self.address_error:
raise PddPurchaseError(
"PURCHASE_ADDRESS_PAGE_TIMEOUT",
"地址页面切换超时",
step="purchase_update_shipping_address",
)
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 UnavailableGoodsAdapter(RecordingDryRunAdapter):
def open_goods(self, goods_url: str) -> None:
self.calls.append(("open_goods", goods_url))
raise PddPurchaseError(
"PDD_GOODS_UNAVAILABLE",
"商品链接已失效,PDD 无法打开商品详情页并返回了首页",
step="purchase_open_goods",
retryable=False,
)
class ResolvingDryRunAdapter(RecordingDryRunAdapter):
"""首次选择报告尺码不存在,随后记录审计候选的精确应用。"""
def __init__(self) -> None:
super().__init__()
self.snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id=self.goods_id,
selected_color="黑色",
target_size="L",
dimension_name="尺码",
observations=(
PurchaseSizeObservation("120斤", True),
PurchaseSizeObservation("130斤", True),
),
observed_at="2026-08-17T08:00:00Z",
)
def select_options(self, options) -> None:
self.calls.append(("select_options", dict(options)))
self.options = {
key: value for key, value in options.items() if key != "size"
}
raise PddPurchaseSpecResolutionRequired(self.snapshot)
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
self.calls.append(
("apply_resolved_size", expected_snapshot.candidate_snapshot_hash,
candidate.candidate_id, candidate.raw_text)
)
self.options["size"] = candidate.raw_text
class ResolvingLiveAdapter(ResolvingDryRunAdapter, PddLivePurchaseAdapter):
def __init__(self) -> None:
super().__init__()
self.submit_count = 0
def update_shipping_address(self, purchase_number: str) -> None:
self.calls.append(("update_shipping_address", purchase_number))
def submit_order_once(self) -> None:
self.submit_count += 1
self.calls.append(("submit_order_once",))
class ChangedAfterResolutionAdapter(ResolvingDryRunAdapter):
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
"等待期间候选变化",
step="purchase_resolve_options",
)
2026-08-09 23:39:37 +08:00
class PurchaseTaskServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp_dir.name) / "client.db"
self.repository = TaskRepository(self.db_path)
2026-08-09 23:39:37 +08:00
self.gateway = MockAdminGateway()
self.client = ClientInfo("CLIENT-001", "测试客户端")
def tearDown(self) -> None:
self.temp_dir.cleanup()
def _prepare_task(
self,
*,
task_id: str = "PUR-001",
execution_mode: str = "dry_run",
) -> None:
2026-08-09 23:39:37 +08:00
task = AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=1,
priority=10,
execution_mode=execution_mode,
2026-08-09 23:39:37 +08:00
payload={
"goods_url": (
"https://mobile.yangkeduo.com/goods.html?"
"goods_id=737116531267"
),
"goods_id": "737116531267",
"options": dict(OPTIONS),
"quantity": 2,
"max_price_cent": 5000,
},
)
self.gateway.enqueue_task(task, self.client.client_id)
claimed = self.gateway.claim_next(
self.client,
ClaimCapabilities(
device=AndroidDeviceInfo("USB-001"),
supported_types=(TaskType.PURCHASE,),
purchase_mode=(
"live" if execution_mode == "live" else "dry_run"
),
2026-08-09 23:39:37 +08:00
),
)
assert claimed is not None
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=claimed.task_id,
task_type=claimed.task_type,
goods_url=str(claimed.payload["goods_url"]),
execution_mode=claimed.execution_mode,
2026-08-09 23:39:37 +08:00
goods_id=str(claimed.payload["goods_id"]),
quantity=int(claimed.payload["quantity"]),
priority=claimed.priority,
version=claimed.version,
admin_payload={
"id": claimed.task_id,
"type": claimed.task_type.value,
"version": claimed.version,
"priority": claimed.priority,
"payload": dict(claimed.payload),
},
)
)
def _service(self, adapter: RecordingDryRunAdapter):
return PurchaseTaskService(
self.gateway,
self.repository,
self.client,
"USB-001",
lambda _address, _cancelled: adapter,
)
def test_dynamic_options_dry_run_stops_before_order_submission(self):
self._prepare_task()
adapter = RecordingDryRunAdapter()
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "succeeded")
self.assertIn("没有提交订单", outcome.message)
self.assertIn(("select_options", OPTIONS), adapter.calls)
self.assertIn(("set_quantity", 2), adapter.calls)
self.assertIn(("enter_confirmation",), adapter.calls)
self.assertIn(("stop_before_submit",), adapter.calls)
detail = self.repository.get_task("PUR-001")
assert detail is not None
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
purchase = detail.pdd_data["purchase"]
self.assertEqual(purchase["mode"], "dry_run")
self.assertEqual(purchase["requested"]["options"], OPTIONS)
self.assertEqual(purchase["confirmed"]["options"], OPTIONS)
self.assertFalse(purchase["order_submitted"])
self.assertFalse(purchase["payment_attempted"])
def test_matched_spec_resolution_is_persisted_and_existing_flow_continues(self):
self._prepare_task(task_id="PUR-RESOLVE")
adapter = ResolvingDryRunAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-001",
"matched",
"rule",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
10000,
"唯一重量等价",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE")
self.assertEqual(outcome.kind, "succeeded")
self.assertTrue(any(call[0] == "apply_resolved_size" for call in adapter.calls))
detail = self.repository.get_task("PUR-RESOLVE")
purchase = detail.pdd_data["purchase"]
self.assertEqual(purchase["requested"]["options"], OPTIONS)
self.assertEqual(purchase["confirmed"]["options"]["size"], "120斤")
self.assertEqual(
purchase["spec_resolution"]["resolution_id"], "psr-001"
)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, resolution_id, outcome"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(tuple(row), ("resolved", "psr-001", "matched"))
def test_uncertain_spec_resolution_stops_before_quantity_and_submission(self):
self._prepare_task(task_id="PUR-UNCERTAIN")
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-uncertain",
"uncertain",
"ai",
adapter.snapshot.candidate_snapshot_hash,
None,
7000,
"无法唯一判断",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-UNCERTAIN")
self.assertEqual(outcome.kind, "task_failed")
self.assertFalse(any(call[0] == "apply_resolved_size" for call in adapter.calls))
self.assertNotIn(("set_quantity", 2), adapter.calls)
detail = self.repository.get_task("PUR-UNCERTAIN")
self.assertEqual(
detail.last_error_code, "PURCHASE_SPEC_RESOLUTION_UNCERTAIN"
)
def test_rejected_and_failed_spec_resolution_use_stable_failure_codes(self):
for outcome, expected_code in (
("rejected", "PURCHASE_SPEC_RESOLUTION_REJECTED"),
("failed", "PURCHASE_SPEC_RESOLUTION_FAILED"),
):
with self.subTest(outcome=outcome):
task_id = f"PUR-{outcome.upper()}"
self._prepare_task(task_id=task_id)
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
f"psr-{outcome}",
outcome,
None,
adapter.snapshot.candidate_snapshot_hash,
None,
None,
"没有可安全采用的候选",
"2026-08-17T08:00:01Z",
)
)
result = self._service(adapter).execute_selected(task_id)
self.assertEqual(result.kind, "task_failed")
self.assertEqual(
self.repository.get_task(task_id).last_error_code,
expected_code,
)
self.assertFalse(
any(call[0] == "apply_resolved_size" for call in adapter.calls)
)
def test_live_resolution_keeps_original_request_and_existing_safety_gates(self):
self._prepare_task(task_id="PUR-RESOLVE-LIVE", execution_mode="live")
adapter = ResolvingLiveAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-live",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9300,
"唯一候选",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE-LIVE")
self.assertEqual(outcome.kind, "reconcile_pending")
self.assertEqual(adapter.submit_count, 1)
task = self.repository.get_task("PUR-RESOLVE-LIVE")
run = self.repository.latest_task_run("PUR-RESOLVE-LIVE")
self.assertIsNotNone(run.irreversible_action_at)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["options"]["size"],
"120斤",
)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["spec_resolution"]
["resolution_id"],
"psr-live",
)
query = PurchaseReconcileService._query(task, run)
self.assertEqual(query.original_options, OPTIONS)
self.assertEqual(query.options["size"], "120斤")
self.assertEqual(query.spec_resolution["resolution_id"], "psr-live")
result = PurchaseReconcileService._result_data(
SimpleNamespace(_device_address="127.0.0.1:5555"),
task,
query,
PurchaseOrderCandidate(
order_no="ORDER-001",
ordered_at="2026-08-17T08:00:03Z",
ordered_at_raw="2026-08-17 16:00:03",
payment_status="unpaid",
),
)
self.assertEqual(result["purchase"]["requested"]["options"], OPTIONS)
self.assertEqual(
result["purchase"]["confirmed"]["options"]["size"], "120斤"
)
self.assertEqual(
result["purchase"]["spec_resolution"]["resolution_id"],
"psr-live",
)
def test_spec_resolution_timeout_keeps_persisted_request_and_does_not_retry(self):
self._prepare_task(task_id="PUR-RESOLVE-TIMEOUT")
adapter = ResolvingDryRunAdapter()
self.gateway.timeout_next_call()
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-TIMEOUT"
)
self.assertEqual(outcome.kind, "task_failed")
self.assertEqual(self.gateway.spec_resolution_count, 0)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, idempotency_key, request_json"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(row["status"], "pending")
self.assertTrue(row["idempotency_key"].startswith("spec-resolution-v1:"))
self.assertIn('"candidate_snapshot_hash"', row["request_json"])
def test_matched_resolution_page_change_failure_keeps_resolution_audit(self):
self._prepare_task(task_id="PUR-RESOLVE-CHANGED")
adapter = ChangedAfterResolutionAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-changed",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9000,
"模型匹配",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-CHANGED"
)
self.assertEqual(outcome.kind, "task_failed")
run = self.repository.latest_task_run("PUR-RESOLVE-CHANGED")
self.assertEqual(
run.diagnostics_json["spec_resolution"]["resolution_id"],
"psr-changed",
)
self.assertIsNone(run.irreversible_action_at)
def test_started_callback_runs_after_database_enters_running(self):
self._prepare_task(task_id="PUR-STARTED")
observed = []
service = PurchaseTaskService(
self.gateway,
self.repository,
self.client,
"USB-001",
lambda _address, _cancelled: RecordingDryRunAdapter(),
started=lambda task_id: observed.append(
(task_id, self.repository.get_task(task_id).status)
),
)
service.execute_selected("PUR-STARTED")
self.assertEqual(observed, [("PUR-STARTED", TaskStatus.RUNNING)])
def test_order_total_above_limit_stops_on_confirmation(self):
2026-08-09 23:39:37 +08:00
self._prepare_task()
adapter = RecordingDryRunAdapter(price_cent=5001)
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "task_failed")
self.assertIn(("enter_confirmation",), adapter.calls)
2026-08-09 23:39:37 +08:00
detail = self.repository.get_task("PUR-001")
assert detail is not None
self.assertEqual(detail.status, TaskStatus.FAILED)
2026-08-09 23:39:37 +08:00
self.assertEqual(detail.last_error_code, "PURCHASE_PRICE_EXCEEDED")
self.assertIn("订单总价", detail.last_error_message)
2026-08-09 23:39:37 +08:00
def test_unavailable_goods_stops_before_specs_and_is_not_retryable(self):
self._prepare_task()
adapter = UnavailableGoodsAdapter()
outcome = self._service(adapter).execute_one_local()
self.assertEqual("task_failed", outcome.kind)
self.assertNotIn(("select_options", OPTIONS), adapter.calls)
detail = self.repository.get_task("PUR-001")
self.assertEqual(TaskStatus.FAILED, detail.status)
self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code)
2026-08-09 23:39:37 +08:00
def test_result_submit_timeout_does_not_run_adapter_twice(self):
self._prepare_task()
adapter = RecordingDryRunAdapter()
self.gateway.timeout_next_call()
first = self._service(adapter).execute_one_local()
first_call_count = len(adapter.calls)
second = self._service(adapter).execute_one_local()
self.assertEqual(first.kind, "result_pending")
self.assertEqual(second.kind, "succeeded")
self.assertEqual(len(adapter.calls), first_call_count)
self.assertEqual(self.gateway.submission_count, 1)
def test_invalid_pages_and_ambiguous_target_fail_safely(self):
cases = (
("captcha", 1, "PDD_PAGE_CAPTCHA", "global_failed"),
("login_required", 1, "PDD_PAGE_LOGIN_REQUIRED", "global_failed"),
("unknown", 1, "PDD_PAGE_UNKNOWN", "global_failed"),
("", 2, "PURCHASE_AMBIGUOUS_TARGET", "task_failed"),
2026-08-09 23:39:37 +08:00
)
for index, (page, candidates, expected_code, expected_kind) in enumerate(cases):
2026-08-09 23:39:37 +08:00
with self.subTest(page=page, candidates=candidates):
task_id = f"PUR-BLOCK-{index}"
self._prepare_task(task_id=task_id)
adapter = RecordingDryRunAdapter(
forced_page=page,
candidate_count=candidates,
)
outcome = self._service(adapter).execute_selected(task_id)
self.assertEqual(outcome.kind, expected_kind)
2026-08-09 23:39:37 +08:00
self.assertNotIn(("enter_confirmation",), adapter.calls)
detail = self.repository.get_task(task_id)
assert detail is not None
self.assertEqual(detail.last_error_code, expected_code)
def test_wrong_goods_or_options_never_reaches_confirmation(self):
cases = (
({"goods_id": "OTHER"}, "PURCHASE_GOODS_MISMATCH"),
({"wrong_options": True}, "PURCHASE_OPTIONS_MISMATCH"),
)
for index, (kwargs, expected_code) in enumerate(cases):
with self.subTest(expected_code=expected_code):
task_id = f"PUR-MISMATCH-{index}"
self._prepare_task(task_id=task_id)
adapter = RecordingDryRunAdapter(**kwargs)
self._service(adapter).execute_selected(task_id)
self.assertNotIn(("enter_confirmation",), adapter.calls)
detail = self.repository.get_task(task_id)
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, "reconcile_pending")
self.assertEqual(adapter.submit_count, 1)
self.assertIn(("update_shipping_address", "PUR-001"), adapter.calls)
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, "task_failed")
self.assertEqual(adapter.submit_count, 0)
run = self.repository.latest_task_run("PUR-001")
self.assertIsNone(run.irreversible_action_at)
def test_live_address_failure_stops_before_irreversible(self):
self._prepare_task(execution_mode="live")
adapter = RecordingLiveAdapter(address_error=True)
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "task_failed")
self.assertEqual(adapter.submit_count, 0)
run = self.repository.latest_task_run("PUR-001")
self.assertIsNone(run.irreversible_action_at)
detail = self.repository.get_task("PUR-001")
self.assertEqual(
detail.last_error_code, "PURCHASE_ADDRESS_PAGE_TIMEOUT"
)
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, "reconcile_pending")
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, "task_failed")
self.assertEqual(adapter.calls, [("close",)])
run = self.repository.latest_task_run("PUR-001")
self.assertIsNone(run.irreversible_action_at)
2026-08-09 23:39:37 +08:00
def test_purchase_task_with_missing_safety_fields_is_reported(self):
task_id = "PUR-INVALID"
task = AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=1,
priority=0,
payload={"goods_url": "https://example.test/goods"},
)
self.gateway.enqueue_task(task, self.client.client_id)
self.gateway.claim_next(
self.client,
ClaimCapabilities(supported_types=(TaskType.PURCHASE,)),
)
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=task_id,
task_type=TaskType.PURCHASE,
goods_url="https://example.test/goods",
admin_payload={
"id": task_id,
"type": "purchase",
"version": 1,
"payload": dict(task.payload),
},
)
)
adapter = RecordingDryRunAdapter()
outcome = self._service(adapter).execute_selected(task_id)
self.assertEqual(outcome.kind, "task_failed")
2026-08-09 23:39:37 +08:00
self.assertNotIn(("open_goods", "https://example.test/goods"), adapter.calls)
detail = self.repository.get_task(task_id)
assert detail is not None
self.assertEqual(detail.last_error_code, "PURCHASE_TASK_INVALID")
if __name__ == "__main__":
unittest.main()