Files
rakuten-api/tests/test_gateway_recover.py
T
q792602257andClaude Opus 5 f6c3976c0a feat(gateway): 定时下派通道——周期性盘点账号订单并回写网关编目
新增 §12 定时下派通道:网关自己定期派 order_list / order_detail 查询,把账号
真实订单沉淀进新表 account_orders(回写网关),上游可直接查 GET /api/account/orders
拿到账号里实际有哪些订单,不必自己记 order_number。

- collector.py: OrderDiscoveryCollector 常驻后台任务(与 sweep 并列)。每隔
  account_discovery_interval_seconds 派 order_list,收割结果后把「编目里没有的
  订单」upsert 进编目,再逐笔派 order_detail 沉淀 delivery_status/order_state。
  状态无痕:不新增编排跟踪表,仅内存 _pending + _detail_dispatched_this_day;
  detail query_id 按天分段(discover-detail-<order>-<日期>),同天幂等防重复派。
  边界:collector 只消费 worker 结果的规范化字段,绝不解析 raw/raw_pages。
- db.py: account_orders 表 + AccountOrderRow + upsert/single/list/stale 访问;
  upsert 以 order_number 为主键,重复采集只刷新,列表重扫不抹详情节点。
- 路由: GET /api/account/orders(列编目)、GET /api/account/orders/{n}(6005)、
  POST /api/account/discovery/trigger(手动立即派一轮 list)。
- config: RAKUTEN_ACCOUNT_DISCOVERY_ENABLED / _INTERVAL_SECONDS / _MAX_PAGES /
  RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS。
- 既有网关测试夹具统一关 discovery(会启动即派扫描污染查询队列语义),
  新表测试在 tests/test_gateway_discovery.py(13 用例,真实 QueryQueue+DB 装配)。

472 tests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:35:51 +08:00

168 lines
5.8 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))
monkeypatch.setenv("RAKUTEN_ACCOUNT_DISCOVERY_ENABLED", "false")
# 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