Files
group_fqcd_jr/tests/unit/service/test_risk_scan_notification.py
T
lzf_0626 d331c817ac fix(risk): 通知创建失败必须能被区分出来,不再静默返回 0
docs/25 P2「通知失败被吞」。核实后要把定性说得更准一点:它**有** logger.exception,
不是完全静默;问题在于 _create_notifications 失败时 
eturn 0,而"无需通知"
(没有高风险预警、或通知功能关闭)也返回 0 —— 调用方只拿到一个 notification_count,
**分不清"本来就不用通知"和"高风险通知创建失败"**。

风控里这个区别很要紧:通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
这与同一批缺陷里的"定时扫描重启后不再执行"是同一性质 —— 看起来在工作、其实有一环没跑。

改动:
- _create_notifications 返回 (条数, 失败原因),失败原因非空即代表出了问题。
- scan() 在有失败时于返回体里带上
otification_failure,并把 message 改成
  "规则扫描完成,但高风险通知创建失败",让上游与运维都能直接看到。
- **失败仍然不回滚预警**:预警已经生成、比通知重要,不该因为通知写失败就丢掉。

新增 tests/unit/service/test_risk_scan_notification.py(4 条),其中一条专门断言
"两种 0 必须可区分" —— 那是本次修复的全部意义。

ruff / mypy(136 文件) / 616 unit+contract 全绿。
2026-09-11 13:33:41 +08:00

111 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""扫描的通知环节:失败必须能被区分出来。
**背景**:`_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 != "", "失败必须能与『无需通知』区分开"