Files
group_xinghuo_jinrong/tests/test_suitability.py
T

202 lines
8.4 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 单测(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)