"""账号只读查询通道测试(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()