150 lines
4.5 KiB
Python
150 lines
4.5 KiB
Python
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from app.core.contracts import RequestContext
|
|
from app.model.audit import InteractionAudit
|
|
from app.model.fund import FundCustomerProfile, FundRiskAlert
|
|
from app.service.risk_action_service import RiskActionError, RiskActionService
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, alerts=None, profiles=None):
|
|
self.values = list(alerts or [])
|
|
self.profiles = list(profiles or [])
|
|
self.added = []
|
|
self.committed = False
|
|
|
|
async def scalar(self, _statement):
|
|
if self.values:
|
|
return self.values.pop(0)
|
|
if self.profiles:
|
|
return self.profiles.pop(0)
|
|
return None
|
|
|
|
def add(self, value):
|
|
self.added.append(value)
|
|
|
|
async def commit(self):
|
|
self.committed = True
|
|
|
|
async def refresh(self, _value):
|
|
return None
|
|
|
|
|
|
def context() -> RequestContext:
|
|
return RequestContext(
|
|
user_id="990000002",
|
|
trace_id="action-trace",
|
|
permissions=("risk:alert:write",),
|
|
data_scope="all",
|
|
)
|
|
|
|
|
|
def alert(
|
|
*,
|
|
status: str = "待处理",
|
|
ack_at: datetime | None = None,
|
|
level: str = "高",
|
|
) -> FundRiskAlert:
|
|
return FundRiskAlert(
|
|
id=1,
|
|
alert_no="ALERT-001",
|
|
customer_id=9,
|
|
alert_type="适当性错配",
|
|
alert_level=level,
|
|
trigger_rule_codes=["RW-007"],
|
|
evidence_summary="证据",
|
|
evidence_snapshot={},
|
|
priority_score=90,
|
|
event_status="正在发生",
|
|
status=status,
|
|
ack_status="已确认" if ack_at else "未确认",
|
|
ack_at=ack_at,
|
|
is_escalated=0,
|
|
created_at=datetime(2026, 9, 10),
|
|
updated_at=datetime(2026, 9, 10),
|
|
)
|
|
|
|
|
|
def profile(score: int = 20) -> FundCustomerProfile:
|
|
return FundCustomerProfile(
|
|
customer_id=9,
|
|
trade_account="ACC-009",
|
|
real_name="张三",
|
|
investor_type="C2",
|
|
total_asset=Decimal("100000.00"),
|
|
behavior_score=score,
|
|
updated_at=datetime(2026, 9, 10),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acknowledge_updates_status_and_audit() -> None:
|
|
session = FakeSession(alerts=[alert()])
|
|
|
|
result = await RiskActionService(session).acknowledge("ALERT-001", context())
|
|
|
|
assert result["ack_status"] == "已确认"
|
|
assert session.committed is True
|
|
assert session.added[0].action_type == "risk_alert_acknowledged"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_investigate_requires_acknowledgement() -> None:
|
|
session = FakeSession(alerts=[alert()])
|
|
|
|
with pytest.raises(RiskActionError, match="确认接收"):
|
|
await RiskActionService(session).investigate("ALERT-001", context())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exclude_requires_reason_and_closes_false_positive() -> None:
|
|
session = FakeSession(alerts=[alert(ack_at=datetime(2026, 9, 10))])
|
|
|
|
result = await RiskActionService(session).exclude("ALERT-001", "客户本人确认", context())
|
|
|
|
assert result["status"] == "已排除"
|
|
assert result["handle_result"] == "客户本人确认"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("level", "before", "deduction", "after"),
|
|
[("低", 20, 3, 17), ("中", 20, 5, 15), ("高", 20, 20, 0), ("高", 4, 20, 0)],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_applies_behavior_score_deduction(
|
|
level: str,
|
|
before: int,
|
|
deduction: int,
|
|
after: int,
|
|
) -> None:
|
|
session = FakeSession(
|
|
alerts=[alert(status="调查中", ack_at=datetime(2026, 9, 10), level=level)],
|
|
profiles=[profile(before)],
|
|
)
|
|
|
|
result = await RiskActionService(session).resolve("ALERT-001", "已核实", context())
|
|
|
|
assert result["status"] == "已结案"
|
|
assert result["behavior_score_deduction"] == deduction
|
|
assert result["behavior_score_after"] == after
|
|
assert any(isinstance(item, InteractionAudit) for item in session.added)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_is_not_terminal_and_rejects_duplicate() -> None:
|
|
session = FakeSession(alerts=[alert(status="调查中", ack_at=datetime(2026, 9, 10))])
|
|
|
|
result = await RiskActionService(session).escalate("ALERT-001", "需要高级复核", context())
|
|
|
|
assert result["is_escalated"] is True
|
|
assert result["status"] == "调查中"
|
|
assert result["escalation_reason"] == "需要高级复核"
|
|
|
|
session = FakeSession(alerts=[alert(status="调查中", ack_at=datetime(2026, 9, 10))])
|
|
session.values[0].is_escalated = 1
|
|
with pytest.raises(RiskActionError, match="已经升级"):
|
|
await RiskActionService(session).escalate("ALERT-001", "重复", context())
|