Files
group_xinghuo_jinrong/tests/test_convert_service.py
T
GaoYiYuan_0626 c56ea8d50e 基金转换 T-13:全量回归 + 50 并发压测 + 性能实测补录
交付:新增 tests/test_convert_concurrency.py(9 条 · CONVERT_STRESS 门禁)+ test_convert_service 同键重试 3 条;app/repository/convert_repository.insert_placeholder 改三步法(R-a)、app/service/convert/convert_service.py 幂等判定 pending→202。

修复 2 处并发缺陷(压测暴露,sqlite 不可见):① 阶段一失败后同键重试永久 503(占位朴素 INSERT 撞 uk_group/uk_idem);② 同键竞态子窗口 A 真·双扣 / B 双扣+503(撞 uk_idem 直穿)。

验证:pytest 736 passed / 10 skipped(+5,跑两遍稳定);CONVERT_STRESS=1 并发 9/9;按 SOP 重灌双库复跑零回归;7 个 convert 真库脚本复跑零回归;突变 4 组全部精准命中。50 并发不超卖(成交 20~25)、紧池退避 40/50=80%(不够用)、松池 100%、跨客户 1213 死锁登记待评估;性能端到端 P50 44.2/max 59.8ms、阶段一 P50 9.8/max 16.7ms(未触阈值、未改实测值)。

文档:PRD v0.9.3(§9 第 18 条实测补录)+ 开发计划 §10(10.1~10.5)+ docs/memory/{TODO,MEMORY,FRAMEWORK,ITERATION,2026-09-10}。
2026-09-10 19:46:08 +08:00

730 lines
30 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.
"""T-7 `convert_service` 八步编排单测(开发计划 §6.2 DoD)。
sqlite 内存库(`conftest.sqlite_engine`,含 C×R 矩阵种子),数据自建。
折算期望值一律由生产 `calc.py` 实算(不自造公式副本,自检第 13 问)。
覆盖:
1. 三阶段贯通(占位 pending→completed / 2 流水 / 响应字段与 PRD §5.3 对齐)
2. **blocked 不占位**(PRD §7.0:前四步不落库)
3. 幂等命中返回首次结果、**不产生第二组流水**
4. 未抢到执行权 → `status=processing`(T-9 映射 202)
5. 全额转出豁免 `min_redeem_qty`(验收 16)/ 强制全转留痕
6. `nav_stale` 额外落副审计(1~2 条)
7. 阶段 1.5 引擎异常**不阻断**已成立交易
8. 阶段二失败 → `convert_detail_write_failed` 审计 + 占位 `failed`
9. 各 4xx/503 分支:SameProduct / 不可赎回 / 跨主体 / 份额不足 / 低于最低份额 /
无净值 / 批次数超限
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.calc import (
convert_amount,
diff_fee,
hold_days,
in_qty,
lot_amount,
lot_fee,
plan_lots,
)
from app.service.convert.convert_service import (
PROCESSING,
compensate_convert,
convert_fund,
)
from app.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
InsufficientShares,
LotConflict,
NavNotReady,
ProductNotRedeemable,
SameProduct,
TooManyLots,
)
from app.service.convert.fee import pick_fee_rate
from app.service.convert.types import FeeRule, Lot
from app.service.risk.locks import try_lock
from app.service.risk.rules import RiskThresholds
CUST = "CUST-T7" # C3 客户 → R4 产品 allowed_with_disclosure(放行)
CUST_LOW = "CUST-T7L" # C1 客户 → R4 forbidden(用于 blocked 分支)
PROD_OUT = "PROD-T7A"
PROD_IN = "PROD-T7B"
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
NOW = datetime(2026, 9, 4, 10, 0, 0)
TODAY = date(2026, 9, 4)
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
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"),
]
def _exec(engine, sql: str, **params) -> None:
with engine.begin() as conn:
conn.execute(text(sql), params)
def _rows(engine, sql: str, **params) -> list[dict]:
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
def _seed(engine, *, nav_date: date = TODAY, min_redeem: str = "0", min_hold: str = "0") -> None:
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'T7客户', 40, 1)",
c=CUST,
)
_exec(
engine,
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
"VALUES (:c, 'T7低风险客户', 40, 1)",
c=CUST_LOW,
)
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C3', :t, :exp)",
c=CUST, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
)
_exec(
engine,
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
"VALUES (:c, 'C1', :t, :exp)",
c=CUST_LOW, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
)
_exec(
engine,
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
"can_subscribe, can_redeem, subscribe_fee_rate, min_redeem_qty, min_hold_qty, "
"fund_company, ta_code) VALUES (:p, '转出债基', 'R2', 'bond', 1, 1, :r, :mr, :mh, :co, :ta)",
p=PROD_OUT, r=float(OUT_RATE), mr=float(min_redeem), mh=float(min_hold),
co=COMPANY, ta=TA,
)
_exec(
engine,
"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, '转入股基', 'R4', 'stock', 1, 1, :r, :co, :ta)",
p=PROD_IN, r=float(IN_RATE), co=COMPANY, ta=TA,
)
for mh, mh_max, rate in FEE_TIERS:
_exec(
engine,
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, max_hold_days, rate) "
"VALUES (:p, 'redeem', :mh, :mh_max, :rate)",
p=PROD_OUT, mh=mh, mh_max=mh_max, rate=float(rate),
)
_exec(
engine,
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
"VALUES (:p, :nav, 0, :d)",
p=PROD_IN, nav=float(IN_NAV), d=nav_date,
)
_seed_lot(engine, "LOT-T7-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0))
_seed_lot(engine, "LOT-T7-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0))
_exec(
engine,
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
c=CUST, p=PROD_OUT, d=TODAY,
)
# CUST_LOW(C1,R4 应被适当性拦截)也需有份额,否则 ② 份额校验会先于 ④ 触发
_seed_lot(engine, "LOT-T7L-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0),
customer=CUST_LOW)
_seed_lot(engine, "LOT-T7L-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0),
customer=CUST_LOW)
_exec(
engine,
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
c=CUST_LOW, p=PROD_OUT, d=TODAY,
)
def _seed_lot(engine, lot_id: str, qty: str, nav: str, confirmed_at: datetime,
customer: str = CUST) -> None:
_exec(
engine,
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, nav, "
"confirmed_at) VALUES (:l, :c, :p, :q, :q, :nav, :cat)",
l=lot_id, c=customer, p=PROD_OUT, q=float(qty), nav=float(nav), cat=confirmed_at,
)
def _services(engine):
return dict(
core_ro=CoreReadOnlyRepository(engine=engine),
risk_repo=RiskRepository(engine=engine),
convert_repo=ConvertRepository(engine=engine),
core_writer=ConvertCoreRepository(engine=engine),
)
def _req(customer: str = CUST, qty: str = "120", cid_req: str | None = None) -> dict:
return {
"customer_id": customer,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": qty,
"client_request_id": cid_req,
}
def _expected(engine, requested: str) -> dict:
"""用生产纯函数算一遍期望值(与 service 内部同一套口径)。"""
core = CoreReadOnlyRepository(engine=engine)
lots = [Lot.from_row(r) for r in core.list_share_lots(CUST, PROD_OUT)]
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(PROD_OUT)]
plan = plan_lots(lots, Decimal(requested))
out_amount = Decimal("0")
redeem_fee = Decimal("0")
for alloc in plan.allocations:
amount = lot_amount(alloc.qty, alloc.nav)
rate = pick_fee_rate(rules, hold_days(TODAY, alloc.confirmed_at), product_id=PROD_OUT)
out_amount += amount
redeem_fee += lot_fee(amount, rate)
conv = convert_amount(out_amount, redeem_fee)
gap = diff_fee(conv, OUT_RATE, IN_RATE, "amount_diff")
in_amount = conv - gap
return {
"out_amount": out_amount,
"redeem_fee": redeem_fee,
"convert_amount": conv,
"diff_fee": gap,
"in_amount": in_amount,
"in_qty": in_qty(in_amount, IN_NAV),
"actual_qty": plan.actual_qty,
}
# ── 1. 三阶段贯通 ───────────────────────────────────────────────────
def test_happy_path_writes_two_trades_and_completes_placeholder(sqlite_engine):
_seed(sqlite_engine)
exp = _expected(sqlite_engine, "120")
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["estimated"] is True
assert resp["convert_group_id"].startswith("CNV-")
assert Decimal(resp["out_amount"]) == exp["out_amount"]
assert Decimal(resp["redeem_fee"]) == exp["redeem_fee"]
assert Decimal(resp["convert_amount"]) == exp["convert_amount"]
assert Decimal(resp["diff_fee"]) == exp["diff_fee"]
assert Decimal(resp["in_amount"]) == exp["in_amount"]
assert Decimal(resp["in_qty"]) == exp["in_qty"]
assert Decimal(resp["actual_qty"]) == exp["actual_qty"]
assert resp["lot_count"] == 2
assert [b["hold_days"] for b in resp["lot_breakdown"]] == [34, 3]
assert resp["confirm_basis"] == "natural_day_approx"
# 阶段二:占位 completed
rows = _rows(
sqlite_engine,
"SELECT * FROM risk_convert_detail WHERE convert_group_id = :g",
g=resp["convert_group_id"],
)
assert len(rows) == 1 and rows[0]["status"] == "completed"
# 两条流水同组、redeem/subscribe(R-b)
trades = _rows(
sqlite_engine,
"SELECT * FROM core_trade WHERE convert_group_id = :g ORDER BY trade_type",
g=resp["convert_group_id"],
)
assert [t["trade_type"] for t in trades] == ["redeem", "subscribe"]
# 主审计 1 条(净值新鲜 → 无 nav_stale 副审计)
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
assert _rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'") == []
# ── 2. blocked 不占位(PRD §7.0)─────────────────────────────────────
def test_suitability_blocked_does_not_placeholder(sqlite_engine):
_seed(sqlite_engine)
resp = convert_fund(
_req(customer=CUST_LOW), now=NOW, **_services(sqlite_engine)
)
assert resp["blocked"] is True
assert resp["block_response_code"]
# 关键:前四步不落库 —— 无占位、无流水
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'suitability_blocked'")
) == 1
# ── 3. 幂等:同键重复提交不产生第二组流水 ────────────────────────────
def test_idempotent_repeat_returns_first_result(sqlite_engine):
_seed(sqlite_engine)
svc = _services(sqlite_engine)
first = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
again = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
assert again["convert_group_id"] == first["convert_group_id"]
assert again["in_qty"] == first["in_qty"]
assert again["out_trade_id"] == first["out_trade_id"]
# 只有一组流水(2 条),没有第二组
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1
# ── 4. 未抢到执行权 → processing(T-9 映射 202)───────────────────────
def test_lock_not_acquired_returns_processing(sqlite_engine, monkeypatch):
import app.service.convert.convert_service as cs
from app.service.risk.locks import _NoLock
_seed(sqlite_engine)
monkeypatch.setattr(cs, "try_lock", lambda *a, **k: _NoLock())
resp = convert_fund(_req(cid_req="CLI-T7-002"), now=NOW, **_services(sqlite_engine))
assert resp["status"] == PROCESSING
assert "convert_group_id" in resp
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
# ── 5. 全额转出豁免最低份额(验收 16)────────────────────────────────
def test_full_transfer_waives_min_redeem_qty(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150(< min_redeem 10000)但申请全额 → 豁免,成功
resp = convert_fund(_req(qty="150"), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False
assert Decimal(resp["actual_qty"]) == Decimal("150")
def test_below_min_qty_rejected_when_not_full(sqlite_engine):
_seed(sqlite_engine, min_redeem="10000")
# 持 150、申请 100(< 最低 10000,且非全额)→ 400 BELOW_MIN_QTY
with pytest.raises(BelowMinQty):
convert_fund(_req(qty="100"), now=NOW, **_services(sqlite_engine))
# ── 6. 强制全转留痕 ─────────────────────────────────────────────────
def test_forced_full_transfer_flag(sqlite_engine):
_seed(sqlite_engine, min_hold="100")
# 持 150、申请 140 → 余额 10 < 100 → 强制全转 150
resp = convert_fund(_req(qty="140"), now=NOW, **_services(sqlite_engine))
assert resp["forced_full_transfer"] is True
assert resp["min_hold_action"] == "force_transfer"
assert Decimal(resp["actual_qty"]) == Decimal("150")
# ── 7. nav_stale 额外落副审计 ────────────────────────────────────────
def test_nav_stale_adds_second_audit(sqlite_engine):
_seed(sqlite_engine, nav_date=date(2026, 8, 25)) # 距今 10 天 > 3
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert resp["nav_stale"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'")) == 1
assert len(
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
) == 1
# ── 8. 阶段 1.5:引擎异常不阻断已成立的交易 ───────────────────────────
def test_engine_exception_does_not_block_trade(sqlite_engine):
_seed(sqlite_engine)
def boom(out_trade, in_trade): # noqa: ANN001
raise RuntimeError("引擎炸了")
resp = convert_fund(_req(), now=NOW, engine_hook=boom, **_services(sqlite_engine))
assert resp["blocked"] is False
assert resp["engine_error"] is True
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2 # 交易仍成立
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'engine_error'")) == 1
def test_engine_result_merged_into_response(sqlite_engine):
_seed(sqlite_engine)
hook = lambda o, i: { # noqa: E731
"triggered_rules": ["RISK-002"],
"alert_ids": ["ALT-1"],
"aml_hit": False,
}
resp = convert_fund(_req(), now=NOW, engine_hook=hook, **_services(sqlite_engine))
assert resp["triggered_rules"] == ["RISK-002"]
assert resp["alert_ids"] == ["ALT-1"]
# ── 9. 阶段二失败:留痕 + 占位 failed(不回滚 Core)───────────────────
def test_phase_two_failure_keeps_trade_and_marks_failed(sqlite_engine, monkeypatch):
_seed(sqlite_engine)
def boom(self, group_id, **kwargs): # noqa: ANN001
raise RuntimeError("阶段二写失败")
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
resp = convert_fund(
_req(cid_req="CLI-T7-003"), now=NOW, **_services(sqlite_engine)
)
# 交易已成立:core 有两条流水,响应照常返回
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert resp["blocked"] is False
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
)
) == 1
rows = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert rows[0]["status"] == "failed"
# ── 9b. 阶段一失败 → 同键重试必须可成功(T-13 前置修复的回归闸门)──────
def test_retry_after_phase_one_failure_succeeds_with_same_key(
sqlite_engine, monkeypatch,
):
"""`LotConflict`(409) 后带**同一** `client_request_id` 重试 → 成功、只有一组流水。
这是架构 §8.3「`LOT_CONFLICT` → 调用方重试,建议 ≤3 次、间隔 100/200/400ms」的
服务端契约。修复前:占位被 `mark_failed` 置 failed,重试复用同一 `group_id`
再次进入阶段零,`insert_placeholder` 的朴素 INSERT 撞 `uk_group`/`uk_idem`
→ `IdempotencyUnavailable`(503) —— **确定性失败**,重试永远不会成功。
"""
_seed(sqlite_engine)
original = ConvertCoreRepository.apply_convert
def conflict(self, req): # noqa: ANN001
raise LotConflict("并发争抢:条件 UPDATE rowcount=0")
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict)
with pytest.raises(LotConflict):
convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", original)
# 前置态:占位 failed、两条流水都没落
pre = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert len(pre) == 1 and pre[0]["status"] == "failed"
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
first_gid = pre[0]["convert_group_id"]
# 带同键重试 → 必须成功,且复用原 group_id(杜绝第二组流水)
resp = convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
assert resp["convert_group_id"] == first_gid
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")[0]["status"] == "completed"
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1 # 不新增占位行
def test_retry_after_phase_one_failure_can_fail_again_and_still_retry(
sqlite_engine, monkeypatch,
):
"""连续两次 409 后再重试仍能成功 —— 证明「failed → pending」可反复回置。"""
_seed(sqlite_engine)
original = ConvertCoreRepository.apply_convert
state = {"n": 0}
def conflict_twice(self, req): # noqa: ANN001
state["n"] += 1
if state["n"] <= 2:
raise LotConflict("第 %d 次争抢失败" % state["n"])
original(self, req)
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict_twice)
for _ in range(2):
with pytest.raises(LotConflict):
convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
resp = convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
assert resp["blocked"] is False and resp["in_qty"]
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert [r["status"] for r in _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")] == [
"completed"
]
def test_placeholder_uk_idem_race_returns_processing_without_writing(
sqlite_engine, monkeypatch,
):
"""同键并发子窗口(两笔都判定"无占位")→ 撞 `uk_idem` 的那笔必须回 202,且零写入。
构造方式:**只把"按 cid 查"这一读打桩成 None**(等价于并发下"那一行还没插进来"),
占位表里预置另一笔同 cid 的占位。于是:
① 幂等判定读不到 → 本笔自生成新 group_id;
② 阶段零 INSERT 撞 `uk_idem` → `insert_placeholder` 返回 False → 本笔让路。
这是**资金安全**的闸门:若不接住这个信号(仓储吞掉 False、调用方照跑),
两笔会各带不同 group_id 跑完阶段一 —— `in_lot_id` 派生自 group_id 不再相撞
→ **双扣**(真库突变验证实测:120 份变 240 份、两组流水,见
`tests/test_convert_concurrency.py::test_same_client_request_id_placeholder_race_must_not_leak_5xx`)。
"""
_seed(sqlite_engine)
_exec(
sqlite_engine,
"INSERT INTO risk_convert_detail"
" (convert_group_id, client_request_id, status, estimated)"
" VALUES ('CNV-OTHER-CONC', 'CID-RACE', 'pending', 0)",
)
monkeypatch.setattr(
ConvertRepository, "get_by_client_request_id", lambda self, cid: None
)
resp = convert_fund(_req(cid_req="CID-RACE"), now=NOW, **_services(sqlite_engine))
assert resp["status"] == PROCESSING # 让路 → 202(T-9 映射)
assert resp["convert_group_id"] is None
# 零写入:没有第二组流水、没有第二行占位、没有扣份额
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
placeholders = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
assert len(placeholders) == 1 and placeholders[0]["convert_group_id"] == "CNV-OTHER-CONC"
assert Decimal(
_rows(
sqlite_engine,
"SELECT SUM(remain_qty) AS s FROM core_share_lot WHERE customer_id = :c",
c=CUST,
)[0]["s"]
) == Decimal("150")
# ── 10. 各 4xx / 503 分支 ───────────────────────────────────────────
def test_same_product_rejected(sqlite_engine):
_seed(sqlite_engine)
req = _req()
req["to_product_id"] = PROD_OUT
with pytest.raises(SameProduct):
convert_fund(req, now=NOW, **_services(sqlite_engine))
def test_cross_entity_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET fund_company = '易方达模拟基金' WHERE product_id = :p",
p=PROD_IN,
)
with pytest.raises(CrossEntityNotSupported):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_out_product_not_redeemable_rejected(sqlite_engine):
_seed(sqlite_engine)
_exec(
sqlite_engine,
"UPDATE core_product SET can_redeem = 0 WHERE product_id = :p",
p=PROD_OUT,
)
with pytest.raises(ProductNotRedeemable):
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
def test_insufficient_shares_rejected(sqlite_engine):
_seed(sqlite_engine)
with pytest.raises(InsufficientShares):
convert_fund(_req(qty="500"), now=NOW, **_services(sqlite_engine))
def test_nav_not_ready_503(sqlite_engine):
_seed(sqlite_engine)
_exec(sqlite_engine, "DELETE FROM core_product_nav")
with pytest.raises(NavNotReady) as exc:
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
assert exc.value.status_code == 503
def test_too_many_lots_rejected(sqlite_engine, monkeypatch):
from app.config import settings as settings_module
_seed(sqlite_engine)
monkeypatch.setattr(settings_module.settings, "convert_batch_max_lots", 1)
with pytest.raises(TooManyLots) as exc:
convert_fund(_req(qty="120"), now=NOW, **_services(sqlite_engine))
assert exc.value.extra == {"batch_count": 2, "max_lots": 1}
# ── 10. 阶段 1.5 接线(T-8):引擎真跑并出单 ─────────────────────────
def test_engine_wired_produces_single_alert_with_two_events(sqlite_engine):
"""T-8 落地后阶段 1.5 不再跳过:大额转换**真出一张单**、`payload.events` 两条(验收 7)。
本用例是「接线回归」:若 `_run_engine` 又被改回静默跳过(或签名对不上被
ImportError 吞掉),这里会因 `triggered_rules` 为空而变红。
"""
_seed(sqlite_engine)
th = RiskThresholds(
large_amount=Decimal("100"), # 调低以让 120 份的折算额命中 RISK-001
daily_total=Decimal("1000000"),
freq_count=3,
probe_window_minutes=5,
probe_count=3,
probe_amount=Decimal("400000"),
small_amount=Decimal("10000"),
small_count=3,
concentration_threshold=1.01,
)
resp = convert_fund(_req(), now=NOW, thresholds=th, **_services(sqlite_engine))
assert resp["engine_error"] is False, "引擎真的跑了且没炸(未走 ImportError 跳过分支)"
assert "RISK-001" in resp["triggered_rules"]
assert len(resp["alert_ids"]) == 1, "一次转换只出一张单"
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
assert len(alerts) == 1
payload = json.loads(alerts[0]["payload"])
assert len(payload["events"]) == 2, "一张单承载两条事件(转出 + 转入)"
assert [e["trade_type"] for e in payload["events"]] == ["redeem", "subscribe"]
# ── 11. T-12 补偿:阶段二失败 → 按 group 补写详情 + 预警(FR-C17 / 架构 §5.4)──
def _low_thresholds() -> RiskThresholds:
"""调低大额阈值,让 120 份的折算额足以命中 RISK-001(与 §10 接线用例同口径)。"""
return RiskThresholds(
large_amount=Decimal("100"),
daily_total=Decimal("1000000"),
freq_count=3,
probe_window_minutes=5,
probe_count=3,
probe_amount=Decimal("400000"),
small_amount=Decimal("10000"),
small_count=3,
concentration_threshold=1.01,
)
def _prepare_failed_convert(sqlite_engine, monkeypatch, cid_req: str) -> str:
"""构造**真实待补偿态**:Core 侧两条流水已成立,但详情与预警双双缺失。
- 阶段二:`complete_convert` 打桩抛异常 → 占位 `failed` + `convert_detail_write_failed` 审计;
- 阶段 1.5:注入会抛的 `engine_hook` → 无预警单 + `engine_error` 审计。
这正是 PRD §7.1 描述的补偿场景(agent 侧两件事都没落)。
返回 convert_group_id;`complete_convert` 已被还原(补偿才能真的写进去)。
"""
_seed(sqlite_engine)
original = ConvertRepository.complete_convert
def boom(self, group_id, **kwargs): # noqa: ANN001
raise RuntimeError("阶段二写失败")
def engine_boom(out_trade, in_trade): # noqa: ANN001
raise RuntimeError("阶段 1.5 引擎失败")
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
resp = convert_fund(
_req(cid_req=cid_req),
now=NOW,
engine_hook=engine_boom,
thresholds=_low_thresholds(),
**_services(sqlite_engine),
)
monkeypatch.setattr(ConvertRepository, "complete_convert", original)
return resp["convert_group_id"]
def _compensate(sqlite_engine, gid: str) -> dict:
svc = _services(sqlite_engine)
return compensate_convert(
gid,
core_ro=svc["core_ro"],
risk_repo=svc["risk_repo"],
convert_repo=svc["convert_repo"],
thresholds=_low_thresholds(),
now=NOW,
)
def _detail_rows(engine, gid: str) -> list[dict]:
return _rows(
engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
)
def test_compensate_rebuilds_detail_and_alerts(sqlite_engine, monkeypatch):
"""补偿把「详情 + 预警」两件事都补齐,且详情直接置 completed(验收 17)。"""
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-001")
# 前置态自检:交易已成立、详情 failed、无预警单
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed"
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
)
) == 1
out = _compensate(sqlite_engine, gid)
assert out["state"] == "rebuilt"
assert out["detail"] == "rebuilt" and out["engine"] == "rebuilt"
assert "RISK-001" in out["triggered_rules"]
assert len(out["alert_ids"]) == 1
row = _detail_rows(sqlite_engine, gid)[0]
assert row["status"] == "completed"
# 补偿回填的指针与 Core 侧一致(幂等锚点 = 转出端)
assert row["out_trade_id"] == out["out_trade_id"]
assert row["in_trade_id"] == out["in_trade_id"]
assert row["nav"] is not None and row["nav_date"] is not None
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1
# 补偿也补了主审计(与首次成功路径同一份 `_write_main_audit`)
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_accepted'",
)
) == 1
def test_compensate_is_idempotent(sqlite_engine, monkeypatch):
"""重复补偿 → `skipped`,不产生第二张预警单、不重复写详情与主审计。"""
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-002")
first = _compensate(sqlite_engine, gid)
second = _compensate(sqlite_engine, gid)
assert first["state"] == "rebuilt"
assert second["state"] == "skipped"
assert second["detail"] == "already_completed"
assert second["engine"] == "skipped"
assert second["alert_ids"] == first["alert_ids"]
assert second["triggered_rules"] == []
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1 # 只有一张单
assert len(
_rows(
sqlite_engine,
"SELECT * FROM audit_log WHERE decision = 'convert_accepted'",
)
) == 1 # 主审计不重复
# 曾阶段 1.5 中断过 → 幂等跳过时附「人工核对」提示(与 rebuild_alerts 同款警示)
assert "人工核对" in second["warning"]
def test_compensate_missing_group_touches_nothing(sqlite_engine):
"""Core 侧不足两条流水 → missing,零写入(不是有效转换组)。"""
_seed(sqlite_engine)
out = _compensate(sqlite_engine, "CNV-T12-NOPE")
assert out["state"] == "missing"
assert out["trade_count"] == 0
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
assert _rows(sqlite_engine, "SELECT * FROM audit_log") == []
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
def test_compensate_returns_locked_when_execution_right_taken(
sqlite_engine, monkeypatch
):
"""未抢到 `convert:rerun:{gid}` → locked(有并发重试/实例在跑),零写入。"""
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-004")
with try_lock(f"convert:rerun:{gid}", 30) as acquired:
assert acquired is True
out = _compensate(sqlite_engine, gid)
assert out["state"] == "locked" and out["alert_ids"] == []
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed" # 仍待补偿
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []