Files
group_xinghuo_jinrong/tests/test_trade_gateway.py
T

273 lines
12 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.
"""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, 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.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="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_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):小额赎回放行,无预警。"""
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):
"""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"}
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 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"