2026-09-06 18:05:44 +08:00
|
|
|
|
"""trade_gateway 集成测试(B5 · FR-1:convert 400 / 阻断不落 trade / 放行贯通引擎)。
|
|
|
|
|
|
|
|
|
|
|
|
服务层直测三路径 + TestClient 验 HTTP 语义(sqlite 全套表,驱动差异由引擎层
|
|
|
|
|
|
_normalize_trades 兜底)。API 层经 monkeypatch 注入 sqlite 仓储。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi import FastAPI
|
2026-09-06 18:58:48 +08:00
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
from fastapi.testclient import TestClient
|
2026-09-06 23:37:03 +08:00
|
|
|
|
from sqlalchemy import text
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
from _ddl import create_sqlite_engine, seed_suitability_matrix
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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
|
2026-09-10 18:00:20 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
from app.service.risk import alert_service
|
|
|
|
|
|
from app.service.risk.profile_l3 import AML_PENDING_TAG
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from app.utils.response import register_error_handlers
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakePublisher:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.messages = []
|
2026-09-06 20:48:50 +08:00
|
|
|
|
self.deletes = []
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
def publish(self, channel, payload):
|
|
|
|
|
|
self.messages.append((channel, payload))
|
|
|
|
|
|
|
2026-09-06 20:48:50 +08:00
|
|
|
|
def delete(self, *keys):
|
|
|
|
|
|
self.deletes.append(keys)
|
|
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
|
def env():
|
2026-09-06 23:37:03 +08:00
|
|
|
|
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
2026-09-07 17:34:32 +08:00
|
|
|
|
seed_suitability_matrix(engine) # AL-05:check_suitability 以矩阵表为 L0 权威
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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(
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) VALUES"
|
|
|
|
|
|
" ('CUST-1001', 'C1', :t, :exp), ('CUST-3001', 'C3', :t, :exp)"
|
2026-09-06 18:05:44 +08:00
|
|
|
|
),
|
2026-09-07 17:34:32 +08:00
|
|
|
|
{"t": datetime.now() - timedelta(days=30), "exp": datetime.now() + timedelta(days=300)},
|
2026-09-06 18:05:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
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="100000"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"customer_id": customer,
|
|
|
|
|
|
"product_id": product,
|
|
|
|
|
|
"trade_type": ttype,
|
|
|
|
|
|
"amount": amount,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 18:00:20 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 服务层 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 18:00:20 +08:00
|
|
|
|
def test_unknown_trade_type_rejected(env):
|
|
|
|
|
|
"""§12 **R3 拆分后**:未知类型仍 400;convert 不再拒绝(改由下方 API 层覆盖)。
|
|
|
|
|
|
|
|
|
|
|
|
保留原 `test_convert_rejected` 的两条断言:不落 `core_trade`、
|
|
|
|
|
|
不落 `invalid_type` 审计(PRD 审计口径仅阻断/放行)。
|
|
|
|
|
|
"""
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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):
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"""A-1:C1 客户买 R4 → blocked=true(SUIT_RISK_MISMATCH)、不落 core_trade、日志/预警/审计齐全。"""
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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
|
2026-09-07 17:34:32 +08:00
|
|
|
|
assert resp["block_response_code"] == "SUIT_RISK_MISMATCH" # main 契约机器码(SUIT-001 退役)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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
|
2026-09-06 18:17:44 +08:00
|
|
|
|
assert alert["status"] == "pending_review" # 评审 P3-3 加固
|
2026-09-06 18:05:44 +08:00
|
|
|
|
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
|
2026-09-06 18:17:44 +08:00
|
|
|
|
(channel, payload), = pub.messages
|
|
|
|
|
|
assert channel == "risk:pub:alert" and payload["risk_score"] == 70
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_redeem_accepted_without_alert(env):
|
|
|
|
|
|
"""redeem 正向路径(评审 P3-3):小额赎回放行,无预警。"""
|
|
|
|
|
|
core, repo, writer, pub, engine = env
|
|
|
|
|
|
resp = submit_trade(
|
|
|
|
|
|
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", amount="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_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 == []
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_customer_returns_lookup_error(env):
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"""AL-05 换核:NotFound 不再抛 LookupError,返回 forbidden/not_found 结构(main 契约)。"""
|
2026-09-06 18:05:44 +08:00
|
|
|
|
core, repo, writer, _, _ = env
|
2026-09-07 17:34:32 +08:00
|
|
|
|
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"
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 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)
|
2026-09-06 18:58:48 +08:00
|
|
|
|
# 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)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
app = FastAPI()
|
2026-09-06 20:48:50 +08:00
|
|
|
|
register_error_handlers(app) # 统一错误体(手册 §10,与 main 同一 handler 集)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
app.include_router(simulate_router)
|
|
|
|
|
|
with TestClient(app) as c:
|
|
|
|
|
|
yield c
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-90001"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 18:00:20 +08:00
|
|
|
|
# ---------- 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 表)。
|
|
|
|
|
|
|
|
|
|
|
|
做法:让 `convert_fund` 抛该异常,验统一错误体出口 —— convert 异常继承
|
|
|
|
|
|
`ApiError`,经 `register_error_handlers` 自动出体,**路由层不逐个 except**。
|
|
|
|
|
|
"""
|
|
|
|
|
|
def _boom(*_a, **_k):
|
|
|
|
|
|
raise exc
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(tg, "convert_fund", _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, "convert_fund", _boom)
|
|
|
|
|
|
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
assert r.status_code == 400
|
2026-09-10 18:00:20 +08:00
|
|
|
|
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):
|
|
|
|
|
|
"""架构 §8.3:未抢到执行权 → **202** + `{convert_group_id, status}`。"""
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
tg,
|
|
|
|
|
|
"convert_fund",
|
|
|
|
|
|
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_200(client, monkeypatch):
|
|
|
|
|
|
"""convert 走通 → 200 且**路由层原样透传**(完整折算数值见真库集成测试)。"""
|
|
|
|
|
|
fake = {"blocked": False, "convert_group_id": "CNV-TEST-1", "in_qty": "1000.00"}
|
|
|
|
|
|
monkeypatch.setattr(tg, "convert_fund", lambda *_a, **_k: fake)
|
|
|
|
|
|
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert r.json() == fake
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
"convert_fund",
|
|
|
|
|
|
lambda *_a, **_k: {"blocked": False, "convert_group_id": "CNV-TEST-2"},
|
|
|
|
|
|
)
|
|
|
|
|
|
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert _counts(engine, "audit_log", "event_type='trade_request'") == 0
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_blocked_returns_200_with_blocked_true(client):
|
2026-09-06 18:58:48 +08:00
|
|
|
|
r = client.post("/api/simulate/trade", json=_req(), headers=DEMO) # C1 买 R4
|
2026-09-06 18:05:44 +08:00
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
body = r.json()
|
2026-09-07 17:34:32 +08:00
|
|
|
|
assert body["blocked"] is True and body["block_response_code"] == "SUIT_RISK_MISMATCH"
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"),
|
2026-09-06 18:58:48 +08:00
|
|
|
|
headers=DEMO,
|
2026-09-06 18:05:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
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
|
2026-09-06 19:21:15 +08:00
|
|
|
|
# 网关越权审计与放行同口径 agent_type='platform'(复审 P3)
|
|
|
|
|
|
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden' AND agent_type='platform'") == 1
|
2026-09-06 18:58:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
def test_api_non_positive_amount_returns_422(client):
|
2026-09-06 18:58:48 +08:00
|
|
|
|
r = client.post("/api/simulate/trade", json=_req(amount="0"), headers=DEMO)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
assert r.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_unknown_customer_returns_404(client):
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"""AL-05 换核:未知客户走 main 契约 → 200 + blocked + SUIT_NOT_FOUND(不再 404)。"""
|
2026-09-06 18:58:48 +08:00
|
|
|
|
r = client.post("/api/simulate/trade", json=_req(customer="CUST-9999"), headers=DEMO)
|
2026-09-07 17:34:32 +08:00
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
body = r.json()
|
|
|
|
|
|
assert body["blocked"] is True
|
|
|
|
|
|
assert body["block_response_code"] == "SUIT_NOT_FOUND"
|