Files
group_xinghuo_jinrong/tests/test_suitability.py
T

334 lines
14 KiB
Python
Raw Normal View History

"""suitability 单测(AL-07 · main 矩阵契约重写,验收 A-8 等价)。
覆盖(依据 docs/项目框架设计/修改报告-对齐main基准.md AL-05/AL-07):
- 七判定路径各正反例:not_found / risk_expired(FM-03) / professional_exempt
(JR-AST-PRO) / 矩阵 forbidden(JR-AST-012) / allowed / allowed_with_disclosure /
age_branch_confirm(FM-01)
- build_suitability_log_row 逐字段映射 + rule_refs 组合(过期+等级不匹配 →
["JR-AST-012","FM-03"])
- suitability_check 服务集成(21 列落库 / check_source / actor_id / request_ref /
NotFound 结构化不抛)
- sqlite/MySQL 双方言:sqlite 可控日期全路径;MySQL 真库连通时跑方言冒烟
(SQL 可执行性),未灌库自动 skip。
旧 SUIT-001~008 断言已作废(SUIT-006 封顶 → FM-01;SUIT-008 → FM-03)。
"""
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine, seed_suitability_matrix
from app.config.settings import settings
from app.model.suitability import build_suitability_log_row, compute_rule_refs
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.suitability import SuitabilityResult, suitability_check
from app.utils.db import get_engine
FUTURE = "2099-12-31" # 远未来:未过期(可控日期,不依赖 today 注入)
PAST = "2020-01-01" # 远过去:已过期
@pytest.fixture()
def env():
engine = create_sqlite_engine()
seed_suitability_matrix(engine)
core = CoreReadOnlyRepository(engine=engine)
repo = RiskRepository(engine=engine)
yield engine, core, repo
engine.dispose()
def _seed_customer(engine, cid, age, risk_code, expires=FUTURE, category="ordinary"):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active)"
" VALUES (:cid, '客户·测**', :age, 1)"
),
{"cid": cid, "age": age},
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at,"
" expires_at, investor_category)"
" VALUES (:cid, :rc, '2026-01-01', :exp, :cat)"
),
{"cid": cid, "rc": risk_code, "exp": expires, "cat": category},
)
def _seed_product(engine, pid, risk_code, requires_disclosure=0, min_subscribe=100):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type, min_subscribe_amount, term_days, requires_disclosure)"
" VALUES (:pid, '测试产品', :rc, 'fund', :sub, NULL, :disc)"
),
{"pid": pid, "rc": risk_code, "sub": min_subscribe, "disc": requires_disclosure},
)
# ---------- 七判定路径 ----------
class TestSevenPaths:
def test_not_found_customer(self, env):
engine, core, _ = env
_seed_product(engine, "P1", "R3")
r = core.check_suitability("CUST-NOPE", "P1")
assert r["mismatch_type"] == "not_found"
assert r["match_result"] == "forbidden" and r["blocked"] is True
assert r["block_response_code"] == "SUIT_NOT_FOUND"
def test_not_found_product(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C3")
r = core.check_suitability("C1X", "P-NOPE")
assert r["mismatch_type"] == "not_found" and r["blocked"] is True
def test_risk_expired(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C3", expires=PAST)
_seed_product(engine, "P1", "R1") # 即使 R1 全匹配也先命中过期(FM-03 判定在前)
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "risk_expired"
assert r["blocked"] is True and r["block_response_code"] == "SUIT_RISK_EXPIRED"
assert r["risk_is_expired"] is True
def test_professional_exempt(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C2", category="professional")
_seed_product(engine, "P1", "R5") # C2 买 R5 矩阵 forbidden,专业豁免优先
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "professional_exempt"
assert r["blocked"] is False and r["matched"] is True
assert r["block_response_code"] == "SUIT_PROFESSIONAL_EXEMPT"
def test_matrix_forbidden(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C1")
_seed_product(engine, "P1", "R4") # C1×R4 矩阵 forbidden
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "forbidden" and r["mismatch_type"] == "risk_level"
assert r["blocked"] is True and r["block_response_code"] == "SUIT_RISK_MISMATCH"
def test_matrix_missing_row_is_forbidden(self, env):
"""矩阵无记录(数据缺口)→ 与 forbidden 同处置(fail-closed)。"""
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C1")
_seed_product(engine, "P9", "R9") # C1×R9 无矩阵行
r = core.check_suitability("C1X", "P9")
assert r["match_result"] == "forbidden" and r["mismatch_type"] == "risk_level"
def test_allowed(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C2")
_seed_product(engine, "P1", "R2") # C2×R2 allowed
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "allowed"
assert r["blocked"] is False and r["matched"] is True
assert r["block_response_code"] == "SUIT_OK" and r["requires_disclosure"] is False
def test_allowed_with_disclosure_by_matrix(self, env):
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C3")
_seed_product(engine, "P1", "R4", requires_disclosure=0) # C3×R4 矩阵要求披露
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "allowed_with_disclosure"
assert r["requires_disclosure"] is True and r["blocked"] is False
assert r["block_response_code"] == "SUIT_NEED_DISCLOSURE"
def test_allowed_with_disclosure_by_product(self, env):
"""矩阵 allowed 但产品 requires_disclosure=1 → 同样要求披露。"""
engine, core, _ = env
_seed_customer(engine, "C1X", 40, "C2")
_seed_product(engine, "P1", "R2", requires_disclosure=1) # C2×R2 allowed + 产品要求披露
r = core.check_suitability("C1X", "P1")
assert r["match_result"] == "allowed_with_disclosure"
assert r["requires_disclosure"] is True
def test_age_branch_confirm(self, env):
"""FM-01(main 语义,替换 SUIT-006 封顶):≥70 买 R3+ 阻断待网点确认。"""
engine, core, _ = env
_seed_customer(engine, "OLD", 70, "C5")
_seed_product(engine, "P1", "R5") # C5×R5 矩阵 allowed,但年龄触发 FM-01
r = core.check_suitability("OLD", "P1")
assert r["match_result"] == "allowed" # C5×R5 矩阵层 allowed(披露属 C3×R4/C4×R5)
assert r["needs_branch_confirm"] is True and r["blocked"] is True
assert r["mismatch_type"] == "age_branch_confirm"
assert r["block_response_code"] == "SUIT_AGE_CONFIRM"
def test_age_69_no_confirm(self, env):
engine, core, _ = env
_seed_customer(engine, "MID", 69, "C5")
_seed_product(engine, "P1", "R5")
r = core.check_suitability("MID", "P1")
assert r["needs_branch_confirm"] is False and r["blocked"] is False
def test_age_70_r2_no_confirm(self, env):
"""70 岁买 R2(<R3)不触发 FM-01。"""
engine, core, _ = env
_seed_customer(engine, "OLD", 72, "C5")
_seed_product(engine, "P2", "R2")
r = core.check_suitability("OLD", "P2")
assert r["needs_branch_confirm"] is False and r["blocked"] is False
# ---------- rule_refs 组合(AL-04 验收) ----------
class TestRuleRefs:
def test_expired_plus_mismatch(self):
"""报告验收样例:过期+等级不匹配 → ["JR-AST-012","FM-03"]。"""
refs = compute_rule_refs(
{"match_result": "forbidden", "mismatch_type": "risk_level", "risk_is_expired": True}
)
assert refs == ["JR-AST-012", "FM-03"]
def test_age_confirm(self):
refs = compute_rule_refs({"mismatch_type": "age_branch_confirm", "needs_branch_confirm": True})
assert refs == ["FM-01"]
def test_professional(self):
refs = compute_rule_refs({"mismatch_type": "professional_exempt"})
assert refs == ["JR-AST-PRO"]
def test_clean_pass_empty(self):
refs = compute_rule_refs({"match_result": "allowed", "mismatch_type": "none"})
assert refs == []
# ---------- build_suitability_log_row 逐字段映射 ----------
class TestLogRowMapping:
CHECK = {
"customer_id": "CUST-1",
"product_id": "PROD-1",
"product_name": "测试产品",
"customer_risk_code": "C2",
"product_risk_code": "R4",
"investor_category": "ordinary",
"matched": False,
"blocked": True,
"requires_disclosure": False,
"needs_branch_confirm": False,
"risk_is_expired": True,
"reason": "风评已过期(FM-03),须重新测评",
"block_response_code": "SUIT_RISK_EXPIRED",
"match_result": "risk_expired",
"mismatch_type": "risk_expired",
}
def test_full_row_mapping(self):
row = build_suitability_log_row(
self.CHECK,
trace_id="trc-1",
actor_id="CUST-1",
check_source="r02_chat",
request_ref="sess-1",
profile_l1_version=3,
)
assert row["trace_id"] == "trc-1"
assert row["customer_id"] == "CUST-1" and row["product_id"] == "PROD-1"
assert row["product_name"] == "测试产品"
assert row["customer_risk_level"] == "C2" and row["product_risk_level"] == "R4"
assert row["investor_category"] == "ordinary"
assert row["match_result"] == "risk_expired"
assert row["mismatch_type"] == "risk_expired"
assert row["is_matched"] == 0 and row["is_blocked"] == 1
assert row["requires_disclosure"] == 0 and row["needs_branch_confirm"] == 0
assert row["risk_was_expired"] == 1
assert row["block_reason"] == "风评已过期(FM-03),须重新测评"
assert row["block_response_code"] == "SUIT_RISK_EXPIRED"
assert row["check_source"] == "r02_chat"
assert row["actor_id"] == "CUST-1"
assert row["request_ref"] == "sess-1"
assert row["profile_l1_version"] == 3
assert row["rule_refs"] == ["FM-03"]
def test_bool_to_int_conversion(self):
row = build_suitability_log_row(
{
**self.CHECK,
"matched": True,
"blocked": False,
"risk_is_expired": False,
"mismatch_type": "none", # or 语义:仅 mismatch_type 同为非过期才无 refs
"match_result": "allowed",
},
trace_id="t", actor_id="a",
)
assert row["is_matched"] == 1 and row["is_blocked"] == 0
assert row["risk_was_expired"] == 0 and row["rule_refs"] is None
# ---------- 服务集成(签名兼容 + 落库 + NotFound 结构化) ----------
class TestService:
def test_service_end_to_end_log_written(self, env):
engine, core, repo = env
_seed_customer(engine, "C1X", 40, "C1")
_seed_product(engine, "P1", "R4")
r = suitability_check(
"C1X", "P1", core_ro=core, risk_repo=repo,
check_source="r02_trade", actor_id="svc-trade-suitability", request_ref="TRD-1",
)
assert isinstance(r, SuitabilityResult)
assert r.blocked is True and r.block_response_code == "SUIT_RISK_MISMATCH"
assert r.rule_refs == ["JR-AST-012"]
# 兼容字段映射
assert r.rule_id == "SUIT_RISK_MISMATCH"
assert r.customer_level == "C1" and r.product_level == "R4"
assert r.effective_level == r.customer_level
assert r.reasons == ["客户风险等级与产品最低等级不匹配"]
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT match_result, block_response_code, check_source, actor_id,"
" request_ref, rule_refs FROM risk_suitability_log"
)
).first()
assert row[0] == "forbidden" and row[1] == "SUIT_RISK_MISMATCH"
assert row[2] == "r02_trade" and row[3] == "svc-trade-suitability"
assert row[4] == "TRD-1" and row[5] == '["JR-AST-012"]'
def test_service_not_found_does_not_raise(self, env):
"""AL-05 语义变更:客户缺失不抛 LookupError,返回结构化 not_found。"""
engine, core, repo = env
_seed_product(engine, "P1", "R3")
r = suitability_check("CUST-NOPE", "P1", core_ro=core, risk_repo=repo)
assert r.mismatch_type == "not_found" and r.blocked is True
assert r.block_response_code == "SUIT_NOT_FOUND"
def test_service_tool_source_r02_chat(self, env):
"""对话 Tool 链路 check_source='r02_chat'(AL-06)。"""
engine, core, repo = env
_seed_customer(engine, "C1X", 40, "C2")
_seed_product(engine, "P1", "R2")
suitability_check("C1X", "P1", core_ro=core, risk_repo=repo, check_source="r02_chat")
with engine.connect() as conn:
src = conn.execute(text("SELECT check_source FROM risk_suitability_log")).scalar_one()
assert src == "r02_chat"
# ---------- MySQL 方言冒烟(真库连通才跑,SQL 可执行性验收) ----------
class TestMySQLDialect:
def test_check_suitability_executes_on_mysql(self):
"""check_suitability SQL 在 MySQL 真库可执行(AL-03 双方言验收;CURDATE 已移除)。"""
try:
core = CoreReadOnlyRepository() # 默认引擎(MySQL)
with core._engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception:
pytest.skip("MySQL 真库不可达,方言冒烟跳过")
r = core.check_suitability("CUST-NOPE", "PROD-NOPE")
assert r["mismatch_type"] == "not_found" and r["blocked"] is True
l0 = core.get_customer_l0("CUST-NOPE")
assert l0 is None