Extract compute_freshness(db) from the /admin/scrape/freshness handler (single source of truth, endpoint response unchanged) and add a daily beat task scrape_freshness_check that alerts via sentry_sdk.capture_message when a source is stale/failed — level=error if a critical source fully failed, else warning; also logs at matching severity so a no-DSN worker still surfaces it. Registered in celery include + beat (09:00 MSK daily, after the nightly scraper cluster). Closes #freshness-monitor (rank 7 follow-up)
145 lines
5.4 KiB
Python
145 lines
5.4 KiB
Python
"""Тесты beat-таски scrape_freshness_check — алёрт в GlitchTip при stale/failed источниках.
|
||
|
||
Сеть/БД не дёргаются: мокаем SessionLocal + compute_freshness (единый источник истины) +
|
||
sentry_sdk.capture_message. Проверяем: severity-маппинг (error при failed критичного,
|
||
warning при stale), сообщение называет проблемные источники, all-ok → нет алёрта, ошибка
|
||
агрегации → graceful (исключение не пробрасывается).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
|
||
def _source(
|
||
name: str,
|
||
status: str,
|
||
*,
|
||
critical: bool = False,
|
||
age_days: float | None = 1.0,
|
||
) -> dict[str, Any]:
|
||
"""Минимальный source-dict в форме compute_freshness (нужные таске поля)."""
|
||
return {"source": name, "status": status, "critical": critical, "age_days": age_days}
|
||
|
||
|
||
def _payload(sources: list[dict[str, Any]], overall: str) -> dict[str, Any]:
|
||
return {"generated_at": "2026-06-25T09:00:00+00:00", "overall_status": overall,
|
||
"sources": sources}
|
||
|
||
|
||
def _run_with(payload_or_exc: Any) -> tuple[dict[str, Any], MagicMock]:
|
||
"""Запускает таску с замоканными SessionLocal/compute_freshness/capture_message.
|
||
|
||
payload_or_exc: dict → возвращается из compute_freshness; Exception → бросается.
|
||
Возвращает (summary, capture_message_mock).
|
||
"""
|
||
from app.workers.tasks import scrape_freshness_check as task_mod
|
||
|
||
db = MagicMock()
|
||
if isinstance(payload_or_exc, Exception):
|
||
compute = MagicMock(side_effect=payload_or_exc)
|
||
else:
|
||
compute = MagicMock(return_value=payload_or_exc)
|
||
|
||
with (
|
||
patch.object(task_mod, "SessionLocal", return_value=db),
|
||
patch.object(task_mod, "compute_freshness", compute),
|
||
patch.object(task_mod.sentry_sdk, "capture_message") as cap,
|
||
):
|
||
summary = task_mod.scrape_freshness_check.run()
|
||
|
||
# Сессию всегда закрываем (try/finally).
|
||
db.close.assert_called_once()
|
||
return summary, cap
|
||
|
||
|
||
def test_stale_and_failed_emit_single_alert() -> None:
|
||
"""Один stale + один failed (некритичный) → ровно один capture_message, level=warning,
|
||
сообщение называет оба источника."""
|
||
payload = _payload(
|
||
[
|
||
_source("kn", "ok", critical=True),
|
||
_source("objective", "stale", critical=True, age_days=10.0),
|
||
_source("nspd", "failed", critical=False, age_days=None),
|
||
],
|
||
overall="stale",
|
||
)
|
||
summary, cap = _run_with(payload)
|
||
|
||
cap.assert_called_once()
|
||
args, kwargs = cap.call_args
|
||
message = args[0]
|
||
assert kwargs["level"] == "warning" # упал НЕкритичный → warning
|
||
assert "objective" in message
|
||
assert "nspd" in message
|
||
assert "kn" not in message # ok-источник не упоминается
|
||
|
||
assert summary["alerted"] is True
|
||
assert summary["level"] == "warning"
|
||
assert summary["stale"] == 1
|
||
assert summary["failed"] == 1
|
||
assert summary["alertable"] == 2
|
||
assert summary["checked"] == 3
|
||
|
||
|
||
def test_critical_failed_escalates_to_error() -> None:
|
||
"""Критичный источник полностью упал (failed) → level=error."""
|
||
payload = _payload(
|
||
[
|
||
_source("kn", "failed", critical=True, age_days=None),
|
||
_source("nspd", "stale", critical=False, age_days=12.0),
|
||
],
|
||
overall="failed",
|
||
)
|
||
summary, cap = _run_with(payload)
|
||
|
||
cap.assert_called_once()
|
||
_, kwargs = cap.call_args
|
||
assert kwargs["level"] == "error"
|
||
assert summary["level"] == "error"
|
||
assert summary["alerted"] is True
|
||
|
||
|
||
def test_all_ok_emits_no_alert() -> None:
|
||
"""Все источники ok → capture_message НЕ вызывается."""
|
||
payload = _payload(
|
||
[
|
||
_source("kn", "ok", critical=True),
|
||
_source("objective", "ok", critical=True),
|
||
_source("nspd", "ok"),
|
||
],
|
||
overall="ok",
|
||
)
|
||
summary, cap = _run_with(payload)
|
||
|
||
cap.assert_not_called()
|
||
assert summary["alerted"] is False
|
||
assert summary["level"] is None
|
||
assert summary["alertable"] == 0
|
||
assert summary["overall_status"] == "ok"
|
||
|
||
|
||
def test_compute_freshness_raising_is_graceful() -> None:
|
||
"""compute_freshness бросает → таска логирует и возвращает {'error': ...}, не падает."""
|
||
summary, cap = _run_with(RuntimeError("db boom"))
|
||
|
||
cap.assert_not_called()
|
||
assert summary == {"error": "db boom"}
|
||
|
||
|
||
def test_endpoint_delegates_to_compute_freshness() -> None:
|
||
"""Рефактор: HTTP-эндпоинт scrape_freshness просто возвращает compute_freshness(db).
|
||
|
||
Без живой БД: monkeypatch compute_freshness → проверяем, что handler отдаёт его
|
||
результат байт-в-байт и прокидывает ту же сессию.
|
||
"""
|
||
from app.api.v1 import admin_scrape
|
||
|
||
sentinel = {"generated_at": "x", "overall_status": "ok", "sources": []}
|
||
db = MagicMock()
|
||
with patch.object(admin_scrape, "compute_freshness", return_value=sentinel) as comp:
|
||
result = admin_scrape.scrape_freshness(db)
|
||
|
||
comp.assert_called_once_with(db)
|
||
assert result is sentinel
|