实现 site_interact 加购与购物车校验(Playwright 通道)

探针实测发现 httpx 因 TLS/HTTP2 指纹被 Rakuten 拒认(cookie 有效但
站点不识别 session),site_interact 改为全程 Playwright + storage_state。
add_to_cart 与 verify_cart 已实测可用:从商品页 __INITIAL_STATE__ 抽
purchase 块、调用方补 variant_id 与 choice、POST basketDomain、用 cart
count JSONP API 与 SPA 渲染后的 cart 页校验。enter_checkout 及之后仍
NotImplementedError:Rakuten 对 checkout 要求 session upgrade(重输密码),
是自动 checkout 的硬墙,未实测过墙方案前留接口缝。

SiteInteractor 类持有 Playwright BrowserContext,由 WorkerRunner 实例
持有、container/main 接线、lifespan 管理生命周期。测试侧 23 个用例,
全套件 333 passed,架构边界守住。

附 5 个探针脚本(scripts/probe_*.py),记录实测路径与响应结构。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:59:30 +08:00
co-authored by Claude Opus 4.6
parent 07107094a7
commit 18cf7ae079
13 changed files with 1929 additions and 74 deletions
+50 -26
View File
@@ -1,11 +1,11 @@
"""worker 主循环测试
不启动真实的后台任务,直接构造 WorkerRunner 与桩 gateway client / local_db /
evidence store,覆盖 §6 主循环的分支:
evidence store / site,覆盖 §6 主循环的分支:
- 已完成的任务再次被领:直接补报,不重新执行
- lease_count > 1(恢复领取):走核对分支,verify unknown → needs_human
- 站点交互未实现(site_interact 抛 NotImplementedError):转 needs_human
- 站点交互未实现(SiteInteractor 默认抛 NotImplementedError):转 needs_human
- 金额守卫拦截:转 needs_human,站点侧无提交动作
每一步的证据文件在 report 之前就已落盘(§9 第 11 条)也在这里验证。
@@ -20,11 +20,12 @@ import pytest
from app.shared.errors import OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import site_interact, verify
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, SiteInteractor
# ---- 桩:网关客户端 ----
@@ -102,7 +103,18 @@ def evidence(tmp_path: Path) -> EvidenceStore:
@pytest.fixture
def runner(tmp_path, local_db, evidence, monkeypatch) -> WorkerRunner:
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。
@@ -122,6 +134,7 @@ def runner(tmp_path, local_db, evidence, monkeypatch) -> WorkerRunner:
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]
@@ -162,7 +175,7 @@ async def test_already_finished_task_is_reported_not_re_executed(
terminal = gateway.last_terminal_report()
assert terminal["state"] == OrderState.PAID
assert terminal["terminal"] is True
# 没有进入 execute:site_interact 不应被调用,但为保险起见,我们只看
# 没有进入 execute:site 不应被调用,但为保险起见,我们只看
# evidence 目录里没有任何步骤文件
evidence_files = list(local_db._db_path.parent.parent.glob("**/*.html")) # noqa
# 上面 glob 会扫到 trading.db 之外的所有 .html;这里只关心 evidence 目录
@@ -218,7 +231,19 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
async def test_unimplemented_site_interaction_becomes_needs_human(
runner: WorkerRunner, local_db: LocalDB
):
"""site_interact.add_to_cart 抛 NotImplementedError → runner 转 needs_human"""
"""SiteInteractor 默认 enter_checkout 抛 NotImplementedError → runner 转 needs_human
add_to_cart 与 verify_cart 已实现(但站点未启动时会从 Playwright 调用失败,
测试里先替换为 noop 让流程跑到 enter_checkout)。
"""
# 让已实现的两步 noop,触发未实现的 enter_checkout
async def _noop(task): # noqa: ANN001
return None
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
@@ -243,7 +268,7 @@ async def test_rakuma_site_reports_needs_human(runner: WorkerRunner):
async def test_amount_guard_blocks_when_payable_exceeds_limit(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
runner: WorkerRunner, local_db: LocalDB
):
"""实际应付超过上限 → OrderGuardError → 转 needs_human,站点侧无提交动作
@@ -255,14 +280,11 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
async def _ok(task): # noqa: ANN001
call_log.append(task.task_id + ":step")
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 site_interact.CheckoutSummary(payable_yen=50000)
return CheckoutSummary(payable_yen=50000)
submit_called = False
@@ -271,11 +293,11 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
submit_called = True
return "ord-1"
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
monkeypatch.setattr(site_interact, "verify_cart", _noop)
monkeypatch.setattr(site_interact, "enter_checkout", _checkout_html)
monkeypatch.setattr(site_interact, "parse_checkout", _parse)
monkeypatch.setattr(site_interact, "submit_order", _submit)
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 → 拦截
@@ -290,7 +312,7 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
async def test_amount_guard_honors_intent_max_total_yen(
runner: WorkerRunner, local_db: LocalDB, monkeypatch
runner: WorkerRunner, local_db: LocalDB
):
"""intent 显式给 max_total_yen 时优先于全局上限"""
@@ -301,7 +323,7 @@ async def test_amount_guard_honors_intent_max_total_yen(
return "<html>checkout</html>"
async def _parse(html: str):
return site_interact.CheckoutSummary(payable_yen=8000)
return CheckoutSummary(payable_yen=8000)
submit_called = False
@@ -310,11 +332,11 @@ async def test_amount_guard_honors_intent_max_total_yen(
submit_called = True
raise NotImplementedError("不应到这里") # parse 之后的步骤未实现,到这里即说明守卫过了
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
monkeypatch.setattr(site_interact, "verify_cart", _noop)
monkeypatch.setattr(site_interact, "enter_checkout", _checkout_html)
monkeypatch.setattr(site_interact, "parse_checkout", _parse)
monkeypatch.setattr(site_interact, "submit_order", _submit)
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}))
@@ -329,7 +351,7 @@ async def test_amount_guard_honors_intent_max_total_yen(
async def test_evidence_files_exist_before_each_report(
runner: WorkerRunner, evidence: EvidenceStore, monkeypatch
runner: WorkerRunner, evidence: EvidenceStore
):
"""每个 _run_step 调用:动作 → 落证据 → 写本地 → 回报 gateway
@@ -350,11 +372,13 @@ async def test_evidence_files_exist_before_each_report(
runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined]
# 让 add_to_cart 正常,verify_cart 抛 NotImplemented,触发第一个 step 写证据 + report
# 让 add_to_cart / verify_cart 正常,enter_checkout 抛 NotImplemented,
# 触发前两步写证据 + report,到第三步转 needs_human
async def _noop(task): # noqa: ANN001
return None
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))