Files
group_fqcd_jr/tests/contract/test_trading_endpoint_contract.py
T
张胜宇 ebc3fe4cbe feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)
新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
2026-09-12 15:50:37 +08:00

94 lines
3.5 KiB
Python

"""T 段(账户看板 + 场内模拟交易)Controller 路由契约测试。
不连数据库。覆盖:
- 9 个端点路由**已注册**且未授权时不能到达业务逻辑(401 / 422);
- 端点路径和 HTTP 方法匹配 §19 文档(避免注册漂移)。
受保护路由加入 `tests/unit/api/test_controller_routing_contract.py` 同款断言。
"""
from __future__ import annotations
import httpx
import pytest
from app.main import create_app
# ---- 路由清单(与 docs/05 §19 T 段一一对应) ----
T_GET_LIST = [
"/api/v1/users/me/account/dashboard", # T001
"/api/v1/users/me/orders", # T003
"/api/v1/users/me/holdings", # T006
"/api/v1/users/me/transactions", # T007
"/api/v1/users/me/cash-ledger", # T009
]
T_GET_DETAIL = [
"/api/v1/users/me/orders/SO2026010100000000000", # T004
"/api/v1/users/me/transactions/TX2026010100000000000", # T008
]
T_POST = [
"/api/v1/users/me/orders", # T002
"/api/v1/users/me/orders/SO2026010100000000000/cancellations", # T005
]
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)
@pytest.mark.parametrize("path", T_GET_LIST)
async def test_t_list_endpoint_is_registered_and_protected(path: str) -> None:
"""T 段列表端点路由必须存在;缺 token 时 401 而不是 404。"""
response = await send("GET", path)
assert response.status_code == 401, (
f"GET {path} 未授权应 401,实际 {response.status_code}(路由可能漏注册)"
)
@pytest.mark.parametrize("path", T_GET_DETAIL)
async def test_t_detail_endpoint_is_registered_and_protected(path: str) -> None:
response = await send("GET", path)
assert response.status_code == 401, f"GET {path} -> {response.status_code}"
@pytest.mark.parametrize("path", T_POST)
async def test_t_post_endpoint_is_registered_and_protected(path: str) -> None:
"""T 段 POST / POST * 端点:未带令牌永远不能成功(401/422 之一)。"""
response = await send("POST", path)
assert response.status_code in {401, 422}, f"POST {path} -> {response.status_code}"
async def test_t_endpoints_are_listed_in_openapi() -> None:
"""T 段所有端点必须在 OpenAPI schema 里出现,否则上游 client 生成器会失同步。"""
app = create_app()
paths = app.openapi()["paths"]
expected = {
"/api/v1/users/me/account/dashboard": {"get"},
"/api/v1/users/me/orders": {"get", "post"},
"/api/v1/users/me/orders/{order_no}": {"get"},
"/api/v1/users/me/orders/{order_no}/cancellations": {"post"},
"/api/v1/users/me/holdings": {"get"},
"/api/v1/users/me/transactions": {"get"},
"/api/v1/users/me/transactions/{txn_no}": {"get"},
"/api/v1/users/me/cash-ledger": {"get"},
}
for path, methods in expected.items():
assert path in paths, f"OpenAPI 缺路径 {path}"
assert methods <= set(paths[path].keys()), (
f"OpenAPI {path} 方法集合应为 {methods},实际 {set(paths[path].keys())}"
)
async def test_t_envelope_shape_is_preserved() -> None:
"""T 段响应必须使用 §3.3 信封 ``{data, meta}``。
即便未授权也保持信封一致性以便客户端统一处理。
"""
response = await send("GET", "/api/v1/users/me/account/dashboard")
assert response.status_code == 401