"""扫描健壮性:脏数据不该让整批停摆,调度器不该"重启后再也不跑"。 这两条都属于"风控看起来在工作、其实没在跑"那类缺陷,现象上很难发现: 1. `_level_value` 原先直接 `int(level.replace(prefix, ""))` —— 一条等级字段脏的数据 (比如写了「中风险」)会抛 ValueError,冒到 `scan()` 的兜底 → **整批 rollback**, 本次扫描前面已经生成的预警全部作废。 2. 调度器的 `last_run_at` 只在内存,重启后为 None,而 `_is_due` 此时返回 `config.run_immediately`(默认 False)→ **重启后永不执行**,且没有任何告警。 """ import datetime as dt from types import SimpleNamespace from typing import Any, cast from app.service.risk_scan_service import _level_value from app.worker.risk_scan_scheduler import RiskScanSchedulerWorker def test_level_value_parses_valid_levels() -> None: assert _level_value("R2", "R") == 2 assert _level_value("C3", "C") == 3 assert _level_value("c5", "C") == 5 # 大小写不敏感 assert _level_value(" 2 ", "R") == 2 # 历史数据可能不带前缀 def test_level_value_returns_none_instead_of_raising_on_dirty_data() -> None: """脏数据返回 None,不抛异常 —— 这是不让整批扫描 rollback 的前提。""" assert _level_value("中风险", "R") is None assert _level_value("", "R") is None assert _level_value("R", "R") is None assert _level_value("R6", "R") == 6 # 越界值交给上层比较,解析本身只负责取数字 def test_level_value_does_not_over_strip() -> None: """用 removeprefix 而非 replace:`R2R` 不该被错当成 2。""" assert _level_value("R2R", "R") is None def _scheduler(*, last_run_at: dt.datetime | None) -> RiskScanSchedulerWorker: """只构造被测方法用到的那两个属性。""" scheduler = object.__new__(RiskScanSchedulerWorker) scheduler.last_run_at = last_run_at return scheduler def _config(*, run_immediately: bool, interval_minutes: int = 30) -> Any: return SimpleNamespace(run_immediately=run_immediately, interval_minutes=interval_minutes) _NOW = dt.datetime(2026, 9, 11, 12, 0, tzinfo=dt.UTC) def test_first_run_is_due_even_when_run_immediately_is_false() -> None: """进程刚起来(last_run_at 为 None)必须视为 due,哪怕是 run_immediately=False。 原先这里返回 run_immediately,默认 False ⇒ 重启后永不执行。 """ scheduler = cast(Any, _scheduler(last_run_at=None)) assert scheduler._is_due(_config(run_immediately=False), _NOW) is True def test_not_due_before_interval_elapses() -> None: scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=10))) assert scheduler._is_due(_config(run_immediately=False), _NOW) is False def test_due_after_interval_elapses() -> None: scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=31))) assert scheduler._is_due(_config(run_immediately=False), _NOW) is True