Files
shaguabijia-app-server/tests/test_coupon_proxy.py
T
marco 0fc8521c3b feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#210)
一次比价/领券的 trace_id 改由后端签发,让前端 SLS 运行日志(trace_id 索引列)、
app-server 比价记录与领券流水、pricebot trace 目录/run.log/trace_url 全链共用同一个
id 查到底(此前前端各业务自己 randomUUID,虽同链但非后端签发、也无单一签发点)。

- POST /api/v1/compare/start(预占额度,任务第一个请求,签发与建 running 行合一):
  请求 trace_id 改可选,缺省时服务端签发 uuid;响应新增 trace_id 字段返回(签发的或
  回显客户端带来的)。客户端带值则沿用——老客户端兼容 + 同 trace 重试幂等。
- POST /api/v1/coupon/session:started 帧缺 trace_id 时签发并随响应返回(签发不依赖
  写库成功);非 started 帧缺 trace_id 不签发、不写库(收尾没有 id 只能是异常调用,
  签发新 id 只会造出查不到发起信息的孤儿行)。新增 CouponSessionOut 响应模型——原
  dict[str,bool] 注解无法承载字符串 trace_id,FastAPI 响应校验会炸。
- 测试:更新 2 处旧断言(响应体多出 trace_id 字段),新增 compare 不带 id 签发用例 +
  coupon started 签发/回显、终尾缺 id 跳过写库 3 个用例。全量 30 passed + ruff clean。

配合 shaguabijia-app-android 同名分支 feat-unify-trace-id-backend-issued 的前端换源改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #210
2026-07-31 23:11:35 +08:00

161 lines
5.6 KiB
Python

"""/api/v1/coupon/step 透传端点测试。
mock 掉对 pricebot 的 httpx 调用,验证:
1. 无 token → 401
2. 带 token + pricebot 200 → 响应原样透传,请求 body 原样转发
3. pricebot 5xx → 502
4. pricebot 网络错误 → 502
"""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
import httpx
import pytest
def _stub_screen_state() -> dict:
"""最简的 screen_state(领券业务用,跟外卖/电商同结构)。"""
return {
"screen": {"width": 1080, "height": 2340, "density": 3.0},
"foreground": {"package": "x", "activity": ""},
"windows": [],
}
def _stub_request_body() -> dict:
return {
"device_id": "test-device",
"trace_id": "test-trace-1",
"step": 0,
"screen_state": _stub_screen_state(),
}
@pytest.fixture()
def access_token(client) -> str:
"""sms 登录拿一个 access_token。每次新 phone 避免冷却 429。"""
phone = f"139{int(time.time() * 1000) % 100000000:08d}"
r = client.post("/api/v1/auth/sms/send", json={"phone": phone})
assert r.status_code == 200, r.text
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def test_coupon_step_no_auth_required(client) -> None:
"""MVP 不鉴权:不带 token 也能转发(已去掉 CurrentUser,device_id 透传)。
历史:本测试原断言"无 token → 401",但 coupon/step 已去鉴权(见 docs/待办与
技术债.md「已解决」),401 断言已过时,改为验证不带 token 也能正常透传。
"""
async def fake_post(self, url, json=None, **kw):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json = lambda: {"success": True}
return mock_resp
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post("/api/v1/coupon/step", json=_stub_request_body())
assert r.status_code == 200, r.text
def test_coupon_step_passes_body_through(client, access_token) -> None:
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step;
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)。"""
fake_pricebot_resp = {
"success": True,
"action": {
"command": "launch",
"params": {"app": "美团", "deeplink": "imeituan://test"},
},
"continue": True,
"status": {
"platform": "coupon",
"progress": {
"current": 1,
"total": 2,
"current_coupon_id": "mt_banjia",
"current_coupon_name": "美团半价周末",
},
},
}
captured: dict = {}
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= ——
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
async def fake_post(self, url, content=None, **kw):
captured["url"] = url
captured["content"] = content
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json = lambda: fake_pricebot_resp
return mock_resp
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post(
"/api/v1/coupon/step",
headers={"Authorization": f"Bearer {access_token}"},
json=_stub_request_body(),
)
assert r.status_code == 200, r.text
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started)
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"}
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot)
assert json.loads(captured["content"]) == _stub_request_body()
assert captured["url"].endswith("/api/coupon/step")
def test_coupon_step_pricebot_5xx_returns_502(client, access_token) -> None:
"""pricebot 返 503 → 我们返 502。"""
async def fake_post(self, url, json=None, **kw):
mock_resp = MagicMock()
mock_resp.status_code = 503
mock_resp.text = "service unavailable"
return mock_resp
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post(
"/api/v1/coupon/step",
headers={"Authorization": f"Bearer {access_token}"},
json=_stub_request_body(),
)
assert r.status_code == 502
assert "pricebot upstream returned 503" in r.json()["detail"]
def test_coupon_step_pricebot_unreachable_returns_502(client, access_token) -> None:
"""pricebot 网络不可达 → 502。"""
async def fake_post(self, url, json=None, **kw):
raise httpx.ConnectError("connection refused")
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post(
"/api/v1/coupon/step",
headers={"Authorization": f"Bearer {access_token}"},
json=_stub_request_body(),
)
assert r.status_code == 502
assert "pricebot upstream unreachable" in r.json()["detail"]
def test_coupon_step_invalid_json_body(client, access_token) -> None:
"""非 JSON body → 400(在转发前就拒绝)。"""
r = client.post(
"/api/v1/coupon/step",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
content=b"not-json",
)
assert r.status_code == 400
assert "invalid json body" in r.json()["detail"]