按 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>
308 lines
9.4 KiB
Python
308 lines
9.4 KiB
Python
"""网关 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
|