"""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(