gendesign/tradein-mvp/backend/tests/test_sber_freshness_monitor.py
bot-backend 3cd7e0a9c4
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
fix(tradein/sber): сторож мерит отставание загрузки, а не календарь (#2846) (#2849)
2026-08-12 19:36:20 +00:00

424 lines
18 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.

"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#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