Files
group_xinghuo_jinrong/tests/test_convert_integration.py
T
GaoYiYuan_0626 d0097d6004 基金转换 T-9:API 模型 + 网关分派(HTTP 层 convert 走通)+ 展示位数口径修复
一、T-9 本体:HTTP 层 convert 端到端走通

- api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount;
  convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验;
  未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True);
  PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError
  (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。
- gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派
  (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。
- utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots);
  既有 ApiError 无 extra 属性 → 老错误体逐字节不变。
- utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份
  内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py
  成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。

二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正)

发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。
根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的
回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。

修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 ——
金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口;
原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。

依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」;
「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位);
费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径
并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。

三、文档订正

- PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据);
  §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5
  「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。
- 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。

四、验证

- 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离):
  折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、
  幂等重试不产生第二组、跨主体 400、未知类型 400,以及
  「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。
- test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。
- test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为
  test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。
- pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。
- 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。
- 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条;
  关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红
  (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
2026-09-10 18:00:20 +08:00

487 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T-9 真 MySQL 端到端集成测试(架构 §8/§10 · 开发计划 §7.2 DoD · §12 R15 迁入)。
链路:`TestClient(main app)` → HTTP → `trade_gateway` 分派 → `convert_service`
八步编排 → 真 `jinrong_core` / `jinrong_agent`。
**为什么必须有这一层**:单测层只验到「路由层不加工、原样透传」(见
`test_trade_gateway.py`),而折算数字是否与 PRD §5.3 逐项吻合、两条流水是否
真的同组同事务、持仓/批次是否真的扣减——只有真库能证明。
隔离策略(架构 §10)——**三条同时成立,缺一即污染种子**:
1. **id 前缀**:`CNV-TEST-` / `TRD-TEST-`(monkeypatch `convert_service._new_id`);
2. **数据自建**:客户/产品/费率/净值/批次全部 `CNVTEST` 前缀自建,**绝不碰种子**——
`risk_demo_env` 的 teardown 只清 `TRD-TEST-` 前缀与时间窗,**不还原
`core_share_lot`/`core_holding`**;若借种子客户跑转换,扣掉的份额会跨用例污染
`test_integration_risk.py`;
3. **teardown 全清**:函数级 fixture 按前缀删两库全部自建行(幂等,seed 失败亦可清)。
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from conftest import ensure_risk_demo_ready
from app.gateway import trade_gateway # noqa: E402
from app.main import app # noqa: E402
from app.service.convert import convert_service as cs # noqa: E402
from app.service.convert.calc import ( # noqa: E402
convert_amount,
diff_fee,
in_qty,
lot_amount,
lot_fee,
)
from app.service.risk import redis_gateway # noqa: E402
ensure_risk_demo_ready()
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-DEMO"}
# ── 隔离种子常量(全部带 CNVTEST 前缀)──────────────────────────────
CUSTOMER = "CUST-CNVTEST"
PROD_OUT = "PROD-CNVTESTO" # 债基,申购费率 0.0030
PROD_IN = "PROD-CNVTESTI" # 股基,申购费率 0.0080(**高于**转出端 → 补差费非零)
PROD_CROSS = "PROD-161725" # 真库既有:易方模拟基金 / TA-CN-002(跨主体负例)
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
OUT_NAV = Decimal("1.0300")
IN_NAV = Decimal("0.9500")
FEE_TIERS = [
(0, 7, "0.0150"),
(7, 30, "0.0100"),
(30, 180, "0.0050"),
(180, 365, "0.0025"),
(365, None, "0.0000"),
]
#: PRD §5.3 场景:两批次 30000 份持 100 天 + 20000 份持 3 天 → 费用档 0.0050 / 0.0150
LOT_SPEC = [("LOT-CNVTEST-A1", "30000", 100), ("LOT-CNVTEST-A2", "20000", 3)]
TOTAL_QTY = sum(Decimal(q) for _, q, _ in LOT_SPEC)
_GROUP_LIKE = "CNV-TEST-%"
class FakePub:
def __init__(self) -> None:
self.messages: list = []
self.deletes: list = []
def publish(self, channel, payload):
self.messages.append((channel, payload))
def delete(self, *keys):
self.deletes.append(keys)
def _test_new_id(prefix: str, now: datetime) -> str:
"""`_new_id` 替换:`CNV-TEST-xxxx` / `TRD-TEST-xxxx`(架构 §10 前缀约定)。"""
return f"{prefix}-TEST-{uuid4().hex[:8].upper()}"
# ── 种子与清理 ──────────────────────────────────────────────────────
def _seed(core) -> None:
"""自建 PRD §5.3 场景(客户 + 双产品 + 费率 + 净值 + 双批次 + 持仓)。"""
today = date.today()
base = datetime.combine(today, time(10, 0)) # 固定钟点 → hold_days 恒等于 LOT_SPEC 的天数
with core.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date)"
" VALUES (:c, 'T9集成测试', :d)"
),
{"c": CUSTOMER, "d": today},
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)"
" VALUES (:c, 'C5', :t, :exp)"
),
{"c": CUSTOMER, "t": base - timedelta(days=30), "exp": base + timedelta(days=300)},
)
for pid, name, ptype, rate in [
(PROD_OUT, "T9转出基金", "bond", OUT_RATE),
(PROD_IN, "T9转入基金", "stock", IN_RATE),
]:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type, can_subscribe, can_redeem, subscribe_fee_rate,"
" fund_company, ta_code)"
" VALUES (:p, :n, 'R2', :t, 1, 1, :r, :co, :ta)"
),
{"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA},
)
for mh, mh_max, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days,"
" max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mm, :r)"
),
{"p": PROD_OUT, "mh": mh, "mm": mh_max, "r": rate},
)
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)"
" VALUES (:p, :n, 0, :d)"
),
{"p": PROD_IN, "n": str(IN_NAV), "d": today},
)
for lot_id, qty, days in LOT_SPEC:
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty,"
" remain_qty, nav, confirmed_at) VALUES (:l, :c, :p, :q, :q, :n, :cat)"
),
{
"l": lot_id,
"c": CUSTOMER,
"p": PROD_OUT,
"q": qty,
"n": str(OUT_NAV),
"cat": base - timedelta(days=days),
},
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount,"
" market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :mv, 0, :d)"
),
{
"c": CUSTOMER,
"p": PROD_OUT,
"q": str(TOTAL_QTY),
"cost": str(TOTAL_QTY * OUT_NAV),
"mv": str(TOTAL_QTY * OUT_NAV),
"d": today,
},
)
def _cleanup(core, agent) -> None:
"""按前缀清两库全部自建行(幂等)。"""
core_sqls = [
f"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'",
"DELETE FROM core_trade WHERE customer_id = :c",
"DELETE FROM core_share_lot WHERE customer_id = :c",
"DELETE FROM core_holding WHERE customer_id = :c",
"DELETE FROM core_customer_risk WHERE customer_id = :c",
"DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-CNVTEST%'",
"DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-CNVTEST%'",
"DELETE FROM core_product WHERE product_id LIKE 'PROD-CNVTEST%'",
"DELETE FROM core_customer WHERE customer_id = :c",
]
with core.begin() as conn:
for sql in core_sqls:
conn.execute(text(sql), {"c": CUSTOMER})
with agent.begin() as conn:
conn.execute(text(f"DELETE FROM risk_convert_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'"))
conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM risk_suitability_log WHERE customer_id = :c"), {"c": CUSTOMER})
conn.execute(text("DELETE FROM audit_log WHERE customer_id = :c"), {"c": CUSTOMER})
@pytest.fixture()
def conv_env(risk_demo_env, monkeypatch):
"""自建隔离种子 + 注入 TEST 前缀 id 工厂 + 函数级全清。"""
core = risk_demo_env["core"]
agent = risk_demo_env["agent"]
monkeypatch.setattr(cs, "_new_id", _test_new_id)
monkeypatch.setattr(trade_gateway, "_new_trade_id", lambda now: _test_new_id("TRD", now))
_cleanup(core, agent) # 先清后建:上一轮异常退出也能自愈
_seed(core)
try:
yield {"core": core, "agent": agent}
finally:
_cleanup(core, agent)
@pytest.fixture()
def client(conv_env, monkeypatch):
fake = FakePub()
with TestClient(app) as c:
monkeypatch.setattr(redis_gateway, "_gateway", fake)
yield c
def _body(qty="50000", cid=None, frm=PROD_OUT, to=PROD_IN):
payload = {
"customer_id": CUSTOMER,
"trade_type": "convert",
"from_product_id": frm,
"to_product_id": to,
"qty": qty,
}
if cid:
payload["client_request_id"] = cid
return payload
def _one(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).mappings().first()
def _n(engine, sql: str, **params) -> int:
with engine.connect() as conn:
return conn.execute(text(sql), params).scalar_one()
# ── 1. 端到端:与 PRD §5.3 示例逐项吻合 ─────────────────────────────
def test_convert_end_to_end_matches_prd_5_3(client, conv_env):
"""跨两批次转换走通,四段金额与 PRD §5.3 示例**逐项吻合**。
期望值一律**调生产纯函数**得出(禁手算 · 自检第 13 问),再与 PRD 示例数字对表。
"""
r = client.post("/api/simulate/trade", json=_body(), headers=DEMO)
assert r.status_code == 200, r.text
body = r.json()
assert body["blocked"] is False
assert body["estimated"] is True # T 日未知价法
assert body["lot_count"] == 2
assert body["confirm_basis"] == "natural_day_approx"
by_days = {b["hold_days"]: b for b in body["lot_breakdown"]}
assert set(by_days) == {100, 3}, "两批次持有期应为 100 / 3 天"
exp_out = Decimal("0")
exp_fee = Decimal("0")
for days, rate in ((100, Decimal("0.0050")), (3, Decimal("0.0150"))):
leg = by_days[days]
assert Decimal(leg["fee_rate"]) == rate, f"持有 {days} 天的费率档"
leg_amount = lot_amount(Decimal(leg["qty"]), Decimal(leg["nav"]))
leg_fee = lot_fee(leg_amount, rate)
assert Decimal(leg["fee_amount"]) == leg_fee, f"持有 {days} 天的逐批费用"
exp_out += leg_amount
exp_fee += leg_fee # 逐批舍入后求和(PRD §2.5)
exp_conv = convert_amount(exp_out, exp_fee)
exp_diff = diff_fee(exp_conv, OUT_RATE, IN_RATE)
exp_in_amount = convert_amount(exp_conv, exp_diff)
assert Decimal(body["out_amount"]) == exp_out
assert Decimal(body["redeem_fee"]) == exp_fee
assert Decimal(body["convert_amount"]) == exp_conv
assert Decimal(body["diff_fee"]) == exp_diff
assert Decimal(body["in_amount"]) == exp_in_amount
assert Decimal(body["in_qty"]) == in_qty(exp_in_amount, IN_NAV)
# 与 PRD §5.3 示例数字对表(该示例由 calc_convert_demo.py 实算回填)
assert (exp_out, exp_fee, exp_conv, exp_diff, exp_in_amount) == (
Decimal("51500.00"),
Decimal("463.50"),
Decimal("51036.50"),
Decimal("252.40"),
Decimal("50784.10"),
)
assert Decimal(body["in_qty"]) == Decimal("53456.95")
# 转入端费率高于转出端 → 补差费应 > 0(验收 19 的非零场景)
assert exp_diff > 0
# ── 2. 两条流水同组、id 前缀正确 ────────────────────────────────────
def test_convert_writes_two_trades_in_same_group(client, conv_env):
core = conv_env["core"]
gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"]
assert gid.startswith("CNV-TEST-")
rows = {
row["trade_type"]: row
for row in [
dict(r)
for r in _all(
core,
"SELECT trade_type, trade_id, amount, convert_group_id FROM core_trade"
" WHERE customer_id = :c",
c=CUSTOMER,
)
]
}
assert set(rows) == {"redeem", "subscribe"}, "一次转换落两条流水(R-b)"
assert {r["convert_group_id"] for r in rows.values()} == {gid}
assert all(r["trade_id"].startswith("TRD-TEST-") for r in rows.values())
# 转出端金额 = 原始转出额(未扣费)
assert Decimal(str(rows["redeem"]["amount"])) == Decimal("51500.00")
def _all(engine, sql: str, **params):
with engine.connect() as conn:
return conn.execute(text(sql), params).mappings().all()
# ── 3. 持仓与批次如实变动 ───────────────────────────────────────────
def test_convert_updates_holding_and_lots(client, conv_env):
core = conv_env["core"]
client.post("/api/simulate/trade", json=_body(), headers=DEMO)
# 转出端批次:两批 remain_qty 归零(全部转出)—— 必须限定 product_id,
# 否则会把**转入端新建批次**算进同一客户的聚合(首版断言即栽在这里)
remain = _one(
core,
"SELECT SUM(remain_qty) AS s FROM core_share_lot"
" WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER,
p=PROD_OUT,
)
assert Decimal(str(remain["s"])) == Decimal("0.0000")
# 转出端持仓归零但**行保留**(D9/P2)
out_holding = _one(
core,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER,
p=PROD_OUT,
)
assert out_holding is not None, "转出归零保留行(不可 DELETE)"
assert Decimal(str(out_holding["qty"])) == Decimal("0.0000")
# 转入端持仓新建且份额 = 响应 in_qty
in_holding = _one(
core,
"SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER,
p=PROD_IN,
)
assert in_holding is not None
assert Decimal(str(in_holding["qty"])) == Decimal("53456.95")
# ── 4. 阶段二:明细 completed + 审计落库 ────────────────────────────
def test_convert_detail_completed_and_audited(client, conv_env):
agent = conv_env["agent"]
gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"]
detail = _one(
agent,
"SELECT status, fee_amount FROM risk_convert_detail WHERE convert_group_id = :g",
g=gid,
)
assert detail["status"] == "completed"
assert Decimal(str(detail["fee_amount"])) == Decimal("463.50")
# 审计以 convert_group_id 为关联主键(L-2:一次转换 = 一条主审计)
n_main = _n(
agent,
"SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND decision = 'convert_accepted'",
c=CUSTOMER,
)
assert n_main == 1
# 网关不重复写 trade_request(T-9:审计归 convert_service)
assert _n(
agent,
"SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND event_type = 'trade_request'",
c=CUSTOMER,
) == 0
# ── 5. 幂等:同键重试不产生第二组流水 ───────────────────────────────
#: 首发(走 calc 纯函数)与重放(按 core_trade/core_convert_lot_detail 重建)
#: 必须**逐字节相同**的字段。这份清单是「同一响应、两条路径」的一致性契约。
REPLAY_IDENTICAL_FIELDS = (
"requested_qty", "actual_qty",
"out_nav", "out_amount", "redeem_fee",
"in_nav", "convert_amount", "diff_fee", "in_amount", "in_qty",
"rounding_diff", "out_subscribe_fee_rate", "in_subscribe_fee_rate",
)
def test_convert_idempotent_retry_returns_byte_identical_response(client, conv_env):
"""带 `client_request_id` 重试 → 命中幂等、**响应与首次逐字节相同**。
这是「展示位数」的回归闸门:重放值由 `DECIMAL(18,4)` 重建,若不经理发展示规格
收敛,同一字段会吐出 `53456.9500`(4 位)而首次是 `53456.95`(2 位)——
数值相等但字符串不等,前端/对账按字符串比对即误判为两笔。
"""
core = conv_env["core"]
first = client.post(
"/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO
).json()
second = client.post(
"/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO
).json()
assert second["convert_group_id"] == first["convert_group_id"]
for field in REPLAY_IDENTICAL_FIELDS:
assert second[field] == first[field], f"重放 {field} 应与首次**逐字节**相同"
# 逐批明细同样逐字段一致(qty / nav / fee_rate 的位数也是展示规格的一部分)
assert second["lot_breakdown"] == first["lot_breakdown"]
assert _n(core, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 2
assert _n(
core,
"SELECT COUNT(*) FROM core_share_lot"
" WHERE customer_id = :c AND product_id = :p AND remain_qty > 0",
c=CUSTOMER,
p=PROD_OUT,
) == 0, "重试不得再扣一次转出端份额"
assert _n(
core,
"SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p",
c=CUSTOMER,
p=PROD_IN,
) == 1, "重试不得再建一条转入端批次"
# ── 5b. 展示位数规格(真实公告口径,见 convert_service._q)─────────────
def test_convert_response_field_scales(client, conv_env):
"""响应的**展示位数**按字段分类收敛(真实公告口径),不随数据源漂移。
真实依据:金额/份额「四舍五入保留至小数点后两位」;净值保留 4 位、
第 5 位四舍五入;申购费率以百分比 2 位表示(小数形式即 4 位)。
"""
body = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()
def scale(key: str) -> int:
return len(body[key].split(".")[1])
# 金额 / 份额 → 2 位
for key in ("out_amount", "redeem_fee", "convert_amount", "diff_fee", "in_amount",
"in_qty", "requested_qty", "actual_qty"):
assert scale(key) == 2, f"{key} 应为金额/份额口径(2 位),实际 {body[key]}"
# 净值 / 费率 / 尾差 → 4 位
for key in ("out_nav", "in_nav", "out_subscribe_fee_rate",
"in_subscribe_fee_rate", "rounding_diff"):
assert scale(key) == 4, f"{key} 应为净值/费率口径(4 位),实际 {body[key]}"
for leg in body["lot_breakdown"]:
assert len(leg["qty"].split(".")[1]) == 2, f"逐批份额 2 位,实际 {leg['qty']}"
assert len(leg["fee_amount"].split(".")[1]) == 2, f"逐批费用 2 位,实际 {leg['fee_amount']}"
assert len(leg["nav"].split(".")[1]) == 4, f"逐批净值 4 位,实际 {leg['nav']}"
assert len(leg["fee_rate"].split(".")[1]) == 4, f"逐批费率 4 位,实际 {leg['fee_rate']}"
# 申请份额即使**不带小数**传入,也必须补齐到 2 位(不能回显成 "50000")
assert body["requested_qty"] == "50000.00"
# ── 6. 跨主体拒绝(验收 14)─────────────────────────────────────────
def test_convert_cross_entity_rejected(client, conv_env):
"""转出华夏/TA-CN-001 → 转入易方/TA-CN-002 → 400 `CROSS_ENTITY_NOT_SUPPORTED`。"""
r = client.post("/api/simulate/trade", json=_body(to=PROD_CROSS), headers=DEMO)
assert r.status_code == 400
body = r.json()
assert body["error_code"] == "CROSS_ENTITY_NOT_SUPPORTED"
assert body["trace_id"]
# 校验失败不留任何流水(4xx 不落库)
assert _n(conv_env["core"], "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 0
# ── 7. 未知类型仍 400(R3 的集成侧对应)─────────────────────────────
def test_unknown_trade_type_returns_400_over_http(client):
"""走 HTTP 的未知类型仍是 400(`TradeRequest` 分支校验**放行**未知类型给网关兜底拒绝)。"""
r = client.post(
"/api/simulate/trade",
json={"customer_id": CUSTOMER, "trade_type": "purchase", "amount": "1000"},
headers=DEMO,
)
assert r.status_code == 400
assert r.json()["error_code"] == "BAD_REQUEST"