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