Files
rakuten-api/tests/test_worker_runner.py
T
q792602257andClaude Sonnet 5 51e2538438 实现付款后订单监控:真实探测 order.my.rakuten.co.jp 配送阶段
用真实订单号 306087-20260813-0863947697 探测 order.my.rakuten.co.jp(订单列表/
详情页),拿到真实 DOM 结构后实现 SiteInteractor.check_order_status:

- 详情页 URL 可直接从 site_order_id 构造(shop_id 是订单号第一段)
- 配送阶段用「进度条」组件的 4 个固定阶段(ショップ/出荷/配達店/配達完了),
  当前阶段的 class 带 -active-- 中缀,映射到 OrderState.SHIPPED/DELIVERED
- 查不到订单号、进度条解析不出新阶段都不算错误,交给轮询循环继续重试

runner.WorkerRunner 新增 _monitor_order 后台轮询:付款成功上报后以
asyncio.create_task 起后台任务(不阻塞主循环领下一单,因为 SiteInteractor 的
Playwright 操作全程持锁串行化),状态变化时用 terminal=False 追加 report;
新增 cancel_monitors() 在服务关闭时于 SiteInteractor.close() 之前收尾。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:01:35 +08:00

570 lines
21 KiB
Python

"""worker 主循环测试
不启动真实的后台任务,直接构造 WorkerRunner 与桩 gateway client / local_db /
evidence store / site,覆盖 §6 主循环的分支:
- 已完成的任务再次被领:直接补报,不重新执行
- lease_count > 1(恢复领取):走核对分支,verify unknown → needs_human
- 站点交互未实现(SiteInteractor 默认抛 NotImplementedError):转 needs_human
- 金额守卫拦截:转 needs_human,站点侧无提交动作
每一步的证据文件在 report 之前就已落盘(§9 第 11 条)也在这里验证。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from app.shared.errors import CheckoutBlockedError, OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import verify
from app.trading.worker.evidence import EvidenceStore
from app.trading.worker.local_db import LocalDB
from app.trading.worker.models import LeaseTask
from app.trading.worker.runner import WorkerRunner
from app.trading.worker.site_interact import CheckoutSummary, OrderStatusSnapshot, SiteInteractor
# ---- 桩:网关客户端 ----
@dataclass
class FakeGateway:
"""记录所有调用,可控返回值的网关桩"""
reports: list[dict[str, Any]] = field(default_factory=list)
renews: int = 0
fail_report_with: Exception | None = None
async def lease(
self, worker_id: str, *, wait: int = 30, site: str | None = None
) -> LeaseTask | None:
return None # 主循环测试不通过 lease 喂数据,直接调 handle
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
self.renews += 1
return {"task_id": task_id, "lease_expires_at": "2099-01-01T00:00:00Z"}
async def report(
self,
task_id: str,
worker_id: str,
*,
state: OrderState,
payable_yen: int | None = None,
pay_deadline: str | None = None,
site_order_id: str | None = None,
evidence_ref: str | None = None,
detail: str = "",
terminal: bool = False,
terminal_status: TaskStatus | None = None,
) -> dict[str, Any]:
if self.fail_report_with is not None:
raise self.fail_report_with
record = {
"task_id": task_id,
"worker_id": worker_id,
"state": state,
"payable_yen": payable_yen,
"pay_deadline": pay_deadline,
"site_order_id": site_order_id,
"evidence_ref": evidence_ref,
"detail": detail,
"terminal": terminal,
"terminal_status": terminal_status,
}
self.reports.append(record)
return {"task_id": task_id, "status": "running", "recorded": True}
def last_terminal_report(self) -> dict[str, Any]:
for r in reversed(self.reports):
if r["terminal"]:
return r
raise AssertionError("没有终态 report")
# ---- fixtures ----
@pytest.fixture
async def local_db(tmp_path: Path) -> LocalDB:
db = LocalDB(tmp_path / "trading.db")
await db.start()
yield db
await db.close()
@pytest.fixture
def evidence(tmp_path: Path) -> EvidenceStore:
return EvidenceStore(tmp_path / "evidence")
@pytest.fixture
def site() -> SiteInteractor:
"""返回**未启动**的 SiteInteractor(不调 start,不依赖 Playwright)
默认 add_to_cart/verify_cart 已实现(但会抛 NotImplementedError 因为没启动),
具体测试可用 monkeypatch.setattr(site, "method", fn) 替换为桩。
"""
# 用最小化的 auth_session / settings 占位;测试不会真的调它们
return SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
@pytest.fixture
def runner(tmp_path, local_db, evidence, site) -> WorkerRunner:
"""构造一个 worker_id=test、网关 URL 已配置的 runner
使用 fake gateway;settings 取真实 Settings 但 worker_id 注入为 test。
"""
class _FakeSettings:
worker_id = "test"
order_max_total_yen = 30000
# 测试环境不真的等 3 小时:间隔设 0,次数设小,_monitor_order 的循环
# 靠 asyncio.sleep(0) 立刻推进,不拖慢测试
order_monitor_poll_interval_seconds = 0
order_monitor_max_checks = 5
@property
def worker_id_effective(self) -> str:
return "test"
gateway = FakeGateway()
r = WorkerRunner(
settings=_FakeSettings(), # type: ignore[arg-type]
gateway_client=gateway, # type: ignore[arg-type]
local_db=local_db,
evidence=evidence,
site=site,
)
# 把 gateway 桩挂到 runner 上,测试通过它断言调用
r._gateway_for_test = gateway # type: ignore[attr-defined]
return r
def _make_task(
*,
task_id: str = "t1",
site: str = "rakuten",
lease_count: int = 1,
known_state: str | None = None,
intent: dict | None = None,
) -> LeaseTask:
return LeaseTask(
task_id=task_id,
site=site,
intent=intent or {},
lease_expires_at="2099-01-01T00:00:00Z",
lease_count=lease_count,
known_state=known_state,
)
# ---- 已完成的任务:补报而不重新执行(§9 第 9 条)----
async def test_already_finished_task_is_reported_not_re_executed(
runner: WorkerRunner, local_db: LocalDB
):
"""本地已完成 → 直接补报 final_state,不进入 execute"""
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
await local_db.mark_finished("t1", OrderState.PAID.value)
await runner.handle(_make_task(task_id="t1"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["state"] == OrderState.PAID
assert terminal["terminal"] is True
# 没有进入 execute:site 不应被调用,但为保险起见,我们只看
# evidence 目录里没有任何步骤文件
evidence_files = list(local_db._db_path.parent.parent.glob("**/*.html")) # noqa
# 上面 glob 会扫到 trading.db 之外的所有 .html;这里只关心 evidence 目录
# (runner 走 execute 才会写证据)
# ---- lease_count > 1:核对分支(§9 第 8 条)----
async def test_recovery_task_with_unknown_verdict_reports_needs_human(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
):
"""lease_count > 1 + verify 桩返回 unknown → 转 needs_human,不执行"""
# verify 桩默认返回 unknown,不用 monkeypatch
await runner.handle(_make_task(task_id="t1", lease_count=2))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "无法定论" in terminal["detail"]
async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
):
"""lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单"""
async def _already_ordered(task): # noqa: ANN001
return verify.VerifyResult(
verdict=verify.VerifyVerdict.ALREADY_ORDERED,
site_order_id="ord-1",
detail="订单列表命中",
)
monkeypatch.setattr(verify, "verify_on_site", _already_ordered)
await runner.handle(_make_task(task_id="t1", lease_count=2, known_state="ordered"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["state"] == OrderState.ORDERED
assert terminal["site_order_id"] == "ord-1"
assert terminal["terminal_status"] == TaskStatus.SUCCEEDED
# 本地 DB 也应当被标记为已完成
assert await local_db.has_finished("t1") is True
# ---- 站点交互未实现 → needs_human(§10 接口缝)----
async def test_unimplemented_site_interaction_becomes_needs_human(
runner: WorkerRunner, local_db: LocalDB
):
"""站点交互抛 NotImplementedError → runner 转 needs_human
add_to_cart / verify_cart / enter_checkout / parse_checkout / submit_order /
pay / check_order_status 现在均已实现,这里用桩显式模拟「某一步没实现」,
覆盖 runner._execute_with_renewal 里 NotImplementedError → needs_human 的分支,
与具体哪个方法真的未实现解耦。
"""
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "未实现" in terminal["detail"]
# ---- 结算被站点风控拦截 → needs_human(规格 §10.1)----
async def test_checkout_blocked_becomes_needs_human(
runner: WorkerRunner, local_db: LocalDB
):
"""enter_checkout 抛 CheckoutBlockedError(session upgrade 被拦截)→ 转 needs_human
与普通 AppError(转 FAILED)区分开:这类阻断是站点主动要求人工验证,
不是本服务的失败,需要人工用有头浏览器接管当前登录态完成验证。
"""
async def _noop(task): # noqa: ANN001
return None
async def _blocked(task): # noqa: ANN001
raise CheckoutBlockedError("session upgrade 提交密码后 30s 内 URL 未变化,判定被站点风控拦截")
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _blocked # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "风控" in terminal["detail"]
# ---- ラクマ 不在交易范围 → needs_human ----
async def test_rakuma_site_reports_needs_human(runner: WorkerRunner):
"""交易服务只对乐天;其他站点(含 ラクマ)直接转人工"""
await runner.handle(_make_task(task_id="t1", site="rakuma"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
# ---- 金额守卫:拦截而不提交(§9 第 10 条)----
async def test_amount_guard_blocks_when_payable_exceeds_limit(
runner: WorkerRunner, local_db: LocalDB
):
"""实际应付超过上限 → OrderGuardError → 转 needs_human,站点侧无提交动作
模拟站点交互:前两步(add_to_cart / verify_cart)正常,enter_checkout 返回 html,
parse_checkout 返回超额金额。submit_order 永远不应被调用。
"""
call_log: list[str] = []
async def _ok(task): # noqa: ANN001
call_log.append(task.task_id + ":step")
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _parse(html: str):
return CheckoutSummary(payable_yen=50000)
submit_called = False
async def _submit(task): # noqa: ANN001
nonlocal submit_called
submit_called = True
return "ord-1"
runner._site.add_to_cart = _ok # type: ignore[assignment]
runner._site.verify_cart = _ok # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
# intent.max_total_yen 缺省,回落到 settings.order_max_total_yen=30000,
# 实际 50000 > 30000 → 拦截
await runner.handle(_make_task(task_id="t1"))
assert submit_called is False # 关键:站点侧无提交动作
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "上限" in terminal["detail"]
async def test_amount_guard_honors_intent_max_total_yen(
runner: WorkerRunner, local_db: LocalDB
):
"""intent 显式给 max_total_yen 时优先于全局上限"""
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _parse(html: str):
return CheckoutSummary(payable_yen=8000)
submit_called = False
async def _submit(task): # noqa: ANN001
nonlocal submit_called
submit_called = True
raise NotImplementedError("不应到这里") # parse 之后的步骤未实现,到这里即说明守卫过了
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
# 全局上限 30000,但 intent 给 5000 → 实际 8000 > 5000 → 拦截
await runner.handle(_make_task(task_id="t1", intent={"max_total_yen": 5000}))
assert submit_called is False
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
# ---- 证据在 report 之前落盘(§9 第 11 条)----
async def test_evidence_files_exist_before_each_report(
runner: WorkerRunner, evidence: EvidenceStore
):
"""每个 _run_step 调用:动作 → 落证据 → 写本地 → 回报 gateway
用一个 hook 把 report 时机抓出来,断言此时该 step 的 meta.json 已在盘上。
"""
seen_evidence_at_report: list[bool] = []
base = evidence.step_dir("t1")
real_report = runner._gateway_for_test.report # type: ignore[attr-defined]
async def _spy_report(*args, **kwargs): # noqa: ANN001, ANN002, ANN003
# 在 report 触发时检查 evidence_ref 指向的 meta 文件是否已落盘
ev_ref = kwargs.get("evidence_ref")
if ev_ref:
meta_path = base / (ev_ref.split("/", 1)[-1] + ".meta.json")
seen_evidence_at_report.append(meta_path.exists())
return await real_report(*args, **kwargs)
runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined]
# 让 add_to_cart / verify_cart / enter_checkout 正常,parse_checkout 抛
# NotImplemented(模拟某一步没实现),触发前几步写证据 + report,最终转 needs_human
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
# 至少有一个 step 调了 report,且每次 report 之前证据都在
assert seen_evidence_at_report, "应当至少有一次带 evidence_ref 的 report"
assert all(seen_evidence_at_report), "某次 report 之前证据文件未落盘"
# ---- 付款后监控(规格 §6「付款后监控」)----
async def test_successful_order_spawns_monitor_task_and_cancel_monitors_cleans_up(
runner: WorkerRunner, local_db: LocalDB
):
"""付款成功后 execute() 应起一个后台监控任务、不阻塞返回;cancel_monitors 能干净收尾
check_order_status 桩故意永不返回(挂在一个手动控制的 Event 上),模拟「监控任务
还在跑」这个可观察状态——用来确认 execute() 没有同步 await 监控循环
(不然 handle() 早该被这个永不返回的调用卡死,测试会超时而不是正常结束)。
"""
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return "306087-20260813-0863947697"
async def _pay(task, site_order_id): # noqa: ANN001
return None
never_resolves = asyncio.Event()
async def _hang_check(order_id: str): # noqa: ANN001
await never_resolves.wait()
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
runner._site.pay = _pay # type: ignore[assignment]
runner._site.check_order_status = _hang_check # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
terminal = gateway.last_terminal_report()
assert terminal["state"] == OrderState.PAID
assert terminal["terminal_status"] == TaskStatus.SUCCEEDED
assert len(runner._monitor_tasks) == 1
await runner.cancel_monitors()
assert len(runner._monitor_tasks) == 0
async def test_monitor_order_reports_state_changes_and_stops_at_delivered(
runner: WorkerRunner, evidence: EvidenceStore
):
"""状态没变化不重复 report;到「配達完了」立刻停止轮询,不再多探测一次"""
snapshots = [
OrderStatusSnapshot(found=False),
OrderStatusSnapshot(found=True, stage_label="ショップ", order_state=None, html="<html>shop</html>"),
OrderStatusSnapshot(
found=True, stage_label="出荷", order_state=OrderState.SHIPPED, html="<html>shipped</html>"
),
OrderStatusSnapshot(
found=True, stage_label="配達完了", order_state=OrderState.DELIVERED, html="<html>delivered</html>"
),
]
calls: list[OrderStatusSnapshot] = []
async def _check(order_id: str): # noqa: ANN001
calls.append(snapshots[len(calls)])
return calls[-1]
runner._site.check_order_status = _check # type: ignore[assignment]
await runner._monitor_order(_make_task(task_id="t1"), "306087-20260813-0863947697")
assert len(calls) == 4 # 配達完了那次之后立刻返回,不会再多轮询一次
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
reported_states = [r["state"] for r in gateway.reports]
assert reported_states == [OrderState.SHIPPED, OrderState.DELIVERED]
assert all(r["terminal"] is False for r in gateway.reports) # 追加上报,不重新终结任务
base = evidence.step_dir("t1")
assert (base / "06-monitor-shipped.html").exists()
assert (base / "07-monitor-delivered.html").exists()
async def test_monitor_order_stops_after_max_checks_without_finding_order(
runner: WorkerRunner,
):
"""一直查不到订单(found=False):轮询到上限后正常退出,不报错、不 report"""
async def _check(order_id: str): # noqa: ANN001
return OrderStatusSnapshot(found=False)
runner._site.check_order_status = _check # type: ignore[assignment]
await runner._monitor_order(_make_task(task_id="t1"), "ord-1")
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
assert gateway.reports == []
async def test_monitor_order_swallows_check_errors_and_keeps_polling(
runner: WorkerRunner,
):
"""单次探测异常(如登录态失效)只记日志继续重试,不会让后台任务崩掉"""
call_count = 0
async def _check(order_id: str): # noqa: ANN001
nonlocal call_count
call_count += 1
if call_count <= 2:
raise RuntimeError("模拟登录态失效")
return OrderStatusSnapshot(
found=True, stage_label="配達完了", order_state=OrderState.DELIVERED, html="<html>ok</html>"
)
runner._site.check_order_status = _check # type: ignore[assignment]
await runner._monitor_order(_make_task(task_id="t1"), "ord-1")
assert call_count == 3 # 前两次异常被吞掉继续重试,第三次成功拿到 DELIVERED 后停止
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
assert [r["state"] for r in gateway.reports] == [OrderState.DELIVERED]