"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846). Покрывает: 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте / загрузка встала / полных прогонов не было вовсе / граница порога. 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность: при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой. 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы отставшее табло). 4. Свойства миграций 180/212 + регистрацию в kit product_handlers. Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index): latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток), dinamika-tsen-obyavlenii = 2026-05-01 (103); последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639); такт загрузки interval_days = 7; прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается. """ from __future__ import annotations import os import re from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import Any import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.product_handlers import build_product_handlers from app.tasks import sber_freshness_monitor as mon _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" _MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" # Прод-состояние 2026-08-12. _JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток _MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103 _LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639 _PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days _PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) # ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── def test_loader_in_cadence_no_alert_even_at_age_72() -> None: """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим. После полного прогона наш max(period_month) равен максимуму источника ПО ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера, а не наше отставание. """ v = mon.evaluate_sber_freshness( _JUN_2026, _PROD_NOW, last_complete_pull_at=_LAST_FULL_PULL, pull_interval_days=_PULL_INTERVAL, ) assert v.age_days == 72 assert v.pull_lag_days == 6 assert v.stale is False def test_loader_stalled_alerts_at_the_same_age() -> None: """Тот же возраст периода, но полный прогон 20 суток назад → тревога. 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14. """ v = mon.evaluate_sber_freshness( _JUN_2026, _PROD_NOW, last_complete_pull_at=_now(2026, 7, 23), pull_interval_days=_PULL_INTERVAL, ) assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше assert v.pull_lag_days == 20 assert v.max_pull_lag_days == 14 assert v.stale is True def test_no_complete_pull_ever_alerts() -> None: """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина.""" v = mon.evaluate_sber_freshness( _JUN_2026, _PROD_NOW, last_complete_pull_at=None, pull_interval_days=_PULL_INTERVAL, ) assert v.stale is True assert v.pull_lag_days == -1 def test_threshold_boundary_exact_no_alert() -> None: """Ровно на пороге (2 такта) алерта ещё нет — строгое >.""" v = mon.evaluate_sber_freshness( _JUN_2026, _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL), last_complete_pull_at=_LAST_FULL_PULL, pull_interval_days=_PULL_INTERVAL, ) assert v.pull_lag_days == 14 assert v.stale is False def test_threshold_boundary_next_day_alerts() -> None: """Порог + 1 сутки — первый алерт (два такта подряд пропущены).""" v = mon.evaluate_sber_freshness( _JUN_2026, _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1), last_complete_pull_at=_LAST_FULL_PULL, pull_interval_days=_PULL_INTERVAL, ) assert v.pull_lag_days == 15 assert v.stale is True def test_threshold_follows_pull_cadence() -> None: """Порог — производная такта загрузки, а не константа: такт 28 → порог 56.""" v = mon.evaluate_sber_freshness( _JUN_2026, _PROD_NOW, last_complete_pull_at=_now(2026, 7, 23), # 20 суток pull_interval_days=28, ) assert v.max_pull_lag_days == 56 assert v.stale is False # при месячном такте 20 суток — норма # ── check_sber_freshness (FakeDB) ───────────────────────────────────────────── class _Row: def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) class _FakeResult: def __init__(self, row: _Row | None) -> None: self._row = row def first(self) -> _Row | None: return self._row class _FakeDB: """Отвечает на три запроса монитора; пригоден и для старой версии кода. Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой запрос отдаём максимум по всем табло, ровно как это делал бы Postgres. """ def __init__( self, latest_by_dash: dict[str, date], last_pull: datetime | None = _LAST_FULL_PULL, interval_days: str | None = str(_PULL_INTERVAL), ) -> None: self._latest_by_dash = latest_by_dash self._last_pull = last_pull self._interval_days = interval_days self.rolled_back = False self.asked_dashboards: list[str] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) params = params or {} if "scrape_runs" in sql: return _FakeResult(_Row(last_pull=self._last_pull)) if "scrape_schedules" in sql: return _FakeResult(_Row(interval_days=self._interval_days)) if "dash" in params: self.asked_dashboards.append(params["dash"]) return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"]))) # Старый монитор: max(period_month) по всей таблице. latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None return _FakeResult(_Row(latest=latest)) def rollback(self) -> None: self.rolled_back = True def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0} monkeypatch.setattr( mon.runs_mod, "update_heartbeat", lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1), ) monkeypatch.setattr( mon.runs_mod, "mark_done", lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)), ) monkeypatch.setattr( mon.runs_mod, "mark_failed", lambda _db, run_id, err, counters: calls.__setitem__("failed", err), ) return calls def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB: """Прод-состояние 2026-08-12 (оба табло вторички).""" return _FakeDB( {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026}, last_pull=last_pull, ) def test_prod_replay_healthy_loader_silent_source_no_alert( monkeypatch: pytest.MonkeyPatch, ) -> None: """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина. На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же. """ calls = _patch_runs(monkeypatch) db = _prod_db() out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["age_days"] == 72 assert out["alert"] == 0 assert calls["done"] == out assert calls["failed"] is None def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None: """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой. На main оба состояния дают alert=1 (вердикт зависит только от календаря) — сторож не умеет зеленеть, что и было исходным дефектом. """ _patch_runs(monkeypatch) healthy = mon.check_sber_freshness( _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type] run_id=2, params={}, now=_PROD_NOW, ) stalled = mon.check_sber_freshness( _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type] run_id=3, params={}, now=_PROD_NOW, ) assert healthy["age_days"] == stalled["age_days"] == 72 assert (healthy["alert"], stalled["alert"]) == (0, 1) def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None: """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит. Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт — SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь ровно то состояние, что дал бы прод с одним лишь id=37. """ _patch_runs(monkeypatch) out = mon.check_sber_freshness( _prod_db(last_pull=None), # type: ignore[arg-type] run_id=4, params={}, now=_PROD_NOW, ) assert out["alert"] == 1 assert out["pull_lag_days"] == -1 def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None: """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице).""" _patch_runs(monkeypatch) db = _prod_db() out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert db.asked_dashboards[0] == "real_estate_deals" assert (out["latest_year"], out["latest_month"]) == (2026, 6) def test_falls_back_to_next_dashboard_when_first_empty( monkeypatch: pytest.MonkeyPatch, ) -> None: """Первое табло пусто → берём следующее, как и оценщик.""" _patch_runs(monkeypatch) db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026}) out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["latest_month"] == 5 assert out["age_days"] == 103 def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: """Ни одного табло с данными — оценивать нечего, это сбой монитора.""" calls = _patch_runs(monkeypatch) out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["alert"] == 0 assert calls["done"] is None assert calls["failed"] is not None def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь. Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180). С любым её значением вердикт один и тот же — порог берётся из такта загрузки. """ _patch_runs(monkeypatch) base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type] tweaked = mon.check_sber_freshness( _prod_db(), # type: ignore[arg-type] run_id=9, params={"lag_allowance_days": 0}, now=_PROD_NOW, ) assert base["alert"] == tweaked["alert"] == 0 def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None: """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет.""" _patch_runs(monkeypatch) db = _FakeDB( {"real_estate_deals": _JUN_2026}, last_pull=_now(2026, 7, 23), # 20 суток interval_days="28", ) out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["max_pull_lag_days"] == 56 assert out["alert"] == 0 def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем.""" _patch_runs(monkeypatch) db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None) out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS # ── Миграция 180 ────────────────────────────────────────────────────────────── def test_migration_180_exists() -> None: assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}" def test_migration_180_seeds_source() -> None: sql = _MIGRATION_180.read_text("utf-8") assert "'sber_freshness_monitor'" in sql def test_migration_180_enabled_true() -> None: sql = _MIGRATION_180.read_text("utf-8") assert "true" in sql def test_migration_180_is_idempotent() -> None: sql = _MIGRATION_180.read_text("utf-8") assert "ON CONFLICT (source) DO NOTHING" in sql def test_migration_180_is_transactional() -> None: sql = _MIGRATION_180.read_text("utf-8") assert "BEGIN;" in sql assert "COMMIT;" in sql def test_migration_180_window_9_to_10_utc() -> None: sql = _MIGRATION_180.read_text("utf-8") assert re.search(r"\b9\b", sql), "window_start_hour 9 missing" assert re.search(r"\b10\b", sql), "window_end_hour 10 missing" def test_migration_180_no_psycopg_trap() -> None: sql = _MIGRATION_180.read_text("utf-8") assert not re.search(r":\w+::", sql) # ── Миграция 212: такт загрузки = источник порога ───────────────────────────── # # Прежний инвариант («пол возраста + такт < календарного порога монитора») снят # вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при # полном прогоне шестидневной давности, потолок 53 держался бы только если бы # источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода # не должен расходиться с тактом, который сеет миграция. def test_migration_212_makes_pull_cadence_weekly() -> None: sql = _MIGRATION_212.read_text("utf-8") assert "sber_index_pull" in sql assert '"interval_days": 7' in sql assert "BEGIN;" in sql and "COMMIT;" in sql assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) def test_default_pull_interval_matches_migration_212() -> None: """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой.""" interval_days = int( re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr] ) assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days # ── Один порог, а не два ────────────────────────────────────────────────────── def test_no_second_calendar_threshold_in_settings() -> None: """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться.""" from app.core.config import settings assert not hasattr(settings, "sber_index_max_age_days") # ── Регистрация в kit registry ───────────────────────────────────────────────── def test_kit_product_handler_registered() -> None: # ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает. handlers = build_product_handlers(ctx=None) # type: ignore[arg-type] assert "sber_freshness_monitor" in handlers