"""/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 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。""" 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 = {} async def fake_post(self, url, json=None, **kw): captured["url"] = url captured["json"] = json 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 assert r.json() == fake_pricebot_resp # 验证请求被原样转发(body 不动 + URL 指向 pricebot) assert captured["json"] == _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"]