Files
group_xinghuo_jinrong/tests/test_suitability.py
T

334 lines
14 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.
"""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