Files
rakuten-api/tests/test_worker_runner.py
T
q792602257andClaude Opus 5 3c7618a1d6 feat(observability): 抓取去掉首页预热,交易补齐链路埋点
两个问题一起处理,都与「出站请求与可观测性」有关。

## 抓取:正常路径不再多打一次首页

site_session 原先每条通道每 30 分钟打一次 www.rakuten.co.jp/ 做预热,而且预热
返回非 2xx 时 warmed_at 不置位——那种情况下每个请求前都会再打一次首页。

Akamai 的 cookie 随任意页面响应下发,目标页自己就会带回来,专门先打一次首页除了
多一个出站请求(以及多一次被风控计数的机会)之外没有额外收益:首个请求无论打哪个
URL 都是冷的 ~11s,之后都复用 cookie。

改为 cookie 由目标页响应建立(_note_cookies)、超 TTL 主动清空
(_drop_expired_cookies)。首页只保留在失败修复路径上(_rewarm_on_home):目标页
已经吃了挑战页时,拿首页换一套干净 cookie 比继续撞同一个 URL 更安全。happy path
的出站请求数 2 → 1。

_note_cookies 刻意不在每次响应时刷新时刻:TTL 要从「这套 cookie 第一次出现」算起,
每次都刷新会让一套 cookie 被无限续命,反而绕过了 session_ttl_seconds 的本意。

profile_status() 的 warmed 字段名保留(上游健康检查看板在用),语义改为「当前有
可复用的 Akamai cookie」,不再代表「已专门预热过首页」。

## 交易:此前没有任何有意义的链路数据

根因是 trading 的实际工作两类自动埋点都覆盖不到:站点交互走 Playwright(不经
httpx),worker 主循环是后台 asyncio 任务(没有 HTTP 入口,因此没有根 span)。
于是发给网关的每次 httpx 调用各自成为孤立 trace——观测后台上只剩一堆请求记录。

新增手工埋点:

- order.task:一笔下单的根 span,一个 task_id 一条 trace,带 order.route
  (execute / recovery / already_finished)与终态 order.terminal_status
- order.step.*:清车 → 加购 → 校验 → 确认 → 提交 → 付款,每步一个子 span,
  带 order.evidence_ref,可从 span 直接定位落盘证据
- site.*:12 个 Playwright 交互方法(用 traced 装饰器而非 with 块——这些方法的
  函数体本就很长,再加一层缩进不利于阅读)
- account_query:只读查询单的根 span,带 query.outcome

空转的长轮询(30 秒一次、绝大多数返回空)用 suppressed() 屏蔽:量大且没有信息量,
把观测后台刷满的正是它们。领到任务后的网关调用都在任务根 span 底下,不受影响。

闸门 / 风控拦截会被 _execute_with_renewal 吞掉转 needs_human,异常冒不到根 span,
被拦下的单在 trace 里跟成功下单一模一样。加 _execute_recording_errors 一层统一
记录,比每个 except 分支各写一遍省事,也不会漏掉后续新增的分支。

_report_safe 写 span 属性前判断 is_recording():付款后监控是 create_task 起的,
asyncio 在创建时就把 context 复制了进去,等它真正跑起来根 span 早已结束——
get_current_span() 拿到的仍是那个已结束的 span(不是 INVALID_SPAN),写属性会打
"Setting attribute on ended span"。当前监控路径不传 terminal_status 走不到那里,
这道判断是防以后。

## 顺带修掉:instrument_app 从未生效

instrument_app 用 _provider is None 做前置判断,但三个服务都在模块导入时执行
app = create_app(),而 setup_telemetry 要等 lifespan 才跑——那时 _provider 还是
None,照着判断直接 return。**FastAPI 从来没被打桩过,三个服务一条 server span
都没有。**

实测确认两件事:导入期打桩能出 span,lifespan 内打桩出不来(instrument_app 是加
中间件,应用开始服务后加进去不生效);provider 后设也不影响 ProxyTracer 委托到
真实 provider。所以只能在导入期装,判断条件改为 otel_enabled。

app/gateway/main.py 此前完全没接 telemetry,worker 出站请求带过来的 traceparent
没人接上,一条下单链路在网关这里断掉,只看得到 worker 侧那半截。补上
setup_telemetry(service_name="rakuten-gateway") 与 instrument_app / shutdown。

## 验证

新增 8 个用例:首页零请求、cookie 复用与过期清空、失败后用首页换 cookie、一任务
一 trace 的父子结构、闸门失败标 ERROR、空转不埋点,以及 instrument_app 调用顺序
的回归测试。全量 526 passed。

Playwright 那些 site.* 埋点只做了静态验证(测试用桩替换站点方法),没有跑真实
浏览器下单确认 span 真的落地。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:56:49 +08:00

1055 lines
41 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
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import StatusCode
from app.shared.errors import BrowserDeadError, CheckoutBlockedError, OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import runner as runner_module
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,
PageSnapshot,
SiteInteractor,
SubmitOutcome,
)
# ---- 桩:网关客户端 ----
@dataclass
class FakeGateway:
"""记录所有调用,可控返回值的网关桩"""
reports: list[dict[str, Any]] = field(default_factory=list)
renews: int = 0
fail_report_with: Exception | None = None
# verify_on_site 恢复核对会调 get_task 拿 created_at;测试默认给一个可解析
# 的时间戳,具体核对分支由 monkeypatch 替换 verify.verify_on_site 控制
task_created_at: str = "2026-01-01T00:00:00Z"
async def lease(
self, worker_id: str, *, wait: int = 30, site: str | None = None
) -> LeaseTask | None:
return None # 主循环测试不通过 lease 喂数据,直接调 handle
async def get_task(self, task_id: str) -> dict[str, Any]:
return {"task_id": task_id, "created_at": self.task_created_at}
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,
)
def _set_site_clear(site, result: dict) -> None:
"""把 site.clear_cart 替换成返回指定结果的桩
runner.execute() 现在开单一律先 clear_cart(step 0,见 runner 里注释),
测试若不复用它,site 桩会真的去调未启动的 SiteInteractor 而崩。
"""
async def _f() -> dict:
return result
site.clear_cart = _f # type: ignore[assignment]
# ---- 已完成的任务:补报而不重新执行(§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(intent 缺 item_url)→ 转 needs_human,不执行"""
# verify.verify_on_site 已是真实实现(2026-08-13),但 _make_task 默认 intent 为空,
# 缺 item_url 时函数第一步就短路返回 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 "unknown" in terminal["detail"]
assert "item_url" 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, **kwargs): # 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
async def test_recovery_task_with_not_ordered_verdict_still_reports_needs_human(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
):
"""lease_count > 1 + verify 确认 not_ordered → 仍转 needs_human,不自动重新下单
刻意的保守选择(见 runner._handle_recovery 里的注释):NOT_ORDERED 分支目前
只有逻辑正确性,没有被真实多单数据验证过,放开自动重新执行前必须显式决定。
"""
async def _not_ordered(task, **kwargs): # noqa: ANN001
return verify.VerifyResult(
verdict=verify.VerifyVerdict.NOT_ORDERED,
detail="已核对任务创建时间之后的全部订单,未找到匹配商品",
)
monkeypatch.setattr(verify, "verify_on_site", _not_ordered)
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 "not_ordered" in terminal["detail"]
# needs_human 分支不标记本地完成(与 UNKNOWN 分支一致):只有 ALREADY_ORDERED
# 才 mark_finished,避免把「转人工」误当成本地已完成的终态
assert await local_db.has_finished("t1") is False
# ---- 站点交互未实现 → 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 PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
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(不是 failed)----
async def test_browser_dead_becomes_needs_human(runner: WorkerRunner):
"""执行途中浏览器掉线 → BrowserDeadError → 转 needs_human
两件事同时被这个用例守着:
1. BrowserDeadError 是 AppError 的子类,能被 _execute_with_renewal 接住并**上报**。
不是 AppError 的话它会一路飘到主循环的兜底日志里,任务一次都不上报,网关侧
要干等整个 lease_ttl 才被 sweep 置 stale。
2. 结论是 needs_human 而不是 failed——`except BrowserDeadError` 必须排在
`except AppError` 前面,顺序反了这个断言就会挂。浏览器是在动作中途没的,
站点侧生效与否无从判断,不能给上游一个「明确失败」的结论。
"""
async def _noop(task): # noqa: ANN001
return None
async def _browser_died(task): # noqa: ANN001
raise BrowserDeadError("pay:浏览器已掉线,本单是否已提交无法判断,需人工核对订单列表")
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _browser_died # type: ignore[assignment]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
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 PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
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 SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
# 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 PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
# 全局上限 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
# ---- 开单前清理购物车(step 0):上一单失败残留不被新单买走 ----
async def test_execute_clears_cart_before_adding(
runner: WorkerRunner, local_db: LocalDB
):
"""开单一律先 clear_cart,且必须先于 add_to_cart 发生
上一单若在下单前失败(加购后、提交前),残留不会自动消失;不清掉就直接
加购会把新商品叠在旧商品上,结算时把上一单的一起买走。这里断言顺序:
clear 在 add_to_cart 之前,且 clear 返回 count=0 才继续往下加购。
"""
call_order: list[str] = []
async def _clear(): # noqa: ANN001
call_order.append("clear")
return {"removed_count": 0, "cart_count": 0}
async def _noop(task): # noqa: ANN001
call_order.append("add")
return None
async def _verify(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
async def _pay(task, site_order_id): # noqa: ANN001
return None
async def _unchanged(order_id: str): # noqa: ANN001
# 不要让后台监控真的落到未启动的 SiteInteractor 上:found=True 但状态无
# 变化(order_state=None),监控循环会一直轮询到上限退出,不污染断言
return OrderStatusSnapshot(found=True, order_state=None)
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _verify # 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 = _unchanged # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert call_order == ["clear", "add"], f"clear 必须在 add 之前:{call_order}"
async def test_execute_blocks_when_cart_not_empty_after_clear(
runner: WorkerRunner, local_db: LocalDB
):
"""清理后 count 仍非 0 → 视为清理没跑干净,转 needs_human,绝不往下加购
残留可能属于上一单失败留下的商品,带着它加购/结算会把上一单的一起买走。
按闸门语义宁可卡住等人核对,也不赌「残留不会被买走」;关键断言是
add_to_cart/submit_order 都未被调用。
"""
any_add_or_submit = {"called": False}
async def _clear(): # noqa: ANN001
return {"removed_count": 2, "cart_count": 2} # 清理后仍剩 2 件
async def _add(task): # noqa: ANN001
any_add_or_submit["called"] = True
return None
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert any_add_or_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_execute_blocks_when_clear_count_unknown(
runner: WorkerRunner, local_db: LocalDB
):
"""清理后 count API 拿不到结果(cart_count=-1)→ 同样按未清空处理
无法确认购物车已空,就不能带着残留往下加购。clear_cart 在末尾 count API
失败时返回 cart_count=-1——此时不能默认它是 0(那等于把「确认已空」当成
「可能非空」),转 needs_human 让现场可查。
"""
add_called = False
async def _clear(): # noqa: ANN001
return {"removed_count": 0, "cart_count": -1}
async def _add(task): # noqa: ANN001
nonlocal add_called
add_called = True
return None
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert add_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_clear_cart_step_writes_evidence_but_no_gateway_report(
runner: WorkerRunner, evidence: EvidenceStore
):
"""step 0 落本地证据:清理后的页面 HTML 与 meta.json 在盘上,但不回报 gateway
clear_cart 返回的 html 是清理结束后的 cart 页现场,meta 记 removed_count /
cart_count;它仍是本机卫生步骤——evidence_index 可翻查,gateway 收不到
这一步的 evidence_ref。
"""
async def _clear(): # noqa: ANN001
return {
"removed_count": 1,
"cart_count": 0,
"html": "<html>cart</html>",
"screenshot": b"fake-clear-png",
}
async def _noop(task): # noqa: ANN001
return None
async def _unimplemented(task): # noqa: ANN001
# 流程走到 enter_checkout 为止:step 0 的证据此时已经落盘
raise NotImplementedError("模拟:enter_checkout 未实现,到此为止")
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
html_text = (step_dir / "00-cart-clear.html").read_text(encoding="utf-8")
assert html_text == "<html>cart</html>"
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-clear-png"
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta == {"step": "cart-clear", "removed_count": 1, "cart_count": 0}
# 本机卫生步骤:gateway 的任何 report 都不携带 step-0 的 evidence_ref
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
assert all(r["evidence_ref"] != "t1/00-cart-clear" for r in gateway.reports)
async def test_clear_cart_evidence_written_when_guard_blocks(
runner: WorkerRunner, evidence: EvidenceStore
):
"""闸门拦截(清理后 cart_count 非 0)时 step-0 证据同样已落盘
被拦转 needs_human 时这份现场就是排查依据——证据先于闸门判断落盘。
"""
async def _clear(): # noqa: ANN001
return {
"removed_count": 2,
"cart_count": 2,
"html": "<html>leftover</html>",
"screenshot": b"fake-leftover-png",
}
runner._site.clear_cart = _clear # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
assert (step_dir / "00-cart-clear.html").exists()
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-leftover-png"
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
assert meta["cart_count"] == 2
async def test_every_order_step_writes_html_png_and_meta(
runner: WorkerRunner, evidence: EvidenceStore
):
"""下单流程每一步(step 0 清车 + step 1-5)都落 html + png + meta 三件套
排查问题时每一步都要有现场可看:页面 HTML、整页截图、步骤 meta 缺一不可。
"""
async def _clear(): # noqa: ANN001
return {
"removed_count": 0,
"cart_count": 0,
"html": "<html>cart</html>",
"screenshot": b"png-0",
}
async def _add(task): # noqa: ANN001
return PageSnapshot(html="<html>added</html>", screenshot=b"png-1")
async def _verify(task): # noqa: ANN001
return PageSnapshot(html="<html>verified</html>", screenshot=b"png-2")
async def _checkout(task): # noqa: ANN001
return PageSnapshot(html="<html>checkout</html>", screenshot=b"png-3")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return SubmitOutcome(
site_order_id="ord-1",
evidence=PageSnapshot(html="<html>done</html>", screenshot=b"png-4"),
)
async def _pay(task, site_order_id): # noqa: ANN001
return PageSnapshot(html="<html>paid</html>", screenshot=b"png-5")
async def _unchanged(order_id: str): # noqa: ANN001
# 监控不污染断言:found=True 但无状态变化,轮询到上限自行退出
return OrderStatusSnapshot(found=True, order_state=None)
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
runner._site.verify_cart = _verify # type: ignore[assignment]
runner._site.enter_checkout = _checkout # 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 = _unchanged # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
step_dir = evidence.step_dir("t1")
expected = {
"00-cart-clear": b"png-0",
"01-cart-add": b"png-1",
"02-cart-check": b"png-2",
"03-order-confirm": b"png-3",
"04-order-submit": b"png-4",
"05-payment": b"png-5",
}
for stem, png in expected.items():
assert (step_dir / f"{stem}.png").read_bytes() == png, f"{stem} 缺整页截图"
assert (step_dir / f"{stem}.html").exists(), f"{stem} 缺页面 HTML"
assert (step_dir / f"{stem}.meta.json").exists(), f"{stem} 缺 meta"
# ---- 证据在 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 PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
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 PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return SubmitOutcome(
site_order_id="306087-20260813-0863947697",
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
)
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
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 == []
# ---- 链路追踪:一个任务一条 trace ----
@pytest.fixture
def spans(monkeypatch) -> InMemorySpanExporter:
"""把 runner 模块级 tracer 换成写内存的
不动全局 provider:OTel 的全局 provider 只允许设置一次,测试间共享会互相污染。
"""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
monkeypatch.setattr(runner_module, "tracer", provider.get_tracer("test"))
return exporter
def _stub_happy_path(runner: WorkerRunner) -> None:
"""把整条下单流程替换成桩,让 execute() 一路走到付款完成"""
async def _clear():
return {"removed_count": 0, "cart_count": 0, "html": "<html/>", "screenshot": b"p"}
async def _snapshot(*args, **kwargs):
return PageSnapshot(html="<html/>", screenshot=b"p")
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task):
return SubmitOutcome(
site_order_id="ord-1", evidence=PageSnapshot(html="<html/>", screenshot=b"p")
)
async def _unchanged(order_id: str):
return OrderStatusSnapshot(found=True, order_state=None)
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _snapshot # type: ignore[assignment]
runner._site.verify_cart = _snapshot # type: ignore[assignment]
runner._site.enter_checkout = _snapshot # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
runner._site.pay = _snapshot # type: ignore[assignment]
runner._site.check_order_status = _unchanged # type: ignore[assignment]
async def test_order_task_emits_one_trace_with_a_span_per_step(
runner: WorkerRunner, spans: InMemorySpanExporter
):
"""一笔下单 = 一条 trace,每个步骤一个子 span,全部挂在任务根 span 底下
worker 是后台 asyncio 任务,没有 HTTP 入口。不开这个根 span 的话,下游每次
出站调用都各自成为孤立 trace——观测后台上就只剩「一堆请求记录」,看不出
这是同一笔单、也看不出卡在哪一步。这条用例把链路结构钉住。
"""
_stub_happy_path(runner)
await runner.handle(_make_task(task_id="t1", intent={"item_url": "https://x/y/", "quantity": 2}))
finished = spans.get_finished_spans()
root = next(s for s in finished if s.name == "order.task")
steps = [s for s in finished if s.name.startswith("order.step.")]
# 步骤 span 覆盖整条流水,且顺序与执行顺序一致
assert [s.name for s in steps] == [
"order.step.cart-add",
"order.step.cart-check",
"order.step.order-confirm",
"order.step.order-submit",
"order.step.payment",
]
# 每个步骤都挂在任务根底下,同一条 trace
for step in steps:
assert step.parent.span_id == root.context.span_id
assert step.context.trace_id == root.context.trace_id
assert root.attributes["order.task_id"] == "t1"
assert root.attributes["order.route"] == "execute"
assert root.attributes["order.item_url"] == "https://x/y/"
# 证据路径落在 span 上:看到失败步骤能直接找到现场
assert steps[0].attributes["order.evidence_ref"]
assert steps[3].attributes["order.site_order_id"] == "ord-1"
async def test_guard_failure_marks_task_span_as_error(
runner: WorkerRunner, spans: InMemorySpanExporter
):
"""金额守卫拦下时任务根 span 要是 ERROR,并带上对外错误码
闸门拦截会被 _execute_with_renewal 吞掉转 needs_human(不抛出 handle),
所以根 span 很容易显示成功——这里确认它没有。
"""
_stub_happy_path(runner)
async def _pricey(html: str):
return CheckoutSummary(payable_yen=999_999)
runner._site.parse_checkout = _pricey # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
root = next(s for s in spans.get_finished_spans() if s.name == "order.task")
assert root.status.status_code is StatusCode.ERROR
assert root.attributes["error.type"] == "OrderGuardError"
# 提交步骤没有发生:拦在确认页之后、提交之前
names = [s.name for s in spans.get_finished_spans()]
assert "order.step.order-submit" not in names
async def test_idle_lease_does_not_emit_spans(runner: WorkerRunner, spans: InMemorySpanExporter):
"""空转的长轮询不产生 span——否则每 30 秒一条孤立 trace 会把观测后台刷满"""
runner._running = True
async def _lease_then_stop(worker_id: str, *, wait: int = 30, site: str | None = None):
runner.stop() # 领到空就停,避免死循环
return None
runner._gateway.lease = _lease_then_stop # type: ignore[assignment]
await runner.run()
assert spans.get_finished_spans() == ()
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]