All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m32s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 2m3s
424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#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
|