gendesign/backend/tests/workers/test_scrape_freshness_check.py
bot-backend 55811ff7cc
All checks were successful
Deploy / changes (push) Successful in 15s
Deploy / build-backend (push) Successful in 1m58s
Deploy / build-worker (push) Successful in 3m9s
Deploy / build-frontend (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m24s
feat(workers): data-freshness monitor (Celery beat → GlitchTip)
Extract compute_freshness(db) from the /admin/scrape/freshness handler (endpoint unchanged) + daily beat task scrape_freshness_check that alerts via sentry_sdk.capture_message when a source is stale/failed (error on critical-failed, else warning). Registered in celery include + beat (09:00 MSK). Refs #73
2026-06-25 11:29:34 +00:00

145 lines
5.4 KiB
Python
Raw Permalink 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