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