FR-C16 份额批次全生命周期 + FR-C25 T+2 可赎回 + redeem 份额申报 D26 落地普通赎回侧: - trade_gateway:_redeem_quote 走 available_qty_with_inflight 硬校验(R-3, 申报超可赎抛 InsufficientShares 不静默裁剪);_maintain_lots redeem 分支 三重裁剪 = T+2 过滤 → D8 补建 → 在途占用 → 哨兵 FIFO 扣减;subscribe 落新批次 - core_ro.list_share_lots / share_lot_repository.select_for_convert 加 available_from T+2 半开区间过滤(R-4,日历缺行降级不过滤,金额/扣减同口径) - gateway_repository 扣批次加哨兵 remain_qty >= :q(rowcount!=1 回滚防超扣) 验证:test_share_lot +10 / test_trade_gateway +1 → 全量 844 passed / 10 skipped (基线 834 + 11 零回归);真库 verify_convert_api 89/89(H 节提前 + H2 在途 占用拦截断言);rebuild_lots --dry-run 58 持仓 0 补建 0 写入;突防验证区分度成立
627 lines
25 KiB
Python
627 lines
25 KiB
Python
"""trade_gateway 集成测试(B5 · FR-1:convert 400 / 阻断不落 trade / 放行贯通引擎)。
|
||
|
||
服务层直测三路径 + TestClient 验 HTTP 语义(sqlite 全套表,驱动差异由引擎层
|
||
_normalize_trades 兜底)。API 层经 monkeypatch 注入 sqlite 仓储。
|
||
"""
|
||
|
||
from datetime import date, datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine, seed_suitability_matrix
|
||
from app.api.simulate import router as simulate_router
|
||
from app.gateway import trade_gateway as tg
|
||
from app.gateway.gateway_repository import GatewayRepository
|
||
from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.convert.convert_service import PROCESSING
|
||
from app.service.convert.errors import (
|
||
BelowMinQty,
|
||
CrossEntityNotSupported,
|
||
FeeRuleMissing,
|
||
IdempotencyUnavailable,
|
||
InsufficientShares,
|
||
LotConflict,
|
||
NavNotReady,
|
||
ProductNotRedeemable,
|
||
ProductNotSubscribable,
|
||
SameProduct,
|
||
TooManyLots,
|
||
)
|
||
from app.service.risk import alert_service
|
||
from app.service.risk.profile_l3 import AML_PENDING_TAG
|
||
from app.utils.response import register_error_handlers
|
||
|
||
|
||
class FakePublisher:
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
|
||
@pytest.fixture()
|
||
def env():
|
||
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
||
seed_suitability_matrix(engine) # AL-05:check_suitability 以矩阵表为 L0 权威
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
|
||
" ('CUST-1001', '客户·王**', 28, 1), ('CUST-3001', '客户·孙**', 45, 1)"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) VALUES"
|
||
" ('CUST-1001', 'C1', :t, :exp), ('CUST-3001', 'C3', :t, :exp)"
|
||
),
|
||
{"t": datetime.now() - timedelta(days=30), "exp": datetime.now() + timedelta(days=300)},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type) VALUES"
|
||
" ('PROD-161725', '科技成长主题', 'R4', 'mixed'),"
|
||
" ('PROD-510300', '沪深300指数', 'R3', 'index')"
|
||
)
|
||
)
|
||
core = CoreReadOnlyRepository(engine=engine)
|
||
repo = RiskRepository(engine=engine)
|
||
writer = GatewayRepository(engine=engine)
|
||
pub = FakePublisher()
|
||
alert_service.set_publisher(pub)
|
||
yield core, repo, writer, pub, engine
|
||
alert_service.set_publisher(None)
|
||
engine.dispose()
|
||
|
||
|
||
def _req(
|
||
customer="CUST-1001",
|
||
product="PROD-161725",
|
||
ttype="subscribe",
|
||
amount=None,
|
||
qty=None,
|
||
):
|
||
"""构造交易请求(字段按申报方式分池 · T-9/D26)。
|
||
|
||
- `subscribe` → `amount`(**金额申购**);
|
||
- `redeem` → `qty`(**份额赎回**,D26/R-6;不再用 amount 反算);
|
||
- 缺省(不传 amount / qty 且 ttype=subscribe)给 `amount="100000"`,
|
||
保持既有 subscribe 调用零改动。
|
||
"""
|
||
if amount is None and qty is None and ttype == "subscribe":
|
||
amount = "100000"
|
||
req = {
|
||
"customer_id": customer,
|
||
"product_id": product,
|
||
"trade_type": ttype,
|
||
}
|
||
if amount is not None:
|
||
req["amount"] = amount
|
||
if qty is not None:
|
||
req["qty"] = qty
|
||
return req
|
||
|
||
|
||
def _convert_req(
|
||
customer="CUST-3001",
|
||
frm="PROD-510300",
|
||
to="PROD-161725",
|
||
qty="1000",
|
||
**extra,
|
||
):
|
||
"""convert 请求体(T-9 字段池):from/to/qty 三件套 + 可选幂等键。"""
|
||
return {
|
||
"customer_id": customer,
|
||
"trade_type": "convert",
|
||
"from_product_id": frm,
|
||
"to_product_id": to,
|
||
"qty": qty,
|
||
**extra,
|
||
}
|
||
|
||
|
||
def _counts(engine, table, where="1=1"):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one()
|
||
|
||
|
||
# ---------- 服务层 ----------
|
||
|
||
|
||
def test_unknown_trade_type_rejected(env):
|
||
"""§12 **R3 拆分后**:未知类型仍 400;convert 不再拒绝(改由下方 API 层覆盖)。
|
||
|
||
保留原 `test_convert_rejected` 的两条断言:不落 `core_trade`、
|
||
不落 `invalid_type` 审计(PRD 审计口径仅阻断/放行)。
|
||
"""
|
||
core, repo, writer, _, _ = env
|
||
with pytest.raises(UnsupportedTradeType, match="不支持的交易类型"):
|
||
submit_trade(_req(ttype="purchase"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
assert _counts(writer._engine, "core_trade") == 0
|
||
assert _counts(writer._engine, "audit_log", "decision='invalid_type'") == 0
|
||
|
||
|
||
def test_blocked_does_not_touch_core_trade(env):
|
||
"""A-1:C1 客户买 R4 → blocked=true(SUIT_RISK_MISMATCH)、不落 core_trade、日志/预警/审计齐全。"""
|
||
core, repo, writer, pub, engine = env
|
||
resp = submit_trade(_req(), core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0))
|
||
assert resp["blocked"] is True
|
||
assert resp["block_response_code"] == "SUIT_RISK_MISMATCH" # main 契约机器码(SUIT-001 退役)
|
||
assert resp["advice"] == "请联系持证投资顾问" and resp["notice"] == "本次请求已记录"
|
||
assert _counts(engine, "core_trade") == 0 # 阻断不落交易
|
||
assert _counts(engine, "risk_suitability_log", "is_blocked=1 AND request_ref='" + resp["trade_id"] + "'") == 1
|
||
assert _counts(engine, "risk_alert", "alert_type='suitability'") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='suitability_blocked'") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 0
|
||
assert len(pub.messages) == 1 # suitability 预警推送
|
||
|
||
|
||
def test_accepted_trades_and_engine_fires(env):
|
||
"""A-3:C3 客户 60 万买 R3 → 落库 confirmed + 事件预警单(RISK-001/002) + 审计放行。"""
|
||
core, repo, writer, pub, engine = env
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False
|
||
assert resp["trade_id"].startswith("TRD-20260906-")
|
||
assert resp["triggered_rules"] == ["RISK-001", "RISK-002"] # 含本笔累计
|
||
assert len(resp["alert_ids"]) == 1
|
||
with engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT trade_status, amount FROM core_trade WHERE trade_id=:t"),
|
||
{"t": resp["trade_id"]},
|
||
).mappings().one()
|
||
assert row["trade_status"] == "confirmed" and Decimal(str(row["amount"])) == Decimal("600000")
|
||
alert = repo.get_alert(resp["alert_ids"][0])
|
||
assert alert["alert_type"] == "large_amount" and alert["risk_score"] == 70
|
||
assert alert["status"] == "pending_review" # 评审 P3-3 加固
|
||
assert _counts(engine, "risk_suitability_log", "is_blocked=0") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 1
|
||
assert _counts(engine, "customer_profile_l3", "monitor_tier='watch'") == 1
|
||
(channel, payload), = pub.messages
|
||
assert channel == "risk:pub:alert" and payload["risk_score"] == 70
|
||
|
||
|
||
def test_redeem_accepted_without_alert(env):
|
||
"""redeem 正向路径(评审 P3-3):小额赎回放行,无预警。
|
||
|
||
T-9/D26 变更:赎回改**份额申报**(`qty`),金额由网关按 `qty × 净值 − 赎回费`
|
||
反算 → 需 T 日净值种子(v1.0 金额由调用方传入、不依赖净值)。
|
||
"""
|
||
core, repo, writer, pub, engine = env
|
||
_seed_nav(engine, "PROD-510300", "1.0000", date(2026, 9, 6))
|
||
_seed_fee_rule(engine, "PROD-510300")
|
||
# D26:赎回金额按 FIFO 逐批反算 → 需存在可扣批次
|
||
_seed_lot(engine, "LOT-RED-OK", "CUST-3001", "PROD-510300", "5000", "1.0000",
|
||
datetime(2026, 8, 1, 10, 0, 0))
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", qty="1000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False and resp["triggered_rules"] == []
|
||
assert _counts(engine, "core_trade", "trade_type='redeem'") == 1
|
||
assert _counts(engine, "risk_alert") == 0
|
||
|
||
|
||
def test_redeem_amount_formula_qty_times_nav_minus_fee(env):
|
||
"""**DoD 3(D26)**:普通赎回金额 = `qty × T 日净值 − 赎回费`(FIFO 逐批)。
|
||
|
||
单批 5000 份、净值 1.0000、费率 0.5%([0,NULL) 档):
|
||
金额 = 1000 × 1.0000 = 1000.00 · 费用 = 1000.00 × 0.005 = 5.00
|
||
core_trade.amount = 1000.00 − 5.00 = **995.00**
|
||
(`_redeem_quote` 复用 convert 转出端同一套纯函数:plan_lots → hold_days →
|
||
pick_fee_rate → lot_amount / lot_fee,见该函数 docstring。)
|
||
"""
|
||
core, repo, writer, _, engine = env
|
||
_seed_nav(engine, "PROD-510300", "1.0000", date(2026, 9, 6))
|
||
_seed_fee_rule(engine, "PROD-510300")
|
||
_seed_lot(engine, "LOT-RED-Q", "CUST-3001", "PROD-510300", "5000", "1.0000",
|
||
datetime(2026, 8, 1, 10, 0, 0)) # 持有 36 天 → 命中 [0,∞) 0.5% 档
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", qty="1000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False
|
||
with engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT amount FROM core_trade WHERE trade_id=:t"),
|
||
{"t": resp["trade_id"]},
|
||
).mappings().one()
|
||
assert Decimal(str(row["amount"])) == Decimal("995.00") # qty×净值 − 赎回费
|
||
|
||
|
||
# ---------- T-10:普通申赎批次维护(FR-C16 · R7 补断言) ----------
|
||
|
||
|
||
def _seed_fee_rule(engine, pid: str) -> None:
|
||
"""灌一档赎回费率(`[0, NULL)` = 0.5%)—— **T-9 起赎回金额反算需要费率档**。
|
||
|
||
D26 下赎回金额 = `qty × 净值 − 赎回费`,费按 FIFO 逐批查 `core_fee_rule` 分档;
|
||
`pick_fee_rate` 无命中即 `FeeRuleMissing`(**不降级为 0** —— 静默少收费比失败
|
||
危险,见 fee.py 设计说明)。v1.0 的 redeem 金额由调用方传入、不查费率表,
|
||
故本用例集此前无此种子。
|
||
"""
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_fee_rule "
|
||
"(product_id, fee_type, min_hold_days, max_hold_days, rate) "
|
||
"VALUES (:pid, 'redeem', 0, NULL, 0.0050)"
|
||
),
|
||
{"pid": pid},
|
||
)
|
||
|
||
|
||
def _seed_nav(engine, pid: str, nav: str, nav_date: date) -> None:
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||
"VALUES (:pid, :nav, 0, :nd)"
|
||
),
|
||
{"pid": pid, "nav": float(nav), "nd": nav_date},
|
||
)
|
||
|
||
|
||
def _seed_lot(engine, lot_id: str, cid: str, pid: str, remain: str, nav: str,
|
||
confirmed_at: datetime) -> None:
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, "
|
||
"remain_qty, nav, confirmed_at) "
|
||
"VALUES (:lot, :cid, :pid, :q, :q, :nav, :cat)"
|
||
),
|
||
{
|
||
"lot": lot_id, "cid": cid, "pid": pid, "q": float(remain),
|
||
"nav": float(nav), "cat": confirmed_at,
|
||
},
|
||
)
|
||
|
||
|
||
def _norm_dt(value: object) -> datetime:
|
||
if isinstance(value, datetime):
|
||
return value
|
||
return datetime.fromisoformat(str(value)[:19])
|
||
|
||
|
||
def test_subscribe_maintains_share_lot(env):
|
||
"""T-10(R7):申购放行后按 T 日净值建批次 —— 批次表由交易统一出入口维护。"""
|
||
core, repo, writer, _, engine = env
|
||
_seed_nav(engine, "PROD-510300", "1.2000", date(2026, 9, 6))
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", amount="12000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False
|
||
with engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT * FROM core_share_lot WHERE source_trade_id = :t"),
|
||
{"t": resp["trade_id"]},
|
||
).mappings().one()
|
||
assert row["lot_id"] == f"LOT-SUB-{resp['trade_id']}"
|
||
assert Decimal(str(row["qty"])) == Decimal("10000.00") # 12000 / 1.2
|
||
assert _norm_dt(row["confirmed_at"]) == datetime(2026, 9, 6, 14, 0, 0)
|
||
|
||
|
||
def test_redeem_deducts_share_lot_fifo(env):
|
||
"""T-10(R7):赎回放行后 FIFO 扣减既有批次(**T-9 起为份额申报** · D26)。"""
|
||
core, repo, writer, _, engine = env
|
||
_seed_nav(engine, "PROD-510300", "1.0000", date(2026, 9, 6))
|
||
_seed_fee_rule(engine, "PROD-510300") # D26:金额反算需费率档
|
||
_seed_lot(engine, "LOT-R16", "CUST-3001", "PROD-510300", "100", "1.0000",
|
||
datetime(2026, 8, 1, 10, 0, 0))
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", qty="50"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False
|
||
with engine.connect() as conn:
|
||
remain = conn.execute(
|
||
text("SELECT remain_qty FROM core_share_lot WHERE lot_id = 'LOT-R16'")
|
||
).scalar_one()
|
||
assert Decimal(str(remain)) == Decimal("50.00") # 100 − 50 份(份额直接申报,不再折算)
|
||
|
||
|
||
def test_blocked_trade_does_not_maintain_lots(env):
|
||
"""阻断路径不写批次:批次维护在放行分支内,适当性不匹配时两颗表都不动。"""
|
||
core, repo, writer, _, engine = env
|
||
_seed_nav(engine, "PROD-161725", "1.0000", date(2026, 9, 6))
|
||
resp = submit_trade(
|
||
_req(customer="CUST-1001", product="PROD-161725", amount="10000"), # C1 买 R4 → 阻断
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is True
|
||
assert _counts(engine, "core_share_lot") == 0
|
||
|
||
|
||
def test_engine_failure_is_audited_and_degraded(env):
|
||
"""评审 P1-1:引擎异常 → 审计 risk_engine_error + 响应 engine_error=true(交易已成立)。"""
|
||
core, repo, writer, pub, engine = env
|
||
|
||
class Boom(Exception):
|
||
pass
|
||
|
||
monkey_patch = lambda *a, **k: (_ for _ in ()).throw(Boom())
|
||
saved = tg.process_trade_event
|
||
tg.process_trade_event = monkey_patch
|
||
try:
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
finally:
|
||
tg.process_trade_event = saved
|
||
assert resp["blocked"] is False and resp["engine_error"] is True
|
||
assert _counts(engine, "core_trade") == 1 # 交易已成立
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='risk_engine_error'") == 1
|
||
assert _counts(engine, "risk_alert") == 0 # 引擎未跑,无预警
|
||
assert pub.messages == []
|
||
|
||
|
||
def test_missing_customer_returns_lookup_error(env):
|
||
"""AL-05 换核:NotFound 不再抛 LookupError,返回 forbidden/not_found 结构(main 契约)。"""
|
||
core, repo, writer, _, _ = env
|
||
resp = submit_trade(_req(customer="CUST-9999"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
assert resp["blocked"] is True
|
||
assert resp["block_response_code"] == "SUIT_NOT_FOUND"
|
||
|
||
|
||
# ---------- API 层(TestClient;仓储注入 sqlite) ----------
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(env, monkeypatch):
|
||
core, repo, writer, pub, engine = env
|
||
monkeypatch.setattr(tg, "CoreReadOnlyRepository", lambda: core)
|
||
monkeypatch.setattr(tg, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(tg, "GatewayRepository", lambda: writer)
|
||
# 401/越权审计经 deps/simulate 内仓储构造点,统一注入 sqlite(B6 评审 P3-4)
|
||
from app.api import deps as deps_mod
|
||
from app.api import simulate as simulate_mod
|
||
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(simulate_mod, "_repo", lambda: repo)
|
||
app = FastAPI()
|
||
register_error_handlers(app) # 统一错误体(手册 §10,与 main 同一 handler 集)
|
||
app.include_router(simulate_router)
|
||
with TestClient(app) as c:
|
||
yield c
|
||
|
||
|
||
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-90001"}
|
||
|
||
|
||
# ---------- convert 请求模型与错误码映射(T-9 · 架构 §8.1/§8.3) ----------
|
||
|
||
|
||
def test_api_convert_missing_leg_fields_returns_422(client):
|
||
"""§12 **R4 改写**:convert 缺 from/to/qty → 422(模型分支校验)。
|
||
|
||
完整「走通 → 200」路径下放到 `test_convert_integration.py`(真 MySQL)——
|
||
单测层造齐产品/持仓/费率/净值成本高且与集成测试重复(§12 R4 降级方案)。
|
||
"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json={"customer_id": "CUST-3001", "trade_type": "convert", "qty": "1000"},
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
assert r.json()["error_code"] == "REQUEST_VALIDATION_FAILED"
|
||
|
||
|
||
def test_api_convert_bad_client_request_id_returns_422(client):
|
||
"""幂等键白名单与 X-Trace-Id **共用同一份正则**(S4)→ 非法字符 422。"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_convert_req(client_request_id="bad id!"),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_api_subscribe_missing_amount_returns_422(client):
|
||
"""反向验证字段池互斥:subscribe 仅给 product_id(缺 amount)→ 422。"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json={
|
||
"customer_id": "CUST-3001",
|
||
"trade_type": "subscribe",
|
||
"product_id": "PROD-510300",
|
||
},
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
#: 架构 §8.3 全量映射(含验收 14 的 `CROSS_ENTITY_NOT_SUPPORTED`)
|
||
CONVERT_ERROR_MATRIX = [
|
||
(ProductNotRedeemable(), 400, "PRODUCT_NOT_REDEEMABLE"),
|
||
(ProductNotSubscribable(), 400, "PRODUCT_NOT_SUBSCRIBABLE"),
|
||
(InsufficientShares(), 400, "INSUFFICIENT_SHARES"),
|
||
(BelowMinQty(), 400, "BELOW_MIN_QTY"),
|
||
(SameProduct(), 400, "SAME_PRODUCT"),
|
||
(CrossEntityNotSupported(), 400, "CROSS_ENTITY_NOT_SUPPORTED"),
|
||
(TooManyLots(3, 200), 400, "TOO_MANY_LOTS"),
|
||
(NavNotReady(), 503, "NAV_NOT_READY"),
|
||
(LotConflict(), 409, "LOT_CONFLICT"),
|
||
(IdempotencyUnavailable(), 503, "IDEMPOTENCY_UNAVAILABLE"),
|
||
(FeeRuleMissing(), 500, "FEE_RULE_MISSING"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"exc,status,code",
|
||
CONVERT_ERROR_MATRIX,
|
||
ids=[e.error_code for e, _, _ in CONVERT_ERROR_MATRIX],
|
||
)
|
||
def test_convert_error_code_mapping(client, monkeypatch, exc, status, code):
|
||
"""每条 convert 异常 → HTTP 状态 + `error_code` 逐项对齐(架构 §8.3 表)。
|
||
|
||
做法:让网关分派目标(T-9 起为 `accept_convert`)抛该异常,验统一错误体出口 ——
|
||
convert 异常继承 `ApiError`,经 `register_error_handlers` 自动出体,
|
||
**路由层不逐个 except**。
|
||
"""
|
||
def _boom(*_a, **_k):
|
||
raise exc
|
||
|
||
monkeypatch.setattr(tg, "accept_convert", _boom)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == status
|
||
body = r.json()
|
||
assert body["error_code"] == code
|
||
assert body["trace_id"] and body["request_id"] # 统一错误体四要素仍在
|
||
|
||
|
||
def test_too_many_lots_body_carries_batch_count_and_max_lots(client, monkeypatch):
|
||
"""架构 §8.3:`TOO_MANY_LOTS` 错误体必须带 `batch_count`/`max_lots`。
|
||
|
||
本条同时锁住 `response.py` 的 `extra` 展开能力 —— 缺了它前端拿不到
|
||
「需跨 N 个批次、上限 200」的提示依据(执行期风险 #5 三重约束之一)。
|
||
"""
|
||
def _boom(*_a, **_k):
|
||
raise TooManyLots(250, 200)
|
||
|
||
monkeypatch.setattr(tg, "accept_convert", _boom)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 400
|
||
body = r.json()
|
||
assert body["error_code"] == "TOO_MANY_LOTS"
|
||
assert body["batch_count"] == 250
|
||
assert body["max_lots"] == 200
|
||
|
||
|
||
def test_api_convert_processing_returns_202(client, monkeypatch):
|
||
"""两类 202 之一:`status='processing'`(并发执行中)→ **202**。
|
||
|
||
⚠️ 该形态是 **v1.0 实时链路遗留**(「未抢到执行权」):T+1 受理/确认分离后
|
||
新链路不再产生 `processing`(受理段用锁 + `uk_idem` 让路,一律回 `accepted`)。
|
||
本用例保留,锁住 simulate.py 的 `processing → 202` 兼容分支不被误删。
|
||
"""
|
||
monkeypatch.setattr(
|
||
tg,
|
||
"accept_convert",
|
||
lambda *_a, **_k: {"status": PROCESSING, "convert_group_id": None},
|
||
)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 202
|
||
assert r.json()["status"] == "processing"
|
||
|
||
|
||
def test_api_convert_accepted_returns_202(client, monkeypatch):
|
||
"""两类 202 之二:**受理成功(PRD §5.3.1)→ 202** + 受理回执**逐字段透传**。
|
||
|
||
T-9 契约变更:该槽位原断言「200 + v1.0 折算响应(`in_qty` 等)」;受理/确认
|
||
分离后受理段**不返回任何折算字段**(Q2:T 日净值未公告),故改为断言 202 +
|
||
`accepted=True`,并显式断言**无折算字段**。
|
||
"""
|
||
fake = {
|
||
"blocked": False,
|
||
"accepted": True,
|
||
"status": "accepted",
|
||
"convert_group_id": "CNV-TEST-1",
|
||
"client_request_id": "CLI-TEST-1",
|
||
"requested_qty": "1000.00",
|
||
"qty": "1000.00",
|
||
"accept_date": "2026-09-09",
|
||
"confirm_date": "2026-09-10",
|
||
"available_date": "2026-09-11",
|
||
}
|
||
monkeypatch.setattr(tg, "accept_convert", lambda *_a, **_k: fake)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 202
|
||
assert r.json() == fake
|
||
assert "in_qty" not in r.json() # Q2:受理段不返回折算字段
|
||
|
||
|
||
def test_convert_does_not_write_trade_request_audit(client, env, monkeypatch):
|
||
"""convert **不落 `trade_request` 审计** —— 审计由 convert_service 记 `convert_request`。
|
||
|
||
防的是「网关 + convert_service 双重审计」:一次转换被记成两条审计事件。
|
||
"""
|
||
_, _, _, _, engine = env
|
||
monkeypatch.setattr(
|
||
tg,
|
||
"accept_convert",
|
||
lambda *_a, **_k: {
|
||
"blocked": False,
|
||
"accepted": True,
|
||
"status": "accepted",
|
||
"convert_group_id": "CNV-TEST-2",
|
||
},
|
||
)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 202 # T-9:受理成功回 202(此前 v1.0 走通回 200)
|
||
assert _counts(engine, "audit_log", "event_type='trade_request'") == 0
|
||
|
||
|
||
def test_api_blocked_returns_200_with_blocked_true(client):
|
||
r = client.post("/api/simulate/trade", json=_req(), headers=DEMO) # C1 买 R4
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True and body["block_response_code"] == "SUIT_RISK_MISMATCH"
|
||
|
||
|
||
def test_api_accepted_returns_200_with_trade_id(client):
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is False and body["trade_id"].startswith("TRD-")
|
||
assert body["triggered_rules"] == ["RISK-001", "RISK-002"]
|
||
|
||
|
||
def test_api_customer_owner_allowed_other_denied(client, env):
|
||
"""B6 评审 P2-2:客户本人放行进业务(适当性阻断与否由业务层决定),查他人 403+审计。"""
|
||
_, repo, _, _, engine = env
|
||
# CUST-1001(C1)买 R3:鉴权通过进入业务,业务层适当性阻断(200 blocked=true)
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-1001", product="PROD-510300", amount="1000"),
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
|
||
)
|
||
assert r.status_code == 200 and r.json()["blocked"] is True # 业务响应,非 403
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-3001", product="PROD-510300", amount="1000"),
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
|
||
)
|
||
assert r.status_code == 403
|
||
# 网关越权审计与放行同口径 agent_type='platform'(复审 P3)
|
||
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden' AND agent_type='platform'") == 1
|
||
|
||
|
||
def test_api_non_positive_amount_returns_422(client):
|
||
r = client.post("/api/simulate/trade", json=_req(amount="0"), headers=DEMO)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_api_unknown_customer_returns_404(client):
|
||
"""AL-05 换核:未知客户走 main 契约 → 200 + blocked + SUIT_NOT_FOUND(不再 404)。"""
|
||
r = client.post("/api/simulate/trade", json=_req(customer="CUST-9999"), headers=DEMO)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True
|
||
assert body["block_response_code"] == "SUIT_NOT_FOUND"
|