Files
group_xinghuo_jinrong/tests/test_escalation_service.py
T

187 lines
7.1 KiB
Python
Raw Normal View History

"""C5 / FR-9 · RISK-007 处置时效升级:扫描判级 + 幂等 + 降噪合并 + 通知链 + payload 隔离。
覆盖实现方案 §6.2 C5 相关用例(普通 4h/24h、AML 1h/4h 短通道、重复扫描幂等、
同客户多单合并一次推送、处置后退出扫描、status 全程 pending_review、
notify_role 链 L1 含 risk_manager / L2 含 compliance、update_alert_escalation
不碰 handler 列)。
环境:复用 conftest 的 sqlite_engine(StaticPool 单连接共享)+ backdated_alert
回拨注入;RiskRepository 一律 `engine=sqlite_engine` 与注入同源。
"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from sqlalchemy import text
from app.repository.risk_repository import RiskRepository
from app.service.risk import redis_gateway
from app.service.risk.escalation_service import (
EscalationThresholds,
compute_level,
scan_and_escalate,
)
class FakePublisher:
def __init__(self):
self.messages: list = []
def publish(self, channel, payload):
self.messages.append((channel, payload))
def delete(self, *keys):
pass
@pytest.fixture()
def env(sqlite_engine, monkeypatch):
"""注入 fake 发布器(升级通知不依赖真实 Redis)。"""
repo = RiskRepository(engine=sqlite_engine)
pub = FakePublisher()
redis_gateway.set_gateway(pub)
yield repo, pub, sqlite_engine
redis_gateway.set_gateway(None)
def _now_after(alert_created_at: datetime, hours: float) -> datetime:
"""扫描时刻相对回拨注入时刻再后移(避免边界竞态)。"""
return alert_created_at + timedelta(hours=hours)
# ---------- 判级单测(compute_level) ----------
def test_compute_level_boundary():
th = EscalationThresholds()
base = datetime(2026, 9, 6, 12, 0, 0)
def mk(hours_ago):
return {"alert_type": "pattern", "created_at": base - timedelta(hours=hours_ago)}
assert compute_level(mk(3.9), base, th) == 0
assert compute_level(mk(4.0), base, th) == 1
assert compute_level(mk(23.9), base, th) == 1
assert compute_level(mk(24.0), base, th) == 2
def test_compute_level_aml_short_channel():
th = EscalationThresholds()
base = datetime(2026, 9, 6, 12, 0, 0)
def mk(hours_ago):
return {"alert_type": "aml", "created_at": base - timedelta(hours=hours_ago)}
assert compute_level(mk(0.9), base, th) == 0
assert compute_level(mk(1.0), base, th) == 1
assert compute_level(mk(3.9), base, th) == 1
assert compute_level(mk(4.0), base, th) == 2
# ---------- 扫描集成 ----------
def test_no_escalation_below_l1(env, backdated_alert):
repo, pub, engine = env
backdated_alert("ALT-E-1", "C1", hours_ago=3.9)
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert result["escalated"] == []
assert result["skipped"] >= 1
alert = repo.get_alert("ALT-E-1")
assert alert["payload"].get("escalation_level", 0) == 0
assert alert["status"] == "pending_review"
def test_escalate_to_l1_at_4h(env, backdated_alert):
repo, pub, engine = env
backdated_alert("ALT-E-2", "C1", hours_ago=4.1)
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert len(result["escalated"]) == 1
assert result["escalated"][0]["level"] == 1
alert = repo.get_alert("ALT-E-2")
assert alert["payload"]["escalation_level"] == 1
assert alert["status"] == "pending_review"
# 通知链 L1 含 risk_manager(不含 compliance)
assert len(pub.messages) == 1
notify = pub.messages[0][1]["notify_role"]
assert "risk_officer" in notify and "risk_manager" in notify
assert "compliance" not in notify
def test_escalate_to_l2_at_24h(env, backdated_alert):
repo, pub, engine = env
backdated_alert("ALT-E-3", "C1", hours_ago=24.1)
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert result["escalated"][0]["level"] == 2
alert = repo.get_alert("ALT-E-3")
assert alert["payload"]["escalation_level"] == 2
notify = pub.messages[0][1]["notify_role"]
assert "compliance" in notify # L2 升级到合规
def test_aml_escalate_at_1h(env, backdated_alert):
repo, pub, engine = env
backdated_alert("ALT-E-AML", "C1", hours_ago=1.1, alert_type="aml", risk_score=95)
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert result["escalated"][0]["level"] == 1
assert repo.get_alert("ALT-E-AML")["payload"]["escalation_level"] == 1
def test_idempotent_repeat_scan(env, backdated_alert):
"""同单重复扫描不重复推送(幂等闸门:仅升不降)。"""
repo, pub, engine = env
backdated_alert("ALT-E-IDEM", "C1", hours_ago=5)
scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert len(pub.messages) == 1
# 第二次:已达 L1,computed_level 不大于 current_level → 跳过,不再推送
result2 = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert result2["escalated"] == []
assert len(pub.messages) == 1
# 第三次:即便继续超时(now 后移),仍只升到 L2 一次,不再重复 L1 推送
scan_and_escalate(
now=datetime.now() + timedelta(hours=30),
risk_repo=repo,
thresholds=EscalationThresholds(),
)
# 仅 L1 一次 + L2 一次 = 2 条推送
assert len(pub.messages) == 2
def test_merge_same_customer_level(env, backdated_alert):
"""同客户多单同级别合并一次推送(降噪)。"""
repo, pub, engine = env
backdated_alert("ALT-E-M1", "C1", hours_ago=5)
backdated_alert("ALT-E-M2", "C1", hours_ago=6)
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert len(result["escalated"]) == 2 # 两张单都升级
assert result["merged_notices"] == 1 # 但只合并推送一次
assert len(pub.messages) == 1
assert pub.messages[0][1]["merged_count"] == 2
def test_handled_exits_scan(env, backdated_alert):
"""人工处置后 status 不再是 pending_review → 退出扫描范围。"""
repo, pub, engine = env
backdated_alert("ALT-E-H", "C1", hours_ago=10)
# 模拟人工处置(状态机变更)
repo.update_alert_status("ALT-E-H", "confirmed_normal", "STAFF-90001", "已核实")
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
assert result["scanned"] == 0
assert result["escalated"] == []
def test_update_alert_escalation_keeps_handler_columns(env, backdated_alert):
"""升级写入只动 payload,不碰 status / handler_id / handler_result / handled_at。"""
repo, pub, engine = env
backdated_alert("ALT-E-HC", "C1", hours_ago=5)
ok = repo.update_alert_escalation("ALT-E-HC", 1, datetime.now(), "TRACE-X")
assert ok is True
alert = repo.get_alert("ALT-E-HC")
assert alert["status"] == "pending_review"
assert alert["handler_id"] is None
assert alert["handler_result"] is None
assert alert["handled_at"] is None
assert alert["payload"]["escalation_level"] == 1