按 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>
167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
"""恢复路径测试: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
|