Files
group_xinghuo_jinrong/tests/test_risk_repository.py
T

272 lines
10 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 create_engine, text
from app.repository.risk_repository import RiskRepository
@pytest.fixture()
def repo():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE risk_alert (
alert_id VARCHAR(64) PRIMARY KEY,
trace_id VARCHAR(64),
customer_id VARCHAR(64),
trade_id VARCHAR(64),
alert_type VARCHAR(16),
triggered_rules TEXT,
risk_score INTEGER,
status VARCHAR(24) DEFAULT 'pending_review',
payload TEXT,
handler_id VARCHAR(64),
handler_result VARCHAR(64),
handler_comment VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
handled_at TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE customer_profile_l3 (
customer_id VARCHAR(64) PRIMARY KEY,
monitor_tier VARCHAR(8) DEFAULT 'normal',
risk_score INTEGER,
score_dimensions TEXT,
monitor_tags TEXT,
last_alert_id VARCHAR(64),
computed_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE risk_aml_list (
id INTEGER PRIMARY KEY,
list_id VARCHAR(64),
list_type VARCHAR(8),
full_name VARCHAR(128),
match_threshold NUMERIC(3,2) DEFAULT 0.85,
is_active INTEGER DEFAULT 1
)
"""
)
)
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):
r, engine, _ = repo
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE risk_suitability_log ("
"id INTEGER PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64),"
" product_id VARCHAR(64), customer_risk_level VARCHAR(2),"
" product_risk_level VARCHAR(2), is_matched INTEGER, is_blocked INTEGER,"
" block_reason VARCHAR(512), request_ref VARCHAR(64), profile_l1_version INTEGER,"
" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
)
log = {
"trace_id": "trc-x",
"customer_id": "C1",
"product_id": "P1",
"customer_risk_level": "C1",
"product_risk_level": "R4",
"is_matched": 0,
"is_blocked": 1,
"block_reason": "SUIT-001",
"request_ref": None,
"profile_l1_version": None,
}
r.insert_suitability_log(log)
row = engine.connect().execute(
text("SELECT customer_risk_level, is_blocked FROM risk_suitability_log")
).first()
assert row[0] == "C1" and row[1] == 1
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"] == "客户·赵**"