Files
XingHuo/tests/test_suitability.py
T

202 lines
8.4 KiB
Python
Raw Normal View History

"""suitability 单测(A4 · 验收 A-8):矩阵 25 组合 + SUIT-006/008 边界 + 服务集成。"""
from datetime import date, datetime, timedelta
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.suitability import (
cap_by_age,
check_core,
grade_number,
is_assessment_valid,
match_by_matrix,
suitability_check,
)
TODAY = date(2026, 9, 6)
def _customer(level, age=40, evaluated=TODAY - timedelta(days=90)):
return {
"customer_id": "C1",
"risk_code": level,
"age": age,
"risk_evaluated_at": evaluated,
}
def _product(level):
return {"product_id": "P1", "min_risk_code": level}
class TestMatrix25:
"""SUIT-001~005:C1~C5 × R1~R5 全组合(期望值为硬编码字面量,直接对照附表矩阵审查)。"""
# 行=客户 C1~C5,列=产品 R1~R5(✓=可购),与附-风控规则表 §1 矩阵逐格对照
EXPECTED = [
# R1 R2 R3 R4 R5
[True, False, False, False, False], # C1
[True, True, False, False, False], # C2
[True, True, True, False, False], # C3
[True, True, True, True, False], # C4
[True, True, True, True, True], # C5
]
CUSTOMERS = ["C1", "C2", "C3", "C4", "C5"]
PRODUCTS = ["R1", "R2", "R3", "R4", "R5"]
@pytest.mark.parametrize("c", CUSTOMERS)
@pytest.mark.parametrize("p", PRODUCTS)
def test_matrix(self, c, p):
expect = self.EXPECTED[self.CUSTOMERS.index(c)][self.PRODUCTS.index(p)]
result = check_core(_customer(c), _product(p), today=TODAY)
assert result.is_matched is expect
assert result.blocked is (not expect) # 测评有效期内 blocked ≡ not matched
assert result.customer_level == c and result.effective_level == c
def test_a1_c1_buys_r4(self):
"""验收 A-1 断言口径:C1+R4 → rule_id=SUIT-001(客户维度,PRD 字面)。"""
result = check_core(_customer("C1"), _product("R4"), today=TODAY)
assert result.is_matched is False and result.blocked is True
assert result.rule_id == "SUIT-001"
assert "SUIT-001" in result.block_reason
class TestSUIT006AgeCap:
def test_70yo_c5_capped_to_c3(self):
level, reason = cap_by_age("C5", 70)
assert level == "C3" and "SUIT-006" in reason
def test_69yo_c5_not_capped(self):
assert cap_by_age("C5", 69) == ("C5", None)
def test_70yo_c3_unchanged(self):
assert cap_by_age("C3", 72) == ("C3", None)
def test_null_age_skipped_with_review_hint(self):
level, reason = cap_by_age("C5", None)
assert level == "C5" and reason == "年龄缺失,建议人工复核"
def test_a2_70yo_c5_buys_r4_blocked(self):
"""验收 A-2 断言口径:70 岁 C5 买 R4 → 封顶说明 + SUIT-003 不匹配,无过期干扰。"""
result = check_core(_customer("C5", age=70), _product("R4"), today=TODAY)
assert result.blocked is True and result.is_matched is False
assert result.effective_level == "C3"
assert result.rule_id == "SUIT-003" # 客户判定等级 C3 → SUIT-003(PRD A-2 字面)
assert any("SUIT-006" in r for r in result.reasons)
assert "SUIT-003" in result.block_reason
assert not any("SUIT-008" in r for r in result.reasons)
class TestSUIT008Validity:
def test_364_days_valid(self):
assert is_assessment_valid(TODAY - timedelta(days=364), TODAY, 365) is True
def test_365_days_expired(self):
assert is_assessment_valid(TODAY - timedelta(days=365), TODAY, 365) is False
def test_none_evaluated_expired(self):
assert is_assessment_valid(None, TODAY, 365) is False
def test_expired_but_matched_still_blocked(self):
"""C3 买 R3 等级匹配,但测评过期 → 仍阻断(双字段语义)+ rule_id=SUIT-008。"""
customer = _customer("C3", evaluated=TODAY - timedelta(days=400))
result = check_core(customer, _product("R3"), today=TODAY)
assert result.is_matched is True and result.blocked is True
assert result.rule_id == "SUIT-008"
assert "SUIT-008" in result.reasons[-1]
assert "重新测评" in result.block_reason
def test_custom_valid_days(self):
customer = _customer("C3", evaluated=TODAY - timedelta(days=100))
assert is_assessment_valid(customer["risk_evaluated_at"], TODAY, 90) is False
def test_str_and_datetime_input_compat(self):
"""str/datetime 输入兼容(驱动差异防御,评审 P2-4②)。"""
assert is_assessment_valid("2026-06-01", TODAY, 365) is True
assert is_assessment_valid("2025-06-01", TODAY, 365) is False
assert is_assessment_valid(datetime(2026, 6, 1, 12, 0), TODAY, 365) is True
class TestCheckService:
"""suitability_check 服务集成(sqlite 内存库):落日志 + 原等级落库。"""
@pytest.fixture()
def repos(self):
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, open_date)"
" VALUES ('CUST-1', '客户·测**', 70, '2020-01-01')"
)
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)"
" VALUES ('CUST-1', 'C5', :evaluated)"
),
{"evaluated": TODAY - timedelta(days=90)},
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, product_type, min_risk_code)"
" VALUES ('PROD-R4', '测试R4产品', 'stock', 'R4')"
)
)
core_ro = CoreReadOnlyRepository(engine=engine)
risk_repo = RiskRepository(engine=engine)
yield core_ro, risk_repo, engine
engine.dispose()
def test_service_blocked_and_log_written(self, repos):
core_ro, risk_repo, engine = repos
result = suitability_check(
"CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY
)
assert result.blocked is True and result.effective_level == "C3"
# 落库 customer_risk_level = 原测评等级 C5(封顶只进 reasons,PRD FR-2)
row = engine.connect().execute(
text(
"SELECT customer_risk_level, product_risk_level, is_matched, is_blocked"
" FROM risk_suitability_log"
)
).first()
assert row[0] == "C5" and row[1] == "R4" and row[2] == 0 and row[3] == 1
def test_service_unknown_customer_raises(self, repos):
core_ro, risk_repo, _ = repos
with pytest.raises(LookupError):
suitability_check("NOPE", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY)
def test_service_default_valid_days_from_settings(self, repos, monkeypatch):
"""P1-1 回归:不传 valid_days 时取 settings(.env 可配性)。
CUST-1 为 70 岁 C5(封顶 C3 买 R4 本就不匹配,主因 SUIT-003 优先);
配置阈值降到 90 后 90 天前的测评命中过期 → reasons 出现 SUIT-008 行即证明配置生效。
"""
from app.config.settings import settings as app_settings
core_ro, risk_repo, _ = repos
monkeypatch.setattr(app_settings, "risk_assessment_valid_days", 90)
result = suitability_check("CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY)
assert result.blocked is True and result.rule_id == "SUIT-003"
assert any("SUIT-008" in r for r in result.reasons)
def test_service_request_ref_passthrough(self, repos):
"""P2-2:request_ref 透传落库(B5 网关传 trade_id 用)。"""
core_ro, risk_repo, engine = repos
suitability_check(
"CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo,
today=TODAY, request_ref="TRD-TEST-1",
)
row = engine.connect().execute(
text("SELECT request_ref, trace_id FROM risk_suitability_log")
).first()
assert row[0] == "TRD-TEST-1"
assert row[1] and row[1].startswith("trc-") # 无上游 trace 时兜底生成(P2-1)