"""网关 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