"""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"