merge: integrate latest qyqy_develop changes
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""P001(公开产品列表)Controller 路由契约测试。
|
||||
|
||||
不连数据库。覆盖:
|
||||
|
||||
- 路由**已注册**且鉴权生效:缺 token 时是 `401`(鉴权层拦下)而不是 `404`(漏注册);
|
||||
- 路径与 `docs/05` §19 的 P001 一致,避免注册漂移。
|
||||
|
||||
⚠️ **"访客令牌能读到数据"不在本文件测** —— 那要连数据库,放在
|
||||
`tests/integration/test_public_products_endpoint_mysql.py`。这一条必须有人守:
|
||||
访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`),
|
||||
如果谁给 P001 加上权限码校验,整个访客产品页会 401,而前端只会显示
|
||||
"数据暂时不可用",很难联想到是权限口径问题。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
PRODUCTS_PATH = "/api/v1/products"
|
||||
|
||||
|
||||
async def send(method: str, path: str) -> httpx.Response:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.request(method, path)
|
||||
|
||||
|
||||
async def test_products_endpoint_is_registered_and_protected() -> None:
|
||||
"""路由必须存在;缺 token 时 401 而不是 404。"""
|
||||
response = await send("GET", PRODUCTS_PATH)
|
||||
assert response.status_code == 401, (
|
||||
f"GET {PRODUCTS_PATH} 未授权应 401,实际 {response.status_code}(路由可能漏注册)"
|
||||
)
|
||||
|
||||
|
||||
async def test_products_endpoint_uses_documented_error_envelope() -> None:
|
||||
"""未授权响应要用统一错误信封,调用方能按同一套结构解析。"""
|
||||
response = await send("GET", PRODUCTS_PATH)
|
||||
payload = response.json()
|
||||
assert payload["error"]["code"] == "AUTHENTICATION_REQUIRED"
|
||||
assert "meta" in payload
|
||||
|
||||
|
||||
async def test_invalid_token_is_rejected() -> None:
|
||||
"""伪造令牌同样是 401,不能因为"只要求令牌"就放行。"""
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
PRODUCTS_PATH, headers={"Authorization": "Bearer not-a-real-token"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"])
|
||||
async def test_products_endpoint_is_read_only(method: str) -> None:
|
||||
"""只读端点:写方法不应落到同一个路径上(产品数据只能由同步/种子流程写入)。"""
|
||||
response = await send(method, PRODUCTS_PATH)
|
||||
assert response.status_code in (401, 405), (
|
||||
f"{method} {PRODUCTS_PATH} -> {response.status_code},只读端点不应接受写方法"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""真实 MySQL:访客令牌必须能读到公开产品列表。
|
||||
|
||||
## 为什么单独守这一条
|
||||
|
||||
访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`),
|
||||
P001 靠的是"要求令牌但不校验权限"的口径。若有人照管理面接口的样子给它加上权限码,
|
||||
访客产品页(首页推荐、产品列表、产品详情)会**整体 401**,
|
||||
而前端只会显示"数据暂时不可用",极难联想到是权限口径问题。
|
||||
|
||||
本文件同时守住**载荷边界**:公开端点不得混入任何账户/客户字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: 公开端点绝不能出现的字段(账户与客户数据)
|
||||
FORBIDDEN_FIELDS = {
|
||||
"customer_id", "account_id", "trade_account", "available_cash",
|
||||
"total_asset", "total_quantity", "average_cost", "cost_amount",
|
||||
}
|
||||
|
||||
#: 前端三个访客页面直接消费的字段
|
||||
REQUIRED_FIELDS = {
|
||||
"product_code", "product_name", "exchange_code", "product_category", "risk_level",
|
||||
"fund_manager", "current_nav", "current_nav_at", "status", "lot_size", "price_tick",
|
||||
"management_fee_rate", "custodian_fee_rate", "latest_close", "latest_trade_date",
|
||||
"quote_source", "change_pct",
|
||||
}
|
||||
|
||||
|
||||
async def test_visitor_token_can_read_listed_products() -> None:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=30
|
||||
) as client:
|
||||
issued = await client.post("/api/v1/visitor-tokens")
|
||||
# 该端点按创建语义返回 201(本平台创建类端点的统一口径)
|
||||
assert issued.status_code in (200, 201), issued.text
|
||||
# 访客令牌端点是 `raw` 形状:令牌直接在顶层,没有 data 信封
|
||||
token = issued.json()["access_token"]
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/products", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()["data"]
|
||||
products = data["products"]
|
||||
|
||||
assert products, "产品列表为空:访客页面会显示不出任何基金"
|
||||
assert data["count"] == len(products)
|
||||
|
||||
# 只暴露在售产品
|
||||
assert all(item["status"] == "上市" for item in products)
|
||||
|
||||
first = products[0]
|
||||
assert REQUIRED_FIELDS <= set(first), f"缺字段:{REQUIRED_FIELDS - set(first)}"
|
||||
assert not (FORBIDDEN_FIELDS & set(first)), "公开端点混入了账户/客户字段"
|
||||
|
||||
# change_pct 只能是数字或 None。None 表示行情不足两个交易日、算不出涨跌,
|
||||
# **不是 0** —— 前端对 None 显示"暂无",对 0 会显示 "+0.00%"(等于说今天平盘)。
|
||||
for item in products:
|
||||
assert item["change_pct"] is None or isinstance(item["change_pct"], (int, float)), (
|
||||
f"{item['product_code']} 的 change_pct 类型异常:{item['change_pct']!r}"
|
||||
)
|
||||
|
||||
# 价格类字段一律是字符串(与既有接口口径一致)
|
||||
assert isinstance(first["current_nav"], str)
|
||||
Reference in New Issue
Block a user