实现下单任务网关与本地 worker
按 docs/order-gateway.md 落地:第三个部署单元 app.gateway(:31109)承担任务队列 + 状态镜像;本地 worker 在 app.trading.worker 内,按 RAKUTEN_ORDER_GATEWAY_URL 决定是否启动。规格 §5 最关键约束已守:租约过期绝不自动重投,恢复只能 reclaim, worker 收到 lease_count>1 时先核对站点订单。 站点交互(加购/下单/付款/订单列表反查)按规格 §10 留接口缝,site_interact.py 全部 NotImplementedError,verify.py 恒返回 unknown——等真实账号实测后再填, 不写猜测的提交逻辑。 310 个测试全绿,覆盖规格 §9 验收清单 12 条;架构测试守住三方互不 import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
"""worker 主循环测试
|
||||
|
||||
不启动真实的后台任务,直接构造 WorkerRunner 与桩 gateway client / local_db /
|
||||
evidence store,覆盖 §6 主循环的分支:
|
||||
|
||||
- 已完成的任务再次被领:直接补报,不重新执行
|
||||
- lease_count > 1(恢复领取):走核对分支,verify unknown → needs_human
|
||||
- 站点交互未实现(site_interact 抛 NotImplementedError):转 needs_human
|
||||
- 金额守卫拦截:转 needs_human,站点侧无提交动作
|
||||
|
||||
每一步的证据文件在 report 之前就已落盘(§9 第 11 条)也在这里验证。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
# ---- 桩:网关客户端 ----
|
||||
|
||||
|
||||
@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 runner(tmp_path, local_db, evidence, monkeypatch) -> WorkerRunner:
|
||||
"""构造一个 worker_id=test、网关 URL 已配置的 runner
|
||||
|
||||
使用 fake gateway;settings 取真实 Settings 但 worker_id 注入为 test。
|
||||
"""
|
||||
|
||||
class _FakeSettings:
|
||||
worker_id = "test"
|
||||
order_max_total_yen = 30000
|
||||
|
||||
@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,
|
||||
)
|
||||
# 把 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_interact 不应被调用,但为保险起见,我们只看
|
||||
# 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
|
||||
):
|
||||
"""site_interact.add_to_cart 抛 NotImplementedError → runner 转 needs_human"""
|
||||
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, monkeypatch
|
||||
):
|
||||
"""实际应付超过上限 → 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 _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)
|
||||
|
||||
submit_called = False
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
nonlocal submit_called
|
||||
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)
|
||||
|
||||
# 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, monkeypatch
|
||||
):
|
||||
"""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 site_interact.CheckoutSummary(payable_yen=8000)
|
||||
|
||||
submit_called = False
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
nonlocal submit_called
|
||||
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)
|
||||
|
||||
# 全局上限 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, monkeypatch
|
||||
):
|
||||
"""每个 _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 抛 NotImplemented,触发第一个 step 写证据 + report
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
|
||||
|
||||
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 之前证据文件未落盘"
|
||||
Reference in New Issue
Block a user