111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
"""扫描的通知环节:失败必须能被区分出来。
|
||||
|
|
|
|||
|
|
**背景**:`_create_notifications` 原先失败时 `return 0`,而"无需通知"(没有高风险预警、
|
|||
|
|
通知功能关闭)也返回 0 —— 调用方只拿到一个 `notification_count`,分不清两者。
|
|||
|
|
|
|||
|
|
这不是洁癖:**通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"**。
|
|||
|
|
风控系统里这种"看起来在工作、其实有一环没跑"的状态最难发现(同一批缺陷里,
|
|||
|
|
"定时扫描重启后不再执行"也是这个性质)。
|
|||
|
|
|
|||
|
|
所以这里锁住契约:返回 `(条数, 失败原因)`,失败原因非空即代表出了问题。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from types import SimpleNamespace
|
|||
|
|
from typing import Any, cast
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from app.service.risk_scan_service import HIGH_RISK, RiskScanService
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeNested:
|
|||
|
|
async def __aenter__(self) -> "_FakeNested":
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
async def __aexit__(self, *exc: object) -> bool:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeSession:
|
|||
|
|
"""只提供 `begin_nested()`;被测方法不碰其它会话能力。"""
|
|||
|
|
|
|||
|
|
def begin_nested(self) -> _FakeNested:
|
|||
|
|
return _FakeNested()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeNotificationService:
|
|||
|
|
def __init__(self, *, fail: bool = False) -> None:
|
|||
|
|
self.fail = fail
|
|||
|
|
self.in_app: list[str] = []
|
|||
|
|
|
|||
|
|
def create_in_app(self, alert: Any, **kwargs: Any) -> None:
|
|||
|
|
if self.fail:
|
|||
|
|
raise RuntimeError("通知写库失败")
|
|||
|
|
self.in_app.append(str(alert.alert_no))
|
|||
|
|
|
|||
|
|
def create_mail_record(self, alert: Any, **kwargs: Any) -> None:
|
|||
|
|
if self.fail:
|
|||
|
|
raise RuntimeError("邮件记录写库失败")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _alert(level: str, alert_no: str = "ALTEST") -> Any:
|
|||
|
|
return SimpleNamespace(
|
|||
|
|
alert_level=level, alert_no=alert_no, alert_type="大额频繁交易",
|
|||
|
|
evidence_summary="摘要", handler_id=9002,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _service(*, enabled: bool = True, fail: bool = False,
|
|||
|
|
email: str | None = None) -> RiskScanService:
|
|||
|
|
service = object.__new__(RiskScanService)
|
|||
|
|
service.notification_enabled = enabled
|
|||
|
|
service.notification_email = email
|
|||
|
|
service.mail_enabled = False
|
|||
|
|
service.notification_service = cast(Any, _FakeNotificationService(fail=fail))
|
|||
|
|
service.session = cast(Any, _FakeSession())
|
|||
|
|
return service
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_success_reports_count_and_no_failure() -> None:
|
|||
|
|
service = _service()
|
|||
|
|
|
|||
|
|
count, failure = await service._create_notifications([_alert(HIGH_RISK)])
|
|||
|
|
|
|||
|
|
assert count == 1
|
|||
|
|
assert failure == ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_nothing_to_notify_is_not_a_failure() -> None:
|
|||
|
|
"""没有高风险预警,或通知功能关闭——都不算失败,失败原因应为空。"""
|
|||
|
|
service = _service()
|
|||
|
|
assert await service._create_notifications([_alert("低")]) == (0, "")
|
|||
|
|
|
|||
|
|
disabled = _service(enabled=False)
|
|||
|
|
assert await disabled._create_notifications([_alert(HIGH_RISK)]) == (0, "")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_failure_is_reported_not_swallowed(caplog: pytest.LogCaptureFixture) -> None:
|
|||
|
|
"""通知创建失败时必须带出原因——这正是原先丢失的信号。"""
|
|||
|
|
service = _service(fail=True)
|
|||
|
|
|
|||
|
|
with caplog.at_level(logging.ERROR):
|
|||
|
|
count, failure = await service._create_notifications([_alert(HIGH_RISK)])
|
|||
|
|
|
|||
|
|
assert count == 0
|
|||
|
|
assert "RuntimeError" in failure
|
|||
|
|
assert "通知写库失败" in failure
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_failure_reason_differs_from_nothing_to_notify() -> None:
|
|||
|
|
"""两种 0 必须可区分——这是本次修复的全部意义。"""
|
|||
|
|
quiet, _ = await _service()._create_notifications([_alert("低")])
|
|||
|
|
_count, failure = await _service(fail=True)._create_notifications([_alert(HIGH_RISK)])
|
|||
|
|
|
|||
|
|
assert quiet == 0
|
|||
|
|
assert failure != "", "失败必须能与『无需通知』区分开"
|