"""risk_repository 单测(A3 · sqlite 内存库;JSON/ENUM 列用 TEXT 兼容)。 L3 为 insert_l3/update_l3 两方法(无 MySQL 方言残留),更新路径已测。 """ import json from datetime import datetime from decimal import Decimal import pytest from sqlalchemy import text from _ddl import create_sqlite_engine from app.repository.risk_repository import RiskRepository @pytest.fixture() def repo(): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) def make_alert(alert_id, alert_type="large_amount", cid="C1", rules=None, score=70): return { "alert_id": alert_id, "trace_id": "trc-test", "customer_id": cid, "trade_id": "TRD-1", "alert_type": alert_type, "triggered_rules": rules or ["RISK-001"], "risk_score": score, "status": "pending_review", "payload": {"product_id": "P1", "events": []}, } yield RiskRepository(engine=engine), engine, make_alert engine.dispose() def test_insert_and_get_alert(repo): r, _, make_alert = repo r.insert_alert(make_alert("ALT-1")) got = r.get_alert("ALT-1") assert got["alert_type"] == "large_amount" assert got["triggered_rules"] == ["RISK-001"] assert got["payload"]["product_id"] == "P1" assert got["status"] == "pending_review" def test_find_pending_event_alert(repo): r, _, make_alert = repo r.insert_alert(make_alert("ALT-E", alert_type="large_amount")) found = r.find_pending_event_alert("C1", datetime(2026, 1, 1)) assert found is not None and found["alert_id"] == "ALT-E" # suitability 类型不在事件类查询范围 r.insert_alert(make_alert("ALT-S", alert_type="suitability")) assert r.find_pending_event_alert("C1", datetime(2026, 1, 1))["alert_id"] == "ALT-E" def test_find_pending_suitability_alert_matches_product(repo): r, _, make_alert = repo r.insert_alert(make_alert("ALT-S", alert_type="suitability")) assert r.find_pending_suitability_alert("C1", "P1", datetime(2026, 1, 1)) is not None assert r.find_pending_suitability_alert("C1", "P2", datetime(2026, 1, 1)) is None def test_append_alert_event_merges(repo): r, _, make_alert = repo r.insert_alert(make_alert("ALT-1", rules=["RISK-001"], score=70)) r.append_alert_event( "ALT-1", event={"trade_id": "TRD-2", "amount": "600000"}, triggered_rules=["RISK-002", "RISK-001"], risk_score=50, alert_type="large_amount", ) got = r.get_alert("ALT-1") assert got["triggered_rules"] == ["RISK-001", "RISK-002"] # 合并未重复 assert got["risk_score"] == 70 # 取 max,不被 50 降 assert len(got["payload"]["events"]) == 1 assert got["payload"]["events"][0]["trade_id"] == "TRD-2" # 再追加一笔,score 升到 90 r.append_alert_event("ALT-1", {"trade_id": "TRD-3"}, ["RISK-005"], 80, "pattern") got = r.get_alert("ALT-1") assert got["risk_score"] == 80 assert got["alert_type"] == "pattern" # 类型随最高分规则更新 assert len(got["payload"]["events"]) == 2 def test_append_missing_alert_raises(repo): r, _, _ = repo with pytest.raises(ValueError): r.append_alert_event("NOPE", {}, ["RISK-001"], 70, "large_amount") def test_update_alert_status_state_machine(repo): r, _, make_alert = repo r.insert_alert(make_alert("ALT-1")) assert r.update_alert_status("ALT-1", "confirmed_suspicious", "STAFF-30001", "确认可疑") is True got = r.get_alert("ALT-1") assert got["status"] == "confirmed_suspicious" assert got["handler_id"] == "STAFF-30001" assert got["handled_at"] is not None # 已处置不可再改(状态机只允许 pending_review → 三态) assert r.update_alert_status("ALT-1", "confirmed_normal", "STAFF-30002", None) is False def test_list_alerts_filters_and_pages(repo): r, _, make_alert = repo for i in range(1, 4): r.insert_alert(make_alert(f"ALT-{i}", alert_type="large_amount" if i < 3 else "aml")) items, total = r.list_alerts(alert_type="large_amount", page=1, page_size=20) assert total == 2 and len(items) == 2 items, total = r.list_alerts(alert_type="aml") assert total == 1 and items[0]["alert_id"] == "ALT-3" items, total = r.list_alerts(customer_id="C1") assert total == 3 def test_list_alerts_pagination_offset(repo): """分页 offset 路径(评审 P2-4①)。""" r, _, make_alert = repo for i in range(1, 4): r.insert_alert(make_alert(f"ALT-{i}")) page1, total = r.list_alerts(page=1, page_size=2) page2, _ = r.list_alerts(page=2, page_size=2) assert total == 3 and len(page1) == 2 and len(page2) == 1 ids = [a["alert_id"] for a in page1] + [a["alert_id"] for a in page2] assert set(ids) == {"ALT-1", "ALT-2", "ALT-3"} assert not set(a["alert_id"] for a in page1) & set(a["alert_id"] for a in page2) def test_find_pending_excludes_previous_day(repo): """跨日反向:昨日 pending 单不命中当日查询(评审 P2-4③)。""" r, engine, make_alert = repo r.insert_alert(make_alert("ALT-OLD")) with engine.begin() as conn: conn.execute( text("UPDATE risk_alert SET created_at = :ts WHERE alert_id = 'ALT-OLD'"), {"ts": datetime(2026, 9, 5, 23, 0)}, ) assert r.find_pending_event_alert("C1", datetime(2026, 9, 6, 0, 0)) is None def test_append_alert_event_empty_rules(repo): """空 triggered_rules 合并(评审 P2-4④):不破坏已有规则且 score 仍可升。""" r, _, make_alert = repo r.insert_alert(make_alert("ALT-1", rules=["RISK-001"], score=70)) r.append_alert_event("ALT-1", {"trade_id": "TRD-2"}, [], 90, "pattern") got = r.get_alert("ALT-1") assert got["triggered_rules"] == ["RISK-001"] assert got["risk_score"] == 90 and got["alert_type"] == "pattern" def test_insert_suitability_log(repo): """AL-05 对齐 main 契约:21 列全列 INSERT + rule_refs JSON 序列化落库。""" r, engine, _ = repo log = { "trace_id": "trc-x", "customer_id": "C1", "product_id": "P1", "product_name": "测试产品", "customer_risk_level": "C1", "product_risk_level": "R4", "investor_category": "ordinary", "match_result": "forbidden", "mismatch_type": "risk_level", "is_matched": 0, "is_blocked": 1, "requires_disclosure": 0, "needs_branch_confirm": 0, "risk_was_expired": 0, "block_reason": "客户风险等级与产品最低等级不匹配", "block_response_code": "SUIT_RISK_MISMATCH", "check_source": "r02_trade", "actor_id": "svc-trade-suitability", "request_ref": None, "profile_l1_version": None, "rule_refs": ["JR-AST-012"], } r.insert_suitability_log(log) row = engine.connect().execute( text( "SELECT customer_risk_level, is_blocked, match_result, block_response_code," " check_source, actor_id, rule_refs FROM risk_suitability_log" ) ).first() assert row[0] == "C1" and row[1] == 1 assert row[2] == "forbidden" and row[3] == "SUIT_RISK_MISMATCH" assert row[4] == "r02_trade" and row[5] == "svc-trade-suitability" assert row[6] == '["JR-AST-012"]' def test_l3_insert_and_get(repo): r, _, _ = repo r.insert_l3( "C1", "high", 95, {"aml": True}, ["aml_hit_pending_review"], "ALT-AML-1", datetime(2026, 9, 6) ) got = r.get_l3("C1") assert got["monitor_tier"] == "high" assert got["monitor_tags"] == ["aml_hit_pending_review"] assert got["score_dimensions"] == {"aml": True} def test_l3_update_after_insert(repo): r, _, _ = repo r.insert_l3("C1", "watch", 80, {}, ["pattern"], "ALT-1", datetime(2026, 9, 6)) r.update_l3("C1", "high", 95, {"aml": True}, ["pattern", "aml_hit_pending_review"], "ALT-2", datetime(2026, 9, 6, 12)) got = r.get_l3("C1") assert got["monitor_tier"] == "high" assert got["monitor_tags"] == ["pattern", "aml_hit_pending_review"] assert got["last_alert_id"] == "ALT-2" def test_list_active_aml_entries(repo): r, engine, _ = repo with engine.begin() as conn: conn.execute( text( "INSERT INTO risk_aml_list (list_id, list_type, full_name, is_active) VALUES" "('AML-1', 'sanction', '客户·赵**', 1)," "('AML-2', 'pep', '客户·已停**', 0)" ) ) entries = r.list_active_aml_entries() assert len(entries) == 1 assert entries[0]["full_name"] == "客户·赵**"