feat(gateway): 账号只读查询通道——从已登录账号取真实订单
上游要的不只是网关记的任务状态镜像,还有「已登录账号在站点上的真实订单」,
但账号只在 NAT 后本地机上,只能经网关队列走。新增独立查询通道(§11):
- gateway 单开 account_queries 表 + QueryStatus 状态机,接口
POST /api/account/queries(幂等)/ lease / {id}/result / {id}
- 不复用下单任务队列:查询是只读,租约过期可安全重投(与下单「绝不自动
重投」相反),且不该被全局并发度 1 堵死、task_reports 是订单镜像不能污染
- 本地交易服务起第二条常驻循环 query_runner,领到即调 SiteInteractor 真读:
order_list 复用已实测的 list_recent_orders(规范化字段 + 站点
orderListData 原文),order_detail 复用 fetch_order_detail(配送阶段 +
页面 __INITIAL_STATE__ 原样透传,结构未经真实样本,不抽字段)
- 账号级串行仍由 SiteInteractor 的锁保证;每次执行套超时按失败回报
- 错误码 6005/6006(查询通道,可重试只读区别于 6001-6004);/health 暴露
queued_query_count;结果体积上限先丢原始 JSON
openapi.json 重导,docs/order-gateway.md §11、README、.env.example 补全
配置与实测边界。全量测试 404→454 通过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
"""账号只读查询通道测试(docs/order-gateway.md §11)
|
||||
|
||||
分两层:
|
||||
- HTTP 层:鉴权、幂等、6005/6006、lease → result 全链路、列表与 /health
|
||||
- 队列语义层:直接驱动 QueryQueue,测那些靠 HTTP 不好造的时序——租约超时重投、
|
||||
重投次数用尽、整体 TTL 过期、迟到结果被拒、过保留期清理
|
||||
|
||||
这里最关键的一条是「只读可重投」:它与下单任务的「绝不自动重投」正好相反,
|
||||
两条通道的语义分歧全在这几个用例里,改任何一边前先看它们。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.db import GatewayDB, QueryRow
|
||||
from app.gateway.query_queue import QueryQueue, to_iso, utcnow
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.errors import QueryLeaseInvalidError
|
||||
from app.shared.task_state import AccountQueryKind, QueryStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
QUERIES = "/api/account/queries"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
"""起一个独立 DB 的网关应用(与 test_gateway_api.py 同一套装配方式)"""
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(tmp_path / "gw.db"))
|
||||
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()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def queue(tmp_path: Path):
|
||||
"""直接可用的 (QueryQueue, GatewayDB) 对
|
||||
|
||||
队列语义用例要模拟「租约已过期」「结果早就写完了」这类时间流逝,直接改库里的
|
||||
时间戳比 sleep 快得多,所以把 db 一并交出去。
|
||||
"""
|
||||
db = GatewayDB(tmp_path / "q.db")
|
||||
await db.start()
|
||||
try:
|
||||
yield (
|
||||
QueryQueue(
|
||||
db,
|
||||
lease_ttl_seconds=60,
|
||||
query_ttl_seconds=600,
|
||||
max_attempts=3,
|
||||
retention_seconds=7 * 24 * 3600,
|
||||
),
|
||||
db,
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def _submit(client: TestClient, **overrides) -> dict:
|
||||
payload = {
|
||||
"query_id": "q1",
|
||||
"site": "rakuten",
|
||||
"kind": AccountQueryKind.ORDER_LIST.value,
|
||||
"params": {"max_pages": 1},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return client.post(QUERIES, json=payload, headers=AUTH).json()
|
||||
|
||||
|
||||
# ---- HTTP:鉴权与参数校验 ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,method",
|
||||
[
|
||||
(QUERIES, "POST"),
|
||||
(f"{QUERIES}/lease", "GET"),
|
||||
(f"{QUERIES}/q1", "GET"),
|
||||
],
|
||||
)
|
||||
def test_query_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_unknown_kind_is_rejected_at_submit(gateway_client):
|
||||
"""kind 拼错当场 422,而不是等 worker 领走、执行、回报失败才知道"""
|
||||
response = gateway_client.post(
|
||||
QUERIES,
|
||||
json={"site": "rakuten", "kind": "order_lst", "params": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == 1002
|
||||
|
||||
|
||||
# ---- HTTP:提交、幂等、领取、回结果 ----
|
||||
|
||||
|
||||
def test_submit_returns_query_id_and_queued(gateway_client):
|
||||
body = _submit(gateway_client)
|
||||
assert body["success"] is True
|
||||
assert body["data"]["query_id"] == "q1"
|
||||
assert body["data"]["status"] == QueryStatus.QUEUED.value
|
||||
assert body["data"]["created"] is True
|
||||
|
||||
|
||||
def test_submit_is_idempotent_on_same_query_id(gateway_client):
|
||||
assert _submit(gateway_client)["data"]["created"] is True
|
||||
assert _submit(gateway_client)["data"]["created"] is False
|
||||
|
||||
|
||||
def test_submit_generates_query_id_when_absent(gateway_client):
|
||||
body = gateway_client.post(
|
||||
QUERIES,
|
||||
json={"site": "rakuten", "kind": AccountQueryKind.ORDER_DETAIL.value, "params": {}},
|
||||
headers=AUTH,
|
||||
).json()
|
||||
assert body["data"]["query_id"].startswith("q-")
|
||||
|
||||
|
||||
def test_lease_returns_null_when_no_query(gateway_client):
|
||||
response = gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
|
||||
|
||||
def test_lease_then_result_completes_the_query(gateway_client):
|
||||
_submit(gateway_client)
|
||||
|
||||
leased = gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
assert leased["data"]["query_id"] == "q1"
|
||||
assert leased["data"]["kind"] == AccountQueryKind.ORDER_LIST.value
|
||||
assert leased["data"]["params"] == {"max_pages": 1}
|
||||
assert leased["data"]["attempt"] == 1
|
||||
|
||||
result = {"kind": "order_list", "orders": [{"order_number": "306087-20260813-0863947697"}]}
|
||||
reported = gateway_client.post(
|
||||
f"{QUERIES}/q1/result",
|
||||
json={"worker_id": "w1", "success": True, "result": result},
|
||||
headers=AUTH,
|
||||
).json()
|
||||
assert reported["data"]["status"] == QueryStatus.SUCCEEDED.value
|
||||
|
||||
detail = gateway_client.get(f"{QUERIES}/q1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == QueryStatus.SUCCEEDED.value
|
||||
assert detail["result"] == result
|
||||
assert detail["error"] is None
|
||||
assert detail["lease_owner"] is None
|
||||
|
||||
|
||||
def test_failed_result_carries_error_code_and_message(gateway_client):
|
||||
_submit(gateway_client)
|
||||
gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
gateway_client.post(
|
||||
f"{QUERIES}/q1/result",
|
||||
json={
|
||||
"worker_id": "w1",
|
||||
"success": False,
|
||||
"error_code": 5001,
|
||||
"error_message": "账号未登录",
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
detail = gateway_client.get(f"{QUERIES}/q1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == QueryStatus.FAILED.value
|
||||
assert detail["error"] == {"code": 5001, "message": "账号未登录"}
|
||||
|
||||
|
||||
def test_two_queries_can_be_in_flight_at_once(gateway_client):
|
||||
"""与下单任务不同:只读查询没有「全局并发度 1」的闸门"""
|
||||
_submit(gateway_client, query_id="q1")
|
||||
_submit(gateway_client, query_id="q2")
|
||||
|
||||
first = gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
second = gateway_client.get(f"{QUERIES}/lease?worker_id=w2&wait=0", headers=AUTH).json()
|
||||
|
||||
assert first["data"]["query_id"] == "q1"
|
||||
assert second["data"]["query_id"] == "q2"
|
||||
|
||||
|
||||
def test_order_lease_is_not_disturbed_by_pending_query(gateway_client):
|
||||
"""两条通道互不干扰:有查询在飞,下单任务照领"""
|
||||
_submit(gateway_client)
|
||||
gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
leased = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
assert leased["data"]["task_id"] == "t1"
|
||||
|
||||
|
||||
# ---- HTTP:错误码 ----
|
||||
|
||||
|
||||
def test_get_unknown_query_returns_6005(gateway_client):
|
||||
response = gateway_client.get(f"{QUERIES}/no-such", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == 6005
|
||||
|
||||
|
||||
def test_result_from_non_owner_returns_6006(gateway_client):
|
||||
_submit(gateway_client)
|
||||
gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
response = gateway_client.post(
|
||||
f"{QUERIES}/q1/result",
|
||||
json={"worker_id": "w2", "success": True, "result": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6006
|
||||
|
||||
|
||||
def test_result_on_terminal_query_returns_6006(gateway_client):
|
||||
_submit(gateway_client)
|
||||
gateway_client.get(f"{QUERIES}/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
gateway_client.post(
|
||||
f"{QUERIES}/q1/result",
|
||||
json={"worker_id": "w1", "success": True, "result": {"a": 1}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
response = gateway_client.post(
|
||||
f"{QUERIES}/q1/result",
|
||||
json={"worker_id": "w1", "success": True, "result": {"a": 2}},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6006
|
||||
# 结果没有被第二次回报覆盖
|
||||
detail = gateway_client.get(f"{QUERIES}/q1", headers=AUTH).json()["data"]
|
||||
assert detail["result"] == {"a": 1}
|
||||
|
||||
|
||||
# ---- HTTP:列表与健康检查 ----
|
||||
|
||||
|
||||
def test_list_queries_filters_by_kind(gateway_client):
|
||||
_submit(gateway_client, query_id="q1", kind=AccountQueryKind.ORDER_LIST.value)
|
||||
_submit(gateway_client, query_id="q2", kind=AccountQueryKind.ORDER_DETAIL.value)
|
||||
|
||||
body = gateway_client.get(
|
||||
f"{QUERIES}?kind={AccountQueryKind.ORDER_DETAIL.value}", headers=AUTH
|
||||
).json()
|
||||
assert body["data"]["total"] == 1
|
||||
assert body["data"]["items"][0]["query_id"] == "q2"
|
||||
|
||||
|
||||
def test_health_reports_queued_query_count(gateway_client):
|
||||
_submit(gateway_client)
|
||||
body = gateway_client.get("/health").json()
|
||||
assert body["data"]["queued_query_count"] == 1
|
||||
|
||||
|
||||
# ---- 队列语义:只读可重投(与下单任务的核心分歧)----
|
||||
|
||||
|
||||
async def test_expired_lease_goes_back_to_queued(queue):
|
||||
"""租约过期→回 queued 自动重投。下单任务在这里是 stale 且绝不重投"""
|
||||
q, db = queue
|
||||
await q.submit(query_id="q1", site="rakuten", kind="order_list", params={})
|
||||
lease = await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert lease is not None
|
||||
|
||||
await db.update_query("q1", lease_expires_at=to_iso(utcnow() - timedelta(seconds=1)))
|
||||
assert await q.sweep() == 1
|
||||
|
||||
detail = await q.get_detail("q1")
|
||||
assert detail.status == QueryStatus.QUEUED
|
||||
assert detail.lease_owner is None
|
||||
|
||||
again = await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert again is not None
|
||||
assert again.attempt == 2
|
||||
|
||||
|
||||
async def test_late_result_after_reinvest_is_rejected(queue):
|
||||
"""超时重投后,上一轮 worker 迟到的结果必须被拒,不能覆盖新一轮"""
|
||||
q, db = queue
|
||||
await q.submit(query_id="q1", site="rakuten", kind="order_list", params={})
|
||||
await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
await db.update_query("q1", lease_expires_at=to_iso(utcnow() - timedelta(seconds=1)))
|
||||
await q.sweep()
|
||||
await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
|
||||
with pytest.raises(QueryLeaseInvalidError):
|
||||
await q.submit_result(
|
||||
"q1", worker_id="w1", success=True, result={"stale": True},
|
||||
error_code=None, error_message="",
|
||||
)
|
||||
|
||||
|
||||
async def test_reinvest_stops_at_max_attempts(queue):
|
||||
"""重投次数用尽 → failed,不再无限重投同一张卡死的单"""
|
||||
q, db = queue
|
||||
await q.submit(query_id="q1", site="rakuten", kind="order_list", params={})
|
||||
for _ in range(3):
|
||||
await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
await db.update_query("q1", lease_expires_at=to_iso(utcnow() - timedelta(seconds=1)))
|
||||
await q.sweep()
|
||||
|
||||
detail = await q.get_detail("q1")
|
||||
assert detail.status == QueryStatus.FAILED
|
||||
assert detail.attempts == 3
|
||||
assert "不再重投" in detail.error.message
|
||||
|
||||
|
||||
async def test_query_expires_after_ttl(tmp_path):
|
||||
"""整体 TTL 到点 → expired(本地 worker 不在线的典型表现)"""
|
||||
db = GatewayDB(tmp_path / "q.db")
|
||||
await db.start()
|
||||
try:
|
||||
queue = QueryQueue(
|
||||
db, lease_ttl_seconds=60, query_ttl_seconds=60, max_attempts=3,
|
||||
retention_seconds=3600,
|
||||
)
|
||||
# 直接插一张「10 分钟前创建」的单,比 sleep 现实
|
||||
old = to_iso(utcnow() - timedelta(minutes=10))
|
||||
await db.insert_query(
|
||||
QueryRow(
|
||||
query_id="q1", site="rakuten", kind="order_list", params_json="{}",
|
||||
status=QueryStatus.QUEUED.value, lease_owner=None, lease_expires_at=None,
|
||||
attempts=0, result_json=None, error_code=None, error_message=None,
|
||||
created_at=old, updated_at=old, completed_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert await queue.sweep() == 1
|
||||
detail = await queue.get_detail("q1")
|
||||
assert detail.status == QueryStatus.EXPIRED
|
||||
assert "worker 不在线" in detail.error.message
|
||||
# 过期的单不会再被领走
|
||||
assert await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60) is None
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_sweep_purges_queries_past_retention(tmp_path):
|
||||
"""终态查询单过保留期被清掉——结果里带站点原始 JSON,不清会一直涨"""
|
||||
db = GatewayDB(tmp_path / "q.db")
|
||||
await db.start()
|
||||
try:
|
||||
queue = QueryQueue(
|
||||
db, lease_ttl_seconds=60, query_ttl_seconds=600, max_attempts=3,
|
||||
retention_seconds=1,
|
||||
)
|
||||
await queue.submit(query_id="q1", site="rakuten", kind="order_list", params={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
await queue.submit_result(
|
||||
"q1", worker_id="w1", success=True, result={"a": 1},
|
||||
error_code=None, error_message="",
|
||||
)
|
||||
await db.update_query("q1", completed_at=to_iso(utcnow() - timedelta(seconds=60)))
|
||||
|
||||
await queue.sweep()
|
||||
assert await db.get_query("q1") is None
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_sweep_keeps_unfinished_queries(tmp_path):
|
||||
"""保留期清理只动终态单,进行中的一律不碰"""
|
||||
db = GatewayDB(tmp_path / "q.db")
|
||||
await db.start()
|
||||
try:
|
||||
queue = QueryQueue(
|
||||
db, lease_ttl_seconds=60, query_ttl_seconds=600, max_attempts=3,
|
||||
retention_seconds=1,
|
||||
)
|
||||
await queue.submit(query_id="q1", site="rakuten", kind="order_list", params={})
|
||||
await queue.sweep()
|
||||
assert await db.get_query("q1") is not None
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""账号只读查询 worker 测试(app/trading/worker/query_runner.py)
|
||||
|
||||
站点交互与网关都用桩替换,测的是 QueryRunner 自己的编排:
|
||||
- 两种 kind 各自把站点结果整理成什么样的 result(原始 JSON 有没有原样带出来)
|
||||
- 失败怎么转成一次「失败回报」——超时、站点 AppError、参数不合法、未知 kind
|
||||
- 结果体积闸门:先丢站点原始 JSON,仍超限就判失败让上游缩小窗口
|
||||
|
||||
一条贯穿始终的约定:**worker 永远不把异常抛回主循环**,每张查询单都要有一次
|
||||
回报(成功或失败),否则网关侧那张单只能干等到租约超时。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import AppError, NotLoggedInError
|
||||
from app.shared.task_state import AccountQueryKind, OrderState
|
||||
from app.trading.worker.models import QueryTask
|
||||
from app.trading.worker.query_runner import QueryRunner
|
||||
from app.trading.worker.site_interact import (
|
||||
OrderDetailSnapshot,
|
||||
OrderListEntry,
|
||||
OrderListItem,
|
||||
OrderListWindow,
|
||||
OrderStatusSnapshot,
|
||||
)
|
||||
|
||||
|
||||
class _FakeGateway:
|
||||
"""记录回报内容的网关客户端替身
|
||||
|
||||
`lease_query` 里那句 `sleep(0)` 是必需的:真实实现是 HTTP 长轮询,一定会把
|
||||
控制权交回事件循环;纯内存替身不 await 任何东西的话,run() 会一直霸占循环,
|
||||
同一个 event loop 里的停机任务永远排不上,测试直接挂死。
|
||||
"""
|
||||
|
||||
def __init__(self, queries: list[QueryTask | None] | None = None):
|
||||
self._queries = list(queries or [])
|
||||
self.reports: list[dict[str, Any]] = []
|
||||
self.lease_calls = 0
|
||||
|
||||
async def lease_query(self, worker_id: str, *, wait: int = 30) -> QueryTask | None:
|
||||
self.lease_calls += 1
|
||||
await asyncio.sleep(0)
|
||||
if not self._queries:
|
||||
return None
|
||||
return self._queries.pop(0)
|
||||
|
||||
async def report_query_result(
|
||||
self,
|
||||
query_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
success: bool,
|
||||
result: dict[str, Any] | None = None,
|
||||
error_code: int | None = None,
|
||||
error_message: str = "",
|
||||
) -> dict[str, Any]:
|
||||
self.reports.append(
|
||||
{
|
||||
"query_id": query_id,
|
||||
"worker_id": worker_id,
|
||||
"success": success,
|
||||
"result": result,
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
}
|
||||
)
|
||||
return {"query_id": query_id, "status": "succeeded" if success else "failed"}
|
||||
|
||||
|
||||
class _FakeSite:
|
||||
"""SiteInteractor 替身:按脚本返回窗口/详情,或抛出指定异常"""
|
||||
|
||||
def __init__(self, *, window=None, detail=None, error: Exception | None = None, delay: float = 0):
|
||||
self._window = window
|
||||
self._detail = detail
|
||||
self._error = error
|
||||
self._delay = delay
|
||||
self.list_calls: list[dict[str, Any]] = []
|
||||
self.detail_calls: list[str] = []
|
||||
|
||||
async def list_recent_orders(self, *, since, max_pages=None):
|
||||
self.list_calls.append({"since": since, "max_pages": max_pages})
|
||||
await asyncio.sleep(self._delay)
|
||||
if self._error:
|
||||
raise self._error
|
||||
return self._window
|
||||
|
||||
async def fetch_order_detail(self, site_order_id: str):
|
||||
self.detail_calls.append(site_order_id)
|
||||
await asyncio.sleep(self._delay)
|
||||
if self._error:
|
||||
raise self._error
|
||||
return self._detail
|
||||
|
||||
|
||||
def _runner(site: _FakeSite, gateway: _FakeGateway, **settings_kwargs) -> QueryRunner:
|
||||
settings = Settings(worker_id="w1", **settings_kwargs)
|
||||
return QueryRunner(settings=settings, gateway_client=gateway, site=site) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _query(kind: str, params: dict[str, Any] | None = None, **kwargs) -> QueryTask:
|
||||
return QueryTask(
|
||||
query_id=kwargs.get("query_id", "q1"),
|
||||
site=kwargs.get("site", "rakuten"),
|
||||
kind=kind,
|
||||
params=params or {},
|
||||
attempt=kwargs.get("attempt", 1),
|
||||
)
|
||||
|
||||
|
||||
def _window(*, covered: bool = True) -> OrderListWindow:
|
||||
return OrderListWindow(
|
||||
entries=[
|
||||
OrderListEntry(
|
||||
order_number="306087-20260813-0863947697",
|
||||
order_date="2026-08-13T09:47:28.000Z",
|
||||
shop_id=306087,
|
||||
shop_name="BACKYARD FAMILY インテリアタウン",
|
||||
items=[
|
||||
OrderListItem(
|
||||
item_url="https://item.rakuten.co.jp/backyard/x/",
|
||||
item_name="壁掛けフック",
|
||||
item_id=10012345,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
window_fully_covered=covered,
|
||||
raw_pages=[{"ordersFound": 1, "orderList": [{"orderNumber": "306087-20260813-0863947697"}]}],
|
||||
)
|
||||
|
||||
|
||||
# ---- order_list ----
|
||||
|
||||
|
||||
async def test_order_list_returns_normalized_and_raw():
|
||||
site = _FakeSite(window=_window())
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value, {"max_pages": 2}))
|
||||
|
||||
report = gateway.reports[0]
|
||||
assert report["success"] is True
|
||||
result = report["result"]
|
||||
assert result["kind"] == "order_list"
|
||||
assert result["window_fully_covered"] is True
|
||||
assert result["orders"][0]["order_number"] == "306087-20260813-0863947697"
|
||||
assert result["orders"][0]["items"][0]["item_id"] == 10012345
|
||||
# 站点原文原样带出,规范化模型没覆盖的字段上游还能自己取
|
||||
assert result["raw_pages"][0]["ordersFound"] == 1
|
||||
assert site.list_calls[0]["max_pages"] == 2
|
||||
|
||||
|
||||
async def test_order_list_since_is_passed_through():
|
||||
site = _FakeSite(window=_window())
|
||||
runner = _runner(site, _FakeGateway())
|
||||
|
||||
await runner.handle(
|
||||
_query(AccountQueryKind.ORDER_LIST.value, {"since": "2026-08-01T00:00:00Z"})
|
||||
)
|
||||
|
||||
assert site.list_calls[0]["since"] == datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def test_order_list_without_since_uses_epoch_and_default_max_pages():
|
||||
site = _FakeSite(window=_window())
|
||||
runner = _runner(site, _FakeGateway(), account_query_default_max_pages=5)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert site.list_calls[0]["since"] == datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
assert site.list_calls[0]["max_pages"] == 5
|
||||
|
||||
|
||||
async def test_order_list_reports_partial_coverage_honestly():
|
||||
"""翻页没覆盖完窗口时必须如实标出来——上游据此知道「没查到」不等于「没有」"""
|
||||
site = _FakeSite(window=_window(covered=False))
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert gateway.reports[0]["result"]["window_fully_covered"] is False
|
||||
|
||||
|
||||
async def test_invalid_since_is_reported_as_failure():
|
||||
site = _FakeSite(window=_window())
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value, {"since": "上周"}))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 1003
|
||||
assert site.list_calls == [] # 参数不合法就没去碰站点
|
||||
|
||||
|
||||
async def test_invalid_max_pages_is_reported_as_failure():
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(window=_window()), gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value, {"max_pages": 0}))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 1003
|
||||
|
||||
|
||||
# ---- order_detail ----
|
||||
|
||||
|
||||
async def test_order_detail_returns_stage_and_raw():
|
||||
detail = OrderDetailSnapshot(
|
||||
status=OrderStatusSnapshot(
|
||||
found=True, stage_label="出荷", order_state=OrderState.SHIPPED, html="<html/>"
|
||||
),
|
||||
raw={"pageType": "ph-detail"},
|
||||
)
|
||||
site = _FakeSite(detail=detail)
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(
|
||||
_query(AccountQueryKind.ORDER_DETAIL.value, {"order_number": "306087-20260813-0863947697"})
|
||||
)
|
||||
|
||||
result = gateway.reports[0]["result"]
|
||||
assert result["found"] is True
|
||||
assert result["stage_label"] == "出荷"
|
||||
assert result["order_state"] == OrderState.SHIPPED.value
|
||||
assert result["raw"] == {"pageType": "ph-detail"}
|
||||
assert result["raw_available"] is True
|
||||
assert site.detail_calls == ["306087-20260813-0863947697"]
|
||||
|
||||
|
||||
async def test_order_detail_not_found_is_success_not_failure():
|
||||
"""站点自己说订单要 10 分钟才反映出来:查不到是正常结果,不是查询失败"""
|
||||
site = _FakeSite(detail=OrderDetailSnapshot(status=OrderStatusSnapshot(found=False), raw=None))
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_DETAIL.value, {"order_number": "x-1-2"}))
|
||||
|
||||
assert gateway.reports[0]["success"] is True
|
||||
assert gateway.reports[0]["result"]["found"] is False
|
||||
assert gateway.reports[0]["result"]["raw_available"] is False
|
||||
|
||||
|
||||
async def test_order_detail_requires_order_number():
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(), gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_DETAIL.value, {}))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 1003
|
||||
|
||||
|
||||
# ---- 失败路径 ----
|
||||
|
||||
|
||||
async def test_unknown_kind_is_reported_as_failure():
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(), gateway)
|
||||
|
||||
await runner.handle(_query("order_everything"))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert "未知的查询种类" in gateway.reports[0]["error_message"]
|
||||
|
||||
|
||||
async def test_non_rakuten_site_is_rejected():
|
||||
"""交易服务只覆盖乐天市场,ラクマ 永不进入账号链路"""
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(), gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value, site="rakuma"))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert "rakuma" in gateway.reports[0]["error_message"]
|
||||
|
||||
|
||||
async def test_site_app_error_keeps_its_error_code():
|
||||
"""站点侧错误码原样回给上游(掉登录=5001),不被翻译成一个笼统的失败"""
|
||||
site = _FakeSite(error=NotLoggedInError(site="rakuten"))
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 5001
|
||||
|
||||
|
||||
async def test_unexpected_exception_is_reported_not_raised():
|
||||
site = _FakeSite(error=RuntimeError("boom"))
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 1500
|
||||
assert "RuntimeError" in gateway.reports[0]["error_message"]
|
||||
|
||||
|
||||
async def test_timeout_is_reported_as_retryable_failure():
|
||||
"""账号锁被下单占着 → 超时按失败回报(上游重发即可),不挂到租约过期"""
|
||||
site = _FakeSite(window=_window(), delay=0.2)
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(site, gateway, account_query_timeout_seconds=0)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert gateway.reports[0]["error_code"] == 2002
|
||||
assert "可稍后重发" in gateway.reports[0]["error_message"]
|
||||
|
||||
|
||||
async def test_report_failure_does_not_escape():
|
||||
"""回报本身失败(比如租约已被重投)只记日志,不能把循环带崩"""
|
||||
|
||||
class _RejectingGateway(_FakeGateway):
|
||||
async def report_query_result(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AppError(message="lease invalid", code="X", err_code=6006, status_code=409)
|
||||
|
||||
runner = _runner(_FakeSite(window=_window()), _RejectingGateway())
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value)) # 不抛异常即通过
|
||||
|
||||
|
||||
# ---- 结果体积闸门 ----
|
||||
|
||||
|
||||
async def test_oversized_result_drops_raw_first():
|
||||
big_window = _window()
|
||||
big_window.raw_pages = [{"padding": "x" * 5000}]
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(window=big_window), gateway, query_result_max_bytes=2000)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
report = gateway.reports[0]
|
||||
assert report["success"] is True
|
||||
assert report["result"]["raw_pages"] == []
|
||||
assert "raw_omitted" in report["result"]
|
||||
# 规范化字段还在——上游至少拿得到订单号
|
||||
assert report["result"]["orders"][0]["order_number"] == "306087-20260813-0863947697"
|
||||
|
||||
|
||||
async def test_result_still_oversized_after_dropping_raw_fails():
|
||||
"""丢掉原始 JSON 仍超限:判失败并告诉上游缩小窗口,不硬塞进网关库"""
|
||||
window = _window()
|
||||
window.entries = [
|
||||
OrderListEntry(order_number=f"o{i}", order_date="2026-08-13T09:47:28.000Z")
|
||||
for i in range(200)
|
||||
]
|
||||
gateway = _FakeGateway()
|
||||
runner = _runner(_FakeSite(window=window), gateway, query_result_max_bytes=500)
|
||||
|
||||
await runner.handle(_query(AccountQueryKind.ORDER_LIST.value))
|
||||
|
||||
assert gateway.reports[0]["success"] is False
|
||||
assert "缩小查询窗口" in gateway.reports[0]["error_message"]
|
||||
|
||||
|
||||
# ---- 主循环 ----
|
||||
|
||||
|
||||
async def test_run_loop_handles_then_stops():
|
||||
"""领到单就处理;stop() 后循环退出,不吃掉后续任务"""
|
||||
gateway = _FakeGateway([_query(AccountQueryKind.ORDER_LIST.value), None])
|
||||
runner = _runner(_FakeSite(window=_window()), gateway)
|
||||
|
||||
async def stop_soon():
|
||||
# 让 run() 先跑几轮(领单 → 处理 → 再领一次拿到 None),再停
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0)
|
||||
runner.stop()
|
||||
|
||||
await asyncio.wait_for(asyncio.gather(runner.run(), stop_soon()), timeout=5)
|
||||
|
||||
assert [r["query_id"] for r in gateway.reports] == ["q1"]
|
||||
|
||||
|
||||
async def test_run_loop_survives_lease_error(monkeypatch):
|
||||
"""lease 报错不能让循环退出(网关重启、网络抖动都算正常)"""
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
class _FlakyGateway(_FakeGateway):
|
||||
"""前两次 lease 都报错,第二次顺便把循环停掉,避免测试无限转"""
|
||||
|
||||
def __init__(self, stopper):
|
||||
super().__init__()
|
||||
self._stopper = stopper
|
||||
|
||||
async def lease_query(self, worker_id: str, *, wait: int = 30):
|
||||
self.lease_calls += 1
|
||||
if self.lease_calls >= 2:
|
||||
self._stopper()
|
||||
raise AppError(message="gateway down", code="X", err_code=3001)
|
||||
|
||||
holder: dict[str, QueryRunner] = {}
|
||||
gateway = _FlakyGateway(lambda: holder["runner"].stop())
|
||||
runner = _runner(_FakeSite(), gateway)
|
||||
holder["runner"] = runner
|
||||
# 出错后那句 sleep(5) 在测试里没必要真等;捕获原函数再替换,否则递归自调
|
||||
monkeypatch.setattr(asyncio, "sleep", lambda *_: real_sleep(0))
|
||||
|
||||
await runner.run()
|
||||
|
||||
assert gateway.lease_calls == 2
|
||||
@@ -754,6 +754,109 @@ async def test_list_recent_orders_without_start_raises():
|
||||
await site.list_recent_orders(since=_SINCE)
|
||||
|
||||
|
||||
# ---- 账号只读查询通道用到的两条只读路径(docs/order-gateway.md §11)----
|
||||
#
|
||||
# 查询接口不新写解析,复用的就是上面这两条已实测路径;这里补的是「站点原始
|
||||
# JSON 有没有被完整带出来」和「翻页上限有没有生效」——这两点是查询接口独有的,
|
||||
# 恢复核对那条老路径不关心。
|
||||
|
||||
|
||||
def test_parse_order_list_keeps_raw_order_list_data():
|
||||
"""规范化字段之外,站点 orderListData 原文要原样留着供上游取用"""
|
||||
html = _wrap_state(_real_order_list_state())
|
||||
page = _parse_order_list(html)
|
||||
assert page.raw is not None
|
||||
assert page.raw["ordersFound"] == 1
|
||||
# 规范化模型里没有的字段也在(这正是「原样透传」的意义)
|
||||
assert page.raw["orderList"][0]["shopName"] == "BACKYARD FAMILY インテリアタウン"
|
||||
|
||||
|
||||
def test_parse_order_list_raw_is_none_when_page_type_wrong():
|
||||
"""不是 ph-list(改版 / 掉登录 / act 分支不对):raw 为 None,不给上游半截数据"""
|
||||
html = _wrap_state(_real_order_list_state(page_type="ph-detail"))
|
||||
assert _parse_order_list(html).raw is None
|
||||
|
||||
|
||||
def test_accumulate_collects_raw_pages_in_order():
|
||||
"""多页翻页时,每页的原始 JSON 按顺序累积"""
|
||||
acc = _OrderListAccumulator()
|
||||
page1 = OrderListPage(
|
||||
entries=[_entry("o1", "2026-08-10T00:00:00Z")], orders_found=3, raw={"page": 1}
|
||||
)
|
||||
acc, stop = _accumulate_order_list_page(acc, page1, since=_SINCE)
|
||||
assert stop is False
|
||||
page2 = OrderListPage(
|
||||
entries=[_entry("o2", "2026-07-01T00:00:00Z")], orders_found=3, raw={"page": 2}
|
||||
)
|
||||
acc, stop = _accumulate_order_list_page(acc, page2, since=_SINCE)
|
||||
assert stop is True
|
||||
assert acc.raw_pages == [{"page": 1}, {"page": 2}]
|
||||
|
||||
|
||||
async def test_list_recent_orders_respects_max_pages(tmp_path):
|
||||
"""max_pages 是硬上限:翻到上限就停,且必须如实报 window_fully_covered=False"""
|
||||
# 每页都还有更多订单(orders_found 远大于已取回数),正常会一直翻下去
|
||||
state = _real_order_list_state(orders_found=99)
|
||||
page = _FakeOrderPage([(_ORDER_LIST_LANDED_URL, _wrap_state(state))])
|
||||
site = _build_site(tmp_path, _FakeContext([page]), _FakeAuthSession())
|
||||
|
||||
window = await site.list_recent_orders(since=_SINCE, max_pages=2)
|
||||
|
||||
assert len(page.goto_urls) == 2
|
||||
assert page.goto_urls[1].endswith("?page=2")
|
||||
assert window.window_fully_covered is False
|
||||
assert len(window.raw_pages) == 2
|
||||
|
||||
|
||||
async def test_list_recent_orders_window_carries_raw_pages(tmp_path):
|
||||
html = _wrap_state(_real_order_list_state())
|
||||
page = _FakeOrderPage([(_ORDER_LIST_LANDED_URL, html)])
|
||||
site = _build_site(tmp_path, _FakeContext([page]), _FakeAuthSession())
|
||||
|
||||
window = await site.list_recent_orders(since=_SINCE)
|
||||
|
||||
assert window.window_fully_covered is True
|
||||
assert [raw["ordersFound"] for raw in window.raw_pages] == [1]
|
||||
|
||||
|
||||
async def test_fetch_order_detail_returns_status_and_raw_state(tmp_path):
|
||||
"""详情页:已实测的配送阶段照常解析,页面原始状态原样带出"""
|
||||
html = _wrap_state({"pageType": "ph-detail", "whatever": {"a": 1}}) + _stepper_html(
|
||||
active_stage="出荷"
|
||||
)
|
||||
page = _FakeOrderPage([(_ORDER_DETAIL_LANDED_URL, html)])
|
||||
site = _build_site(tmp_path, _FakeContext([page]), _FakeAuthSession())
|
||||
|
||||
detail = await site.fetch_order_detail(_REAL_ORDER_ID)
|
||||
|
||||
assert detail.status.found is True
|
||||
assert detail.status.order_state == OrderState.SHIPPED
|
||||
assert detail.raw == {"pageType": "ph-detail", "whatever": {"a": 1}}
|
||||
|
||||
|
||||
async def test_fetch_order_detail_raw_is_none_without_inline_state(tmp_path):
|
||||
"""页面没有内联状态时 raw=None——不编造,也不因此把这次读取判成失败"""
|
||||
page = _FakeOrderPage([(_ORDER_DETAIL_LANDED_URL, _stepper_html(active_stage="出荷"))])
|
||||
site = _build_site(tmp_path, _FakeContext([page]), _FakeAuthSession())
|
||||
|
||||
detail = await site.fetch_order_detail(_REAL_ORDER_ID)
|
||||
|
||||
assert detail.raw is None
|
||||
assert detail.status.found is True
|
||||
|
||||
|
||||
async def test_check_order_status_still_returns_only_status(tmp_path):
|
||||
"""付款后监控那条老路径不受影响:仍然拿到 OrderStatusSnapshot"""
|
||||
page = _FakeOrderPage([(_ORDER_DETAIL_LANDED_URL, _stepper_html(active_stage="配達完了"))])
|
||||
site = _build_site(tmp_path, _FakeContext([page]), _FakeAuthSession())
|
||||
|
||||
snapshot = await site.check_order_status(_REAL_ORDER_ID)
|
||||
|
||||
assert isinstance(snapshot, OrderStatusSnapshot)
|
||||
assert snapshot.order_state == OrderState.DELIVERED
|
||||
|
||||
|
||||
|
||||
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user