227 lines
8.5 KiB
Python
227 lines
8.5 KiB
Python
"""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"] == "客户·赵**"
|