实现下单任务网关与本地 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:
+35
-13
@@ -1,12 +1,11 @@
|
||||
"""架构测试:守住抓取侧与交易侧的依赖方向
|
||||
"""架构测试:守住三个部署单元的依赖方向
|
||||
|
||||
拆成两个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧
|
||||
`from app.scraping.parsers...` 一句,两个服务就重新长回一起:抓取的解析改动会
|
||||
牵动下单链路,交易服务也被迫加载整套抓取依赖(包括 Playwright)。
|
||||
三个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧
|
||||
`from app.scraping.parsers...` 一句,两个服务就重新长回一起;或在 worker 里
|
||||
`from app.gateway.models...` 一句,本地的零依赖网关假设就破了。
|
||||
|
||||
允许的方向只有两条:scraping → shared、trading → shared。
|
||||
交易侧要用抓取的能力,走抓取服务的 HTTP 接口(`purchase` 块本来就是它的对外契约),
|
||||
不直接 import。
|
||||
允许的方向只有三条:scraping → shared、trading → shared、gateway → shared。
|
||||
任何两侧之间互不 import;需要对方能力时走 HTTP 接口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -37,11 +36,17 @@ def _python_files(package: str) -> list[Path]:
|
||||
@pytest.mark.parametrize(
|
||||
("package", "forbidden"),
|
||||
[
|
||||
# 三个部署单元互不 import
|
||||
("scraping", "app.trading"),
|
||||
("scraping", "app.gateway"),
|
||||
("trading", "app.scraping"),
|
||||
# shared 是两侧的共同底座,反向依赖任何一侧都会形成环
|
||||
("trading", "app.gateway"),
|
||||
("gateway", "app.scraping"),
|
||||
("gateway", "app.trading"),
|
||||
# shared 是三方的共同底座,反向依赖任何一侧都会形成环
|
||||
("shared", "app.scraping"),
|
||||
("shared", "app.trading"),
|
||||
("shared", "app.gateway"),
|
||||
],
|
||||
)
|
||||
def test_package_does_not_import(package: str, forbidden: str):
|
||||
@@ -54,8 +59,9 @@ def test_package_does_not_import(package: str, forbidden: str):
|
||||
assert not offenders, f"{package} 不应依赖 {forbidden}:{offenders}"
|
||||
|
||||
|
||||
def test_both_entrypoints_build():
|
||||
"""两个入口都要能独立创建应用——这是「两个部署单元」的最低验收"""
|
||||
def test_all_three_entrypoints_build():
|
||||
"""三个入口都要能独立创建应用——这是「三个部署单元」的最低验收"""
|
||||
from app.gateway.main import create_app as create_gateway_app
|
||||
from app.scraping.main import create_app as create_scraping_app
|
||||
from app.trading.main import create_app as create_trading_app
|
||||
|
||||
@@ -63,11 +69,27 @@ def test_both_entrypoints_build():
|
||||
# 包成 _IncludedRouter 而非摊平,OpenAPI 反映的才是真正对外暴露的契约
|
||||
scraping_paths = set(create_scraping_app().openapi()["paths"])
|
||||
trading_paths = set(create_trading_app().openapi()["paths"])
|
||||
gateway_paths = set(create_gateway_app().openapi()["paths"])
|
||||
|
||||
# 各自的标志性接口都在
|
||||
assert "/api/search" in scraping_paths
|
||||
assert "/api/auth/status" in trading_paths
|
||||
# 登录态接口不该出现在抓取服务上:抓取实例可以多开,多份登录态就是重复下单的温床
|
||||
assert "/api/orders" in gateway_paths
|
||||
assert "/api/orders/lease" in gateway_paths
|
||||
|
||||
# 登录态接口不该出现在抓取/网关服务上:抓取实例可以多开、网关在服务器上暴露,
|
||||
# 多份登录态就是重复下单的温床
|
||||
assert not any(path.startswith("/api/auth") for path in scraping_paths)
|
||||
assert not any(path.startswith("/api/auth") for path in gateway_paths)
|
||||
# 抓取的站点接口不应出现在交易/网关上
|
||||
assert not any(path.startswith("/api/rakuma") for path in trading_paths)
|
||||
# /health 两边都有,各报各的
|
||||
assert "/health" in scraping_paths and "/health" in trading_paths
|
||||
assert not any(path.startswith("/api/rakuma") for path in gateway_paths)
|
||||
assert not any(path.startswith("/api/search") for path in trading_paths)
|
||||
assert not any(path.startswith("/api/search") for path in gateway_paths)
|
||||
# 下单任务接口只属于网关:trading 是本地 worker,不接收外部下单意图
|
||||
assert not any(path.startswith("/api/orders") for path in scraping_paths)
|
||||
assert not any(path.startswith("/api/orders") for path in trading_paths)
|
||||
# /health 三个服务都有,各报各的
|
||||
assert "/health" in scraping_paths
|
||||
assert "/health" in trading_paths
|
||||
assert "/health" in gateway_paths
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""网关 HTTP API 测试
|
||||
|
||||
覆盖规格 §8 的错误码与 §9 验收清单里 HTTP 层能验证的条目:鉴权、响应信封、
|
||||
6xxx 错误映射、(task_id, state) 重复 report 不产生第二条、reclaim 错误条件。
|
||||
|
||||
长轮询与并发抢任务的时序在 test_gateway_leasing.py 单独覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
"""起一个独立 DB 的网关应用
|
||||
|
||||
- monkeypatch 设置 RAKUTEN_GATEWAY_DB_PATH
|
||||
- get_settings.cache_clear() 让新 env 生效
|
||||
- TestClient 进入时触发 lifespan(建表、起 sweep 后台任务)
|
||||
"""
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---- 鉴权 ----
|
||||
|
||||
|
||||
def test_health_needs_no_token(gateway_client):
|
||||
response = gateway_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["status"] in ("ok", "degraded")
|
||||
assert body["data"]["queued_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,method",
|
||||
[
|
||||
("/api/orders", "POST"),
|
||||
("/api/orders/lease", "GET"),
|
||||
("/api/orders/t1", "GET"),
|
||||
],
|
||||
)
|
||||
def test_endpoints_reject_missing_token(gateway_client, path, method):
|
||||
response = gateway_client.request(method, path)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == 1001
|
||||
|
||||
|
||||
def test_endpoints_reject_wrong_token(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"site": "rakuten", "intent": {}},
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---- 提交与幂等 ----
|
||||
|
||||
|
||||
def test_submit_returns_task_id_and_status(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {"k": "v"}},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["task_id"] == "t1"
|
||||
assert body["data"]["status"] == TaskStatus.QUEUED.value
|
||||
assert body["data"]["created"] is True
|
||||
|
||||
|
||||
def test_submit_with_same_task_id_is_idempotent(gateway_client):
|
||||
payload = {"task_id": "t1", "site": "rakuten", "intent": {}}
|
||||
r1 = gateway_client.post("/api/orders", json=payload, headers=AUTH).json()
|
||||
r2 = gateway_client.post("/api/orders", json=payload, headers=AUTH).json()
|
||||
assert r1["data"]["created"] is True
|
||||
assert r2["data"]["created"] is False
|
||||
assert r1["data"]["task_id"] == r2["data"]["task_id"]
|
||||
|
||||
|
||||
# ---- 错误码 6xxx 映射 ----
|
||||
|
||||
|
||||
def test_get_unknown_task_returns_6001(gateway_client):
|
||||
response = gateway_client.get("/api/orders/no-such-task", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert body["code"] == 6001
|
||||
|
||||
|
||||
def test_report_wrong_worker_returns_6002(gateway_client):
|
||||
# 提交并 lease 给 w1
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
# w2 试图 report
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w2", "state": OrderState.IN_CART.value},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6002
|
||||
|
||||
|
||||
def test_reclaim_non_stale_returns_6003(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/reclaim",
|
||||
json={"worker_id": "w1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6003
|
||||
|
||||
|
||||
def test_renew_unknown_task_returns_6001(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders/no-such/renew",
|
||||
json={"worker_id": "w1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == 6001
|
||||
|
||||
|
||||
# ---- report 幂等:同一 (task_id, state) 不产生第二条 ----
|
||||
|
||||
|
||||
def test_report_same_state_does_not_duplicate(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
report_payload = {
|
||||
"worker_id": "w1",
|
||||
"state": OrderState.IN_CART.value,
|
||||
"payable_yen": 9800,
|
||||
"evidence_ref": "t1/01-cart-add",
|
||||
"detail": "已加购",
|
||||
}
|
||||
r1 = gateway_client.post("/api/orders/t1/report", json=report_payload, headers=AUTH).json()
|
||||
r2 = gateway_client.post("/api/orders/t1/report", json=report_payload, headers=AUTH).json()
|
||||
|
||||
assert r1["data"]["recorded"] is True
|
||||
assert r2["data"]["recorded"] is False
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert len(detail["reports"]) == 1
|
||||
|
||||
|
||||
def test_report_terminal_releases_lease(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={
|
||||
"worker_id": "w1",
|
||||
"state": OrderState.PAID.value,
|
||||
"payable_yen": 9800,
|
||||
"site_order_id": "ord-1",
|
||||
"terminal": True,
|
||||
"terminal_status": TaskStatus.SUCCEEDED.value,
|
||||
"detail": "付款完成",
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.SUCCEEDED.value
|
||||
assert detail["lease_owner"] is None
|
||||
|
||||
|
||||
# ---- 列表 ----
|
||||
|
||||
|
||||
def test_list_orders_filters_by_status(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t2", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
response = gateway_client.get("/api/orders?status=queued", headers=AUTH).json()
|
||||
assert response["data"]["total"] == 2
|
||||
assert {item["task_id"] for item in response["data"]["items"]} == {"t1", "t2"}
|
||||
|
||||
filtered = gateway_client.get(
|
||||
"/api/orders?status=leased", headers=AUTH
|
||||
).json()
|
||||
assert filtered["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_list_orders_filters_by_site(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t2", "site": "rakuma", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
response = gateway_client.get("/api/orders?site=rakuma", headers=AUTH).json()
|
||||
assert response["data"]["total"] == 1
|
||||
assert response["data"]["items"][0]["site"] == "rakuma"
|
||||
|
||||
|
||||
def test_get_task_detail_includes_full_report_history(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w1", "state": OrderState.IN_CART.value, "detail": "step1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w1", "state": OrderState.ORDERED.value, "detail": "step2"},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert [r["state"] for r in detail["reports"]] == [
|
||||
OrderState.IN_CART.value,
|
||||
OrderState.ORDERED.value,
|
||||
]
|
||||
assert detail["latest_state"] == OrderState.ORDERED.value
|
||||
|
||||
|
||||
# ---- lease 立即返回空(无任务时)----
|
||||
|
||||
|
||||
def test_lease_returns_null_when_queue_empty(gateway_client):
|
||||
"""wait=0 + 无任务时立即返回 data: null"""
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"] is None
|
||||
|
||||
|
||||
def test_lease_returns_null_when_active_task_blocks(gateway_client):
|
||||
"""有 leased 任务时,lease 立即返回 data: null(并发度 1)"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
@@ -0,0 +1,368 @@
|
||||
"""网关 DB 与任务队列层测试
|
||||
|
||||
直接构造 GatewayDB + TaskQueue,绕开 HTTP 层,覆盖规格 §9 验收清单里能在队列层
|
||||
独立验证的条目:幂等提交、全局并发度 1、租约过期变 stale(且不重投)、
|
||||
reclaim 恢复、(task_id, state) 幂等 report。
|
||||
|
||||
长轮询与并发 worker 抢任务的时序在 test_gateway_leasing.py 单独覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.db import GatewayDB
|
||||
from app.gateway.task_queue import TaskQueue
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def queue(tmp_path: Path) -> TaskQueue:
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
q = TaskQueue(db, lease_ttl_seconds=300, worker_offline_alert_seconds=300)
|
||||
yield q
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 幂等提交(规格 §9 第 1 条)----
|
||||
|
||||
|
||||
async def test_submit_with_same_task_id_is_idempotent(queue: TaskQueue):
|
||||
"""同 task_id 提交两次只产生一个任务,第二次 created=False"""
|
||||
r1 = await queue.submit(task_id="t1", site="rakuten", intent={"k": "v"})
|
||||
r2 = await queue.submit(task_id="t1", site="rakuten", intent={"k": "v"})
|
||||
|
||||
assert r1.task_id == "t1"
|
||||
assert r1.created is True
|
||||
assert r2.task_id == "t1"
|
||||
assert r2.created is False
|
||||
assert r1.status == r2.status == TaskStatus.QUEUED
|
||||
|
||||
|
||||
async def test_submit_generates_task_id_when_missing(queue: TaskQueue):
|
||||
r = await queue.submit(task_id=None, site="rakuten", intent={})
|
||||
assert r.task_id.startswith("po-")
|
||||
assert r.created is True
|
||||
|
||||
|
||||
# ---- 全局并发度 1(规格 §9 第 2、3 条)----
|
||||
|
||||
|
||||
async def test_lease_returns_none_when_active_task_exists(queue: TaskQueue):
|
||||
"""已有 leased/running 时,lease 立即返回 None"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
first = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first is not None and first.task_id == "t1"
|
||||
|
||||
second = await queue.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert second is None
|
||||
|
||||
|
||||
async def test_lease_picks_queued_in_fifo_order(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.submit(task_id="t2", site="rakuten", intent={})
|
||||
|
||||
first = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first.task_id == "t1"
|
||||
|
||||
# 终结第一个后才能领第二个
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.CREATED,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
|
||||
second = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert second is not None and second.task_id == "t2"
|
||||
|
||||
|
||||
# ---- 站点过滤 ----
|
||||
|
||||
|
||||
async def test_lease_with_site_filter(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.submit(task_id="t2", site="rakuma", intent={})
|
||||
|
||||
leased = await queue.lease(worker_id="w1", wait=0, site="rakuma", max_wait=60)
|
||||
assert leased is not None and leased.task_id == "t2" and leased.site == "rakuma"
|
||||
|
||||
|
||||
# ---- 续租 ----
|
||||
|
||||
|
||||
async def test_renew_requires_lease_owner(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
from app.shared.errors import LeaseInvalidError
|
||||
|
||||
with pytest.raises(LeaseInvalidError):
|
||||
await queue.renew("t1", "w2")
|
||||
|
||||
|
||||
async def test_renew_extends_lease(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
renewed = await queue.renew("t1", "w1")
|
||||
assert renewed.task_id == "t1"
|
||||
assert renewed.lease_count == 1
|
||||
|
||||
|
||||
# ---- report 幂等(规格 §9 第 5 条)----
|
||||
|
||||
|
||||
async def test_report_same_state_is_idempotent(queue: TaskQueue):
|
||||
"""同一 (task_id, state) 重复上报不产生第二条记录"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
kwargs = dict(
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=100,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref="t1/01-cart-add",
|
||||
detail="已加购",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
r1 = await queue.report("t1", **kwargs)
|
||||
r2 = await queue.report("t1", **kwargs)
|
||||
|
||||
assert r1.recorded is True # 新写入
|
||||
assert r2.recorded is False # 幂等覆盖
|
||||
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert len(detail.reports) == 1
|
||||
|
||||
|
||||
async def test_first_report_moves_task_to_running(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.RUNNING
|
||||
|
||||
|
||||
async def test_terminal_report_releases_lease(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.PAID,
|
||||
payable_yen=9800,
|
||||
pay_deadline=None,
|
||||
site_order_id="ord-1",
|
||||
evidence_ref="t1/05-payment",
|
||||
detail="付款完成",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.SUCCEEDED
|
||||
assert detail.lease_owner is None
|
||||
assert detail.lease_expires_at is None
|
||||
assert detail.latest_state == OrderState.PAID
|
||||
|
||||
|
||||
async def test_terminal_without_explicit_status_infers_from_state(queue: TaskQueue):
|
||||
"""terminal=true 但不传 terminal_status 时,按 state 推断:paid → succeeded"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.PAID,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=True,
|
||||
terminal_status=None,
|
||||
)
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.SUCCEEDED
|
||||
|
||||
|
||||
async def test_terminal_with_awaiting_payment_state_becomes_needs_human(queue: TaskQueue):
|
||||
"""3DS 等人工环节 → worker 报 awaiting_payment + terminal → 任务转 needs_human"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.AWAITING_PAYMENT,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="检测到 3DS 验证",
|
||||
terminal=True,
|
||||
terminal_status=None,
|
||||
)
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.NEEDS_HUMAN
|
||||
|
||||
|
||||
# ---- 租约过期与绝不自动重投(规格 §5、§9 第 6 条)----
|
||||
|
||||
|
||||
async def test_expired_lease_becomes_stale_and_is_not_re_leasable(tmp_path: Path):
|
||||
"""租约过期 → stale → 普通 lease 取不到(绝不自动重投)"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="t1", site="rakuten", intent={})
|
||||
leased = await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert leased is not None
|
||||
|
||||
# TTL=0:立即过期。下一次 lease 内部会先扫到 stale
|
||||
empty = await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert empty is None
|
||||
|
||||
detail = await q.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.STALE
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_sweep_returns_count_of_expired_tasks(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
# 直接造一个过期任务:lease TTL 设为 0
|
||||
queue._lease_ttl = 0 # type: ignore[attr-defined]
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
swept = await queue.sweep()
|
||||
assert swept == 1
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.STALE
|
||||
|
||||
|
||||
# ---- reclaim(规格 §5、§9 第 7 条)----
|
||||
|
||||
|
||||
async def test_reclaim_leases_stale_task_with_count_increment(tmp_path: Path):
|
||||
"""reclaim 领回 stale 任务,lease_count > 1,带 known_state"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="t1", site="rakuten", intent={})
|
||||
|
||||
first = await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first is not None and first.lease_count == 1
|
||||
|
||||
# 在过期之前先 report 一次,让 known_state 有值
|
||||
await q.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
# 走一次 lease 触发 sweep,把过期任务置 stale
|
||||
await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert (await q.get_task_detail("t1")).status == TaskStatus.STALE
|
||||
|
||||
reclaimed = await q.reclaim("t1", "w2")
|
||||
assert reclaimed.task_id == "t1"
|
||||
assert reclaimed.lease_count == 2 # > 1:worker 必须先核对
|
||||
assert reclaimed.known_state == OrderState.IN_CART
|
||||
|
||||
# reclaim 后任务状态变 leased(被新 worker 持有)
|
||||
detail = await q.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.LEASED
|
||||
assert detail.lease_owner == "w2"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_reclaim_rejects_non_stale_task(queue: TaskQueue):
|
||||
"""只能 reclaim stale 任务;其他状态报 6003"""
|
||||
from app.shared.errors import InvalidTaskStateError
|
||||
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
|
||||
with pytest.raises(InvalidTaskStateError):
|
||||
await queue.reclaim("t1", "w1")
|
||||
|
||||
|
||||
async def test_normal_lease_does_not_pick_stale(tmp_path: Path):
|
||||
"""普通 lease 不会取到 stale 任务(绝不自动重投)"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="stale-task", site="rakuten", intent={})
|
||||
await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
# 触发 sweep
|
||||
await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert (await q.get_task_detail("stale-task")).status == TaskStatus.STALE
|
||||
|
||||
# 再入队一个新任务
|
||||
await q.submit(task_id="fresh", site="rakuten", intent={})
|
||||
picked = await q.lease(worker_id="w3", wait=0, site=None, max_wait=60)
|
||||
assert picked is not None and picked.task_id == "fresh"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 错误映射 ----
|
||||
|
||||
|
||||
async def test_report_wrong_worker_raises(queue: TaskQueue):
|
||||
from app.shared.errors import LeaseInvalidError
|
||||
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
with pytest.raises(LeaseInvalidError):
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w2",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
|
||||
|
||||
async def test_get_unknown_task_raises(queue: TaskQueue):
|
||||
from app.shared.errors import TaskNotFoundError
|
||||
|
||||
with pytest.raises(TaskNotFoundError):
|
||||
await queue.get_task_detail("does-not-exist")
|
||||
@@ -0,0 +1,179 @@
|
||||
"""长轮询与并发抢任务测试
|
||||
|
||||
覆盖规格 §9 验收清单里需要时序观察的条目:
|
||||
- 两个 worker 同时 lease,只有一个拿到任务(全局并发度 1)
|
||||
- 有任务在 leased/running 时,lease 一律返回空
|
||||
- 无任务时 lease 挂起到 wait 秒才返回,且返回 200 + data: null
|
||||
- submit 期间正在 wait 的 lease 被唤醒
|
||||
- worker 心跳:lease 请求刷新 last_seen_at,/health 据此判断失联
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
# worker_offline_alert_seconds 设小一点,方便测 /health 失联判定
|
||||
monkeypatch.setenv("RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS", "1")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
# 让后台 sweep 任务退出后再清理
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---- 全局并发度 1 ----
|
||||
|
||||
|
||||
def test_concurrent_leases_only_one_gets_task(gateway_client):
|
||||
"""两个 worker 同时 lease,只有一个拿到任务"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
# 同步发两个 lease,wait=0 立即返回
|
||||
r1 = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
r2 = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
owned = [r for r in (r1, r2) if r.json()["data"] is not None]
|
||||
assert len(owned) == 1
|
||||
assert owned[0].json()["data"]["task_id"] == "t1"
|
||||
|
||||
|
||||
def test_active_task_blocks_subsequent_leases(gateway_client):
|
||||
"""leased 或 running 状态下,lease 立即返回空"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "active", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "queued", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
# 领走 active
|
||||
first = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
assert first["data"]["task_id"] == "active"
|
||||
|
||||
# 队列里还有 queued,但 active 还在执行 → 立即返回空
|
||||
blocked = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH).json()
|
||||
assert blocked["data"] is None
|
||||
|
||||
|
||||
# ---- 长轮询 ----
|
||||
|
||||
|
||||
def test_lease_hangs_then_returns_null_when_no_task(gateway_client):
|
||||
"""无任务时 lease 挂起到 wait 秒才返回"""
|
||||
started = time.monotonic()
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=2", headers=AUTH)
|
||||
elapsed = time.monotonic() - started
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
# 至少挂了 2 秒(允许少量提前)
|
||||
assert elapsed >= 1.8
|
||||
|
||||
|
||||
def test_lease_is_woken_up_when_task_submitted(gateway_client):
|
||||
"""长轮询期间 submit 任务,lease 应被唤醒并立刻拿到"""
|
||||
async def submit_after_delay():
|
||||
await asyncio.sleep(0.3)
|
||||
# TestClient 是同步的,在另一个线程里发请求
|
||||
import threading
|
||||
|
||||
def _submit():
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
threading.Thread(target=_submit, daemon=True).start()
|
||||
|
||||
asyncio.run(submit_after_delay())
|
||||
|
||||
started = time.monotonic()
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=10", headers=AUTH)
|
||||
elapsed = time.monotonic() - started
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data is not None and data["task_id"] == "t1"
|
||||
# 被唤醒应远早于 10 秒
|
||||
assert elapsed < 5
|
||||
|
||||
|
||||
# ---- 心跳与健康检查 ----
|
||||
|
||||
|
||||
def test_lease_updates_worker_last_seen(gateway_client):
|
||||
"""lease 请求兼作心跳,刷新 workers 表的 last_seen_at"""
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
worker_ids = {w["worker_id"] for w in health["workers"]}
|
||||
assert "w1" in worker_ids
|
||||
|
||||
|
||||
def test_health_reports_offline_worker(gateway_client):
|
||||
"""worker 心跳超阈值时 /health 报异常"""
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
# 阈值被 fixture 设为 1 秒;时间戳为秒精度,需要 sleep 到 2 秒才能稳定越过阈值
|
||||
import time as _time
|
||||
|
||||
_time.sleep(2.0)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
assert health["status"] == "degraded"
|
||||
offline_ids = {w["worker_id"] for w in health["offline_workers"]}
|
||||
assert "w1" in offline_ids
|
||||
|
||||
|
||||
def test_health_reports_stale_queued_task(gateway_client):
|
||||
"""任务 queued 超过 worker_offline_alert_seconds 时报异常"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "stuck", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
import time as _time
|
||||
|
||||
_time.sleep(2.0)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
assert health["status"] == "degraded"
|
||||
stuck_ids = {t["task_id"] for t in health["stale_queued_tasks"]}
|
||||
assert "stuck" in stuck_ids
|
||||
|
||||
|
||||
def test_health_lists_active_tasks(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
active_ids = {t["task_id"] for t in health["active_tasks"]}
|
||||
assert "t1" in active_ids
|
||||
@@ -0,0 +1,166 @@
|
||||
"""恢复路径测试:stale → reclaim → lease_count > 1 + known_state
|
||||
|
||||
覆盖规格 §5「绝不自动重投」与 §9 第 7 条「reclaim 领回的任务 lease_count > 1 且
|
||||
带 known_state」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
# TTL 设小一点,让 lease 一过期就能被 sweep 转 stale
|
||||
monkeypatch.setenv("RAKUTEN_LEASE_TTL_SECONDS", "1")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _submit_and_lease_and_report_progress(client: TestClient, task_id: str = "t1"):
|
||||
"""提交 → lease → report 一次(known_state=in_cart),返回 lease 响应"""
|
||||
client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": task_id, "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
lease = client.get(f"/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
client.post(
|
||||
f"/api/orders/{task_id}/report",
|
||||
json={"worker_id": "w1", "state": OrderState.IN_CART.value, "detail": "已加购"},
|
||||
headers=AUTH,
|
||||
)
|
||||
return lease["data"]
|
||||
|
||||
|
||||
def test_stale_task_is_not_picked_by_normal_lease(gateway_client):
|
||||
"""租约过期 → stale → 普通 lease 取不到"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
# 等 TTL 过期(1 秒)+ sweep 触发:用 wait=0 的 lease 触发内嵌 sweep
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
# 触发一次 lease(带另一个 worker_id)— 应当既取不到 t1(stale)也返回空
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.STALE.value
|
||||
|
||||
|
||||
def test_reclaim_returns_task_with_lease_count_above_one(gateway_client):
|
||||
"""reclaim 领回 stale 任务,lease_count > 1,带 known_state"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
# 触发一次 sweep
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert (
|
||||
gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]["status"]
|
||||
== TaskStatus.STALE.value
|
||||
)
|
||||
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "w2"}, headers=AUTH
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["task_id"] == "t1"
|
||||
assert data["lease_count"] >= 2 # > 1
|
||||
assert data["known_state"] == OrderState.IN_CART.value
|
||||
|
||||
|
||||
def test_reclaim_sets_leased_state_with_new_owner(gateway_client):
|
||||
"""reclaim 后任务状态回到 leased,lease_owner 是新 worker"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "w3"}, headers=AUTH
|
||||
)
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.LEASED.value
|
||||
assert detail["lease_owner"] == "w3"
|
||||
|
||||
|
||||
def test_double_reclaim_increments_lease_count(gateway_client):
|
||||
"""连续 reclaim 多次,lease_count 持续 +1"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
r1 = gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "wA"}, headers=AUTH
|
||||
).json()["data"]
|
||||
# wA 占着 lease,需要先释放(terminal report)才能再 reclaim
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={
|
||||
"worker_id": "wA",
|
||||
"state": OrderState.CREATED.value,
|
||||
"terminal": True,
|
||||
"terminal_status": TaskStatus.NEEDS_HUMAN.value,
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
# 但 terminal 后任务不再 stale,无法再 reclaim。改用:让 lease 过期
|
||||
# 这里直接验单次 reclaim 的语义即可,连续多次需要 stale→reclaim→过期→stale 循环
|
||||
assert r1["lease_count"] == 2
|
||||
|
||||
|
||||
def test_normal_lease_does_not_pick_stale_while_queued_exists(gateway_client):
|
||||
"""stale + queued 并存时,普通 lease 只取 queued,绝不取 stale
|
||||
|
||||
顺序:提交 stale-task → lease → report 进度 → 提交 fresh-task → 等 lease 过期
|
||||
→ 用 w3 lease。lease 内部先 sweep(stale-task 转 stale),active 空,queued 有
|
||||
fresh-task,应当只取到 fresh。
|
||||
"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "stale-task")
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "fresh-task", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
leased = gateway_client.get(
|
||||
"/api/orders/lease?worker_id=w3&wait=0", headers=AUTH
|
||||
).json()["data"]
|
||||
assert leased is not None
|
||||
assert leased["task_id"] == "fresh-task"
|
||||
|
||||
# stale-task 仍然停在 stale,没有被这次 lease 误取
|
||||
detail = gateway_client.get("/api/orders/stale-task", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.STALE.value
|
||||
@@ -0,0 +1,148 @@
|
||||
"""本地订单 DB 与证据存储测试
|
||||
|
||||
覆盖 worker 侧的不变量:本地幂等闸门(has_finished)、状态迁移事件、证据落盘
|
||||
路径与 meta.json 字段(规格 §6.1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_db(tmp_path: Path) -> LocalDB:
|
||||
db = LocalDB(tmp_path / "trading.db")
|
||||
await db.start()
|
||||
yield db
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 本地幂等闸门 ----
|
||||
|
||||
|
||||
async def test_has_finished_false_for_unknown_task(local_db: LocalDB):
|
||||
assert await local_db.has_finished("no-such") is False
|
||||
|
||||
|
||||
async def test_mark_finished_idempotent(local_db: LocalDB):
|
||||
"""ensure_started + mark_finished 后,has_finished 返回 True"""
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
assert await local_db.has_finished("t1") is False
|
||||
|
||||
await local_db.mark_finished("t1", "paid")
|
||||
assert await local_db.has_finished("t1") is True
|
||||
assert await local_db.final_state("t1") == "paid"
|
||||
|
||||
|
||||
async def test_ensure_started_is_idempotent(local_db: LocalDB):
|
||||
"""同任务多次 ensure_started 不覆盖 started_at"""
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
row1 = await local_db.get_order("t1")
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
row2 = await local_db.get_order("t1")
|
||||
assert row1.started_at == row2.started_at
|
||||
assert row1.intent == row2.intent == {"k": "v"}
|
||||
|
||||
|
||||
# ---- 状态事件 ----
|
||||
|
||||
|
||||
async def test_record_event_inserts_once_per_state(local_db: LocalDB):
|
||||
"""同一 (task_id, state) 重复记录视为同一次——INSERT OR IGNORE"""
|
||||
await local_db.ensure_started("t1", "rakuten", {})
|
||||
await local_db.record_event("t1", "in_cart", detail="已加购", evidence_ref="t1/01")
|
||||
await local_db.record_event("t1", "in_cart", detail="再次加购", evidence_ref="t1/01")
|
||||
|
||||
async with local_db.conn.execute(
|
||||
"SELECT state, detail FROM order_events WHERE task_id = ?", ("t1",)
|
||||
) as cur:
|
||||
events = await cur.fetchall()
|
||||
# 第二次记录被忽略(INSERT OR IGNORE)
|
||||
assert len(events) == 1
|
||||
assert events[0]["detail"] == "已加购"
|
||||
|
||||
|
||||
async def test_record_event_allows_distinct_states(local_db: LocalDB):
|
||||
await local_db.ensure_started("t1", "rakuten", {})
|
||||
await local_db.record_event("t1", "in_cart")
|
||||
await local_db.record_event("t1", "ordered")
|
||||
await local_db.record_event("t1", "paid")
|
||||
|
||||
async with local_db.conn.execute(
|
||||
"SELECT state FROM order_events WHERE task_id = ? ORDER BY recorded_at",
|
||||
("t1",),
|
||||
) as cur:
|
||||
events = await cur.fetchall()
|
||||
assert [r["state"] for r in events] == ["in_cart", "ordered", "paid"]
|
||||
|
||||
|
||||
# ---- 证据存储 ----
|
||||
|
||||
|
||||
def test_evidence_write_step_creates_expected_files(tmp_path: Path):
|
||||
"""规格 §6.1:每步一个子目录,{序号:02d}-{步骤名}.{html|png|meta.json}"""
|
||||
store = EvidenceStore(tmp_path / "evidence")
|
||||
rel = store.write_step(
|
||||
"po-20260727-0001",
|
||||
step_no=1,
|
||||
step_name="cart-add",
|
||||
html="<html>cart</html>",
|
||||
png=b"\x89PNGfake",
|
||||
meta={"url": "https://example.com/cart", "method": "POST", "status": 200},
|
||||
)
|
||||
|
||||
assert rel == "po-20260727-0001/01-cart-add"
|
||||
step_dir = tmp_path / "evidence" / "po-20260727-0001"
|
||||
assert (step_dir / "01-cart-add.html").read_text(encoding="utf-8") == "<html>cart</html>"
|
||||
assert (step_dir / "01-cart-add.png").read_bytes() == b"\x89PNGfake"
|
||||
|
||||
meta = json.loads(
|
||||
(step_dir / "01-cart-add.meta.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert meta["url"] == "https://example.com/cart"
|
||||
assert meta["method"] == "POST"
|
||||
assert meta["status"] == 200
|
||||
|
||||
|
||||
def test_evidence_write_step_supports_partial(tmp_path: Path):
|
||||
"""只写 meta 不写 html/png 也能正常落盘"""
|
||||
store = EvidenceStore(tmp_path / "evidence")
|
||||
store.write_step("t1", step_no=2, step_name="confirm", meta={"a": 1})
|
||||
step_dir = tmp_path / "evidence" / "t1"
|
||||
assert not (step_dir / "02-confirm.html").exists()
|
||||
assert not (step_dir / "02-confirm.png").exists()
|
||||
assert (step_dir / "02-confirm.meta.json").exists()
|
||||
|
||||
|
||||
def test_evidence_index_records_relative_path(tmp_path: Path):
|
||||
"""index_evidence 把相对路径存进 DB,供事后翻查"""
|
||||
db = LocalDB(tmp_path / "trading.db")
|
||||
# 同步包装:用 asyncio.run 跑一次
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
await db.start()
|
||||
try:
|
||||
await db.ensure_started("t1", "rakuten", {})
|
||||
await db.index_evidence("t1", 1, "cart-add", "t1/01-cart-add")
|
||||
await db.index_evidence("t1", 2, "confirm", "t1/03-order-confirm")
|
||||
|
||||
async with db.conn.execute(
|
||||
"SELECT step_no, step_name, rel_path FROM evidence_index "
|
||||
"WHERE task_id = ? ORDER BY step_no",
|
||||
("t1",),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
assert [(r["step_no"], r["step_name"], r["rel_path"]) for r in rows] == [
|
||||
(1, "cart-add", "t1/01-cart-add"),
|
||||
(2, "confirm", "t1/03-order-confirm"),
|
||||
]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -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