266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""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
|
||
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
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.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)
|
||
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) VALUES"
|
||
" ('CUST-1001', 'C1', :t), ('CUST-3001', 'C3', :t)"
|
||
),
|
||
{"t": datetime.now() - timedelta(days=30)},
|
||
)
|
||
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,
|
||
}
|
||
|
||
|
||
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_convert_rejected(env):
|
||
core, repo, writer, _, _ = env
|
||
with pytest.raises(UnsupportedTradeType, match="转换交易暂不支持"):
|
||
submit_trade(_req(ttype="convert"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
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-001)、不落 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 "SUIT-001" in resp["block_reason"]
|
||
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):小额赎回放行,无预警。"""
|
||
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 == []
|
||
|
||
|
||
def test_missing_customer_returns_lookup_error(env):
|
||
core, repo, writer, _, _ = env
|
||
with pytest.raises(LookupError):
|
||
submit_trade(_req(customer="CUST-9999"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
|
||
|
||
# ---------- 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"}
|
||
|
||
|
||
def test_api_convert_returns_400(client):
|
||
r = client.post("/api/simulate/trade", json=_req(ttype="convert"), headers=DEMO)
|
||
assert r.status_code == 400
|
||
assert r.json()["error_code"] == "BAD_REQUEST"
|
||
assert "转换交易暂不支持" in r.json()["message"]
|
||
|
||
|
||
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 "SUIT-001" in body["block_reason"]
|
||
|
||
|
||
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):
|
||
r = client.post("/api/simulate/trade", json=_req(customer="CUST-9999"), headers=DEMO)
|
||
assert r.status_code == 404
|