"""T-9 真 MySQL 端到端集成测试(架构 §8/§10 · 开发计划 §7.2 DoD · §12 R15 迁入)。 链路:`TestClient(main app)` → HTTP → `trade_gateway` 分派 → `convert_service` 八步编排 → 真 `jinrong_core` / `jinrong_agent`。 **为什么必须有这一层**:单测层只验到「路由层不加工、原样透传」(见 `test_trade_gateway.py`),而折算数字是否与 PRD §5.3 逐项吻合、两条流水是否 真的同组同事务、持仓/批次是否真的扣减——只有真库能证明。 隔离策略(架构 §10)——**三条同时成立,缺一即污染种子**: 1. **id 前缀**:`CNV-TEST-` / `TRD-TEST-`(monkeypatch `convert_service._new_id`); 2. **数据自建**:客户/产品/费率/净值/批次全部 `CNVTEST` 前缀自建,**绝不碰种子**—— `risk_demo_env` 的 teardown 只清 `TRD-TEST-` 前缀与时间窗,**不还原 `core_share_lot`/`core_holding`**;若借种子客户跑转换,扣掉的份额会跨用例污染 `test_integration_risk.py`; 3. **teardown 全清**:函数级 fixture 按前缀删两库全部自建行(幂等,seed 失败亦可清)。 """ from __future__ import annotations from datetime import date, datetime, time, timedelta from decimal import Decimal from uuid import uuid4 import pytest from fastapi.testclient import TestClient from sqlalchemy import text from conftest import ensure_risk_demo_ready from app.gateway import trade_gateway # noqa: E402 from app.main import app # noqa: E402 from app.service.convert import convert_service as cs # noqa: E402 from app.service.convert.calc import ( # noqa: E402 convert_amount, diff_fee, in_qty, lot_amount, lot_fee, ) from app.service.risk import redis_gateway # noqa: E402 ensure_risk_demo_ready() DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-DEMO"} # ── 隔离种子常量(全部带 CNVTEST 前缀)────────────────────────────── CUSTOMER = "CUST-CNVTEST" PROD_OUT = "PROD-CNVTESTO" # 债基,申购费率 0.0030 PROD_IN = "PROD-CNVTESTI" # 股基,申购费率 0.0080(**高于**转出端 → 补差费非零) PROD_CROSS = "PROD-161725" # 真库既有:易方模拟基金 / TA-CN-002(跨主体负例) COMPANY = "华夏模拟基金" TA = "TA-CN-001" OUT_RATE = Decimal("0.0030") IN_RATE = Decimal("0.0080") OUT_NAV = Decimal("1.0300") IN_NAV = Decimal("0.9500") FEE_TIERS = [ (0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000"), ] #: PRD §5.3 场景:两批次 30000 份持 100 天 + 20000 份持 3 天 → 费用档 0.0050 / 0.0150 LOT_SPEC = [("LOT-CNVTEST-A1", "30000", 100), ("LOT-CNVTEST-A2", "20000", 3)] TOTAL_QTY = sum(Decimal(q) for _, q, _ in LOT_SPEC) _GROUP_LIKE = "CNV-TEST-%" class FakePub: def __init__(self) -> None: self.messages: list = [] self.deletes: list = [] def publish(self, channel, payload): self.messages.append((channel, payload)) def delete(self, *keys): self.deletes.append(keys) def _test_new_id(prefix: str, now: datetime) -> str: """`_new_id` 替换:`CNV-TEST-xxxx` / `TRD-TEST-xxxx`(架构 §10 前缀约定)。""" return f"{prefix}-TEST-{uuid4().hex[:8].upper()}" # ── 种子与清理 ────────────────────────────────────────────────────── def _seed(core) -> None: """自建 PRD §5.3 场景(客户 + 双产品 + 费率 + 净值 + 双批次 + 持仓)。""" today = date.today() base = datetime.combine(today, time(10, 0)) # 固定钟点 → hold_days 恒等于 LOT_SPEC 的天数 with core.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, open_date)" " VALUES (:c, 'T9集成测试', :d)" ), {"c": CUSTOMER, "d": today}, ) conn.execute( text( "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)" " VALUES (:c, 'C5', :t, :exp)" ), {"c": CUSTOMER, "t": base - timedelta(days=30), "exp": base + timedelta(days=300)}, ) for pid, name, ptype, rate in [ (PROD_OUT, "T9转出基金", "bond", OUT_RATE), (PROD_IN, "T9转入基金", "stock", IN_RATE), ]: conn.execute( text( "INSERT INTO core_product (product_id, product_name, min_risk_code," " product_type, can_subscribe, can_redeem, subscribe_fee_rate," " fund_company, ta_code)" " VALUES (:p, :n, 'R2', :t, 1, 1, :r, :co, :ta)" ), {"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA}, ) for mh, mh_max, rate in FEE_TIERS: conn.execute( text( "INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days," " max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mm, :r)" ), {"p": PROD_OUT, "mh": mh, "mm": mh_max, "r": rate}, ) conn.execute( text( "INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)" " VALUES (:p, :n, 0, :d)" ), {"p": PROD_IN, "n": str(IN_NAV), "d": today}, ) for lot_id, qty, days in LOT_SPEC: conn.execute( text( "INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty," " remain_qty, nav, confirmed_at) VALUES (:l, :c, :p, :q, :q, :n, :cat)" ), { "l": lot_id, "c": CUSTOMER, "p": PROD_OUT, "q": qty, "n": str(OUT_NAV), "cat": base - timedelta(days=days), }, ) conn.execute( text( "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount," " market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :mv, 0, :d)" ), { "c": CUSTOMER, "p": PROD_OUT, "q": str(TOTAL_QTY), "cost": str(TOTAL_QTY * OUT_NAV), "mv": str(TOTAL_QTY * OUT_NAV), "d": today, }, ) def _cleanup(core, agent) -> None: """按前缀清两库全部自建行(幂等)。""" core_sqls = [ f"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'", "DELETE FROM core_trade WHERE customer_id = :c", "DELETE FROM core_share_lot WHERE customer_id = :c", "DELETE FROM core_holding WHERE customer_id = :c", "DELETE FROM core_customer_risk WHERE customer_id = :c", "DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-CNVTEST%'", "DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-CNVTEST%'", "DELETE FROM core_product WHERE product_id LIKE 'PROD-CNVTEST%'", "DELETE FROM core_customer WHERE customer_id = :c", ] with core.begin() as conn: for sql in core_sqls: conn.execute(text(sql), {"c": CUSTOMER}) with agent.begin() as conn: conn.execute(text(f"DELETE FROM risk_convert_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'")) conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER}) conn.execute(text("DELETE FROM risk_suitability_log WHERE customer_id = :c"), {"c": CUSTOMER}) conn.execute(text("DELETE FROM audit_log WHERE customer_id = :c"), {"c": CUSTOMER}) @pytest.fixture() def conv_env(risk_demo_env, monkeypatch): """自建隔离种子 + 注入 TEST 前缀 id 工厂 + 函数级全清。""" core = risk_demo_env["core"] agent = risk_demo_env["agent"] monkeypatch.setattr(cs, "_new_id", _test_new_id) monkeypatch.setattr(trade_gateway, "_new_trade_id", lambda now: _test_new_id("TRD", now)) _cleanup(core, agent) # 先清后建:上一轮异常退出也能自愈 _seed(core) try: yield {"core": core, "agent": agent} finally: _cleanup(core, agent) @pytest.fixture() def client(conv_env, monkeypatch): fake = FakePub() with TestClient(app) as c: monkeypatch.setattr(redis_gateway, "_gateway", fake) yield c def _body(qty="50000", cid=None, frm=PROD_OUT, to=PROD_IN): payload = { "customer_id": CUSTOMER, "trade_type": "convert", "from_product_id": frm, "to_product_id": to, "qty": qty, } if cid: payload["client_request_id"] = cid return payload def _one(engine, sql: str, **params): with engine.connect() as conn: return conn.execute(text(sql), params).mappings().first() def _n(engine, sql: str, **params) -> int: with engine.connect() as conn: return conn.execute(text(sql), params).scalar_one() # ── 1. 端到端:与 PRD §5.3 示例逐项吻合 ───────────────────────────── def test_convert_end_to_end_matches_prd_5_3(client, conv_env): """跨两批次转换走通,四段金额与 PRD §5.3 示例**逐项吻合**。 期望值一律**调生产纯函数**得出(禁手算 · 自检第 13 问),再与 PRD 示例数字对表。 """ r = client.post("/api/simulate/trade", json=_body(), headers=DEMO) assert r.status_code == 200, r.text body = r.json() assert body["blocked"] is False assert body["estimated"] is True # T 日未知价法 assert body["lot_count"] == 2 assert body["confirm_basis"] == "natural_day_approx" by_days = {b["hold_days"]: b for b in body["lot_breakdown"]} assert set(by_days) == {100, 3}, "两批次持有期应为 100 / 3 天" exp_out = Decimal("0") exp_fee = Decimal("0") for days, rate in ((100, Decimal("0.0050")), (3, Decimal("0.0150"))): leg = by_days[days] assert Decimal(leg["fee_rate"]) == rate, f"持有 {days} 天的费率档" leg_amount = lot_amount(Decimal(leg["qty"]), Decimal(leg["nav"])) leg_fee = lot_fee(leg_amount, rate) assert Decimal(leg["fee_amount"]) == leg_fee, f"持有 {days} 天的逐批费用" exp_out += leg_amount exp_fee += leg_fee # 逐批舍入后求和(PRD §2.5) exp_conv = convert_amount(exp_out, exp_fee) exp_diff = diff_fee(exp_conv, OUT_RATE, IN_RATE) exp_in_amount = convert_amount(exp_conv, exp_diff) assert Decimal(body["out_amount"]) == exp_out assert Decimal(body["redeem_fee"]) == exp_fee assert Decimal(body["convert_amount"]) == exp_conv assert Decimal(body["diff_fee"]) == exp_diff assert Decimal(body["in_amount"]) == exp_in_amount assert Decimal(body["in_qty"]) == in_qty(exp_in_amount, IN_NAV) # 与 PRD §5.3 示例数字对表(该示例由 calc_convert_demo.py 实算回填) assert (exp_out, exp_fee, exp_conv, exp_diff, exp_in_amount) == ( Decimal("51500.00"), Decimal("463.50"), Decimal("51036.50"), Decimal("252.40"), Decimal("50784.10"), ) assert Decimal(body["in_qty"]) == Decimal("53456.95") # 转入端费率高于转出端 → 补差费应 > 0(验收 19 的非零场景) assert exp_diff > 0 # ── 2. 两条流水同组、id 前缀正确 ──────────────────────────────────── def test_convert_writes_two_trades_in_same_group(client, conv_env): core = conv_env["core"] gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"] assert gid.startswith("CNV-TEST-") rows = { row["trade_type"]: row for row in [ dict(r) for r in _all( core, "SELECT trade_type, trade_id, amount, convert_group_id FROM core_trade" " WHERE customer_id = :c", c=CUSTOMER, ) ] } assert set(rows) == {"redeem", "subscribe"}, "一次转换落两条流水(R-b)" assert {r["convert_group_id"] for r in rows.values()} == {gid} assert all(r["trade_id"].startswith("TRD-TEST-") for r in rows.values()) # 转出端金额 = 原始转出额(未扣费) assert Decimal(str(rows["redeem"]["amount"])) == Decimal("51500.00") def _all(engine, sql: str, **params): with engine.connect() as conn: return conn.execute(text(sql), params).mappings().all() # ── 3. 持仓与批次如实变动 ─────────────────────────────────────────── def test_convert_updates_holding_and_lots(client, conv_env): core = conv_env["core"] client.post("/api/simulate/trade", json=_body(), headers=DEMO) # 转出端批次:两批 remain_qty 归零(全部转出)—— 必须限定 product_id, # 否则会把**转入端新建批次**算进同一客户的聚合(首版断言即栽在这里) remain = _one( core, "SELECT SUM(remain_qty) AS s FROM core_share_lot" " WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT, ) assert Decimal(str(remain["s"])) == Decimal("0.0000") # 转出端持仓归零但**行保留**(D9/P2) out_holding = _one( core, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_OUT, ) assert out_holding is not None, "转出归零保留行(不可 DELETE)" assert Decimal(str(out_holding["qty"])) == Decimal("0.0000") # 转入端持仓新建且份额 = 响应 in_qty in_holding = _one( core, "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN, ) assert in_holding is not None assert Decimal(str(in_holding["qty"])) == Decimal("53456.95") # ── 4. 阶段二:明细 completed + 审计落库 ──────────────────────────── def test_convert_detail_completed_and_audited(client, conv_env): agent = conv_env["agent"] gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"] detail = _one( agent, "SELECT status, fee_amount FROM risk_convert_detail WHERE convert_group_id = :g", g=gid, ) assert detail["status"] == "completed" assert Decimal(str(detail["fee_amount"])) == Decimal("463.50") # 审计以 convert_group_id 为关联主键(L-2:一次转换 = 一条主审计) n_main = _n( agent, "SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND decision = 'convert_accepted'", c=CUSTOMER, ) assert n_main == 1 # 网关不重复写 trade_request(T-9:审计归 convert_service) assert _n( agent, "SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND event_type = 'trade_request'", c=CUSTOMER, ) == 0 # ── 5. 幂等:同键重试不产生第二组流水 ─────────────────────────────── #: 首发(走 calc 纯函数)与重放(按 core_trade/core_convert_lot_detail 重建) #: 必须**逐字节相同**的字段。这份清单是「同一响应、两条路径」的一致性契约。 REPLAY_IDENTICAL_FIELDS = ( "requested_qty", "actual_qty", "out_nav", "out_amount", "redeem_fee", "in_nav", "convert_amount", "diff_fee", "in_amount", "in_qty", "rounding_diff", "out_subscribe_fee_rate", "in_subscribe_fee_rate", ) def test_convert_idempotent_retry_returns_byte_identical_response(client, conv_env): """带 `client_request_id` 重试 → 命中幂等、**响应与首次逐字节相同**。 这是「展示位数」的回归闸门:重放值由 `DECIMAL(18,4)` 重建,若不经理发展示规格 收敛,同一字段会吐出 `53456.9500`(4 位)而首次是 `53456.95`(2 位)—— 数值相等但字符串不等,前端/对账按字符串比对即误判为两笔。 """ core = conv_env["core"] first = client.post( "/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO ).json() second = client.post( "/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO ).json() assert second["convert_group_id"] == first["convert_group_id"] for field in REPLAY_IDENTICAL_FIELDS: assert second[field] == first[field], f"重放 {field} 应与首次**逐字节**相同" # 逐批明细同样逐字段一致(qty / nav / fee_rate 的位数也是展示规格的一部分) assert second["lot_breakdown"] == first["lot_breakdown"] assert _n(core, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 2 assert _n( core, "SELECT COUNT(*) FROM core_share_lot" " WHERE customer_id = :c AND product_id = :p AND remain_qty > 0", c=CUSTOMER, p=PROD_OUT, ) == 0, "重试不得再扣一次转出端份额" assert _n( core, "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p", c=CUSTOMER, p=PROD_IN, ) == 1, "重试不得再建一条转入端批次" # ── 5b. 展示位数规格(真实公告口径,见 convert_service._q)───────────── def test_convert_response_field_scales(client, conv_env): """响应的**展示位数**按字段分类收敛(真实公告口径),不随数据源漂移。 真实依据:金额/份额「四舍五入保留至小数点后两位」;净值保留 4 位、 第 5 位四舍五入;申购费率以百分比 2 位表示(小数形式即 4 位)。 """ body = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json() def scale(key: str) -> int: return len(body[key].split(".")[1]) # 金额 / 份额 → 2 位 for key in ("out_amount", "redeem_fee", "convert_amount", "diff_fee", "in_amount", "in_qty", "requested_qty", "actual_qty"): assert scale(key) == 2, f"{key} 应为金额/份额口径(2 位),实际 {body[key]}" # 净值 / 费率 / 尾差 → 4 位 for key in ("out_nav", "in_nav", "out_subscribe_fee_rate", "in_subscribe_fee_rate", "rounding_diff"): assert scale(key) == 4, f"{key} 应为净值/费率口径(4 位),实际 {body[key]}" for leg in body["lot_breakdown"]: assert len(leg["qty"].split(".")[1]) == 2, f"逐批份额 2 位,实际 {leg['qty']}" assert len(leg["fee_amount"].split(".")[1]) == 2, f"逐批费用 2 位,实际 {leg['fee_amount']}" assert len(leg["nav"].split(".")[1]) == 4, f"逐批净值 4 位,实际 {leg['nav']}" assert len(leg["fee_rate"].split(".")[1]) == 4, f"逐批费率 4 位,实际 {leg['fee_rate']}" # 申请份额即使**不带小数**传入,也必须补齐到 2 位(不能回显成 "50000") assert body["requested_qty"] == "50000.00" # ── 6. 跨主体拒绝(验收 14)───────────────────────────────────────── def test_convert_cross_entity_rejected(client, conv_env): """转出华夏/TA-CN-001 → 转入易方/TA-CN-002 → 400 `CROSS_ENTITY_NOT_SUPPORTED`。""" r = client.post("/api/simulate/trade", json=_body(to=PROD_CROSS), headers=DEMO) assert r.status_code == 400 body = r.json() assert body["error_code"] == "CROSS_ENTITY_NOT_SUPPORTED" assert body["trace_id"] # 校验失败不留任何流水(4xx 不落库) assert _n(conv_env["core"], "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 0 # ── 7. 未知类型仍 400(R3 的集成侧对应)───────────────────────────── def test_unknown_trade_type_returns_400_over_http(client): """走 HTTP 的未知类型仍是 400(`TradeRequest` 分支校验**放行**未知类型给网关兜底拒绝)。""" r = client.post( "/api/simulate/trade", json={"customer_id": CUSTOMER, "trade_type": "purchase", "amount": "1000"}, headers=DEMO, ) assert r.status_code == 400 assert r.json()["error_code"] == "BAD_REQUEST"