gendesign/backend/tests/workers/test_scrape_freshness_check.py
Light1YT 6d6abf30b1
Some checks failed
CI / backend-tests (pull_request) Successful in 11m11s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 2m3s
feat(workers): Celery-beat data-freshness monitor with GlitchTip alerts
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)
2026-06-25 16:02:13 +05:00

145 lines
5.4 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.

"""Тесты 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