All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
Добавляет sber_freshness_monitor по образцу deals_freshness_monitor: staleness данных СберИндекса теперь видна на MONITOR-частоте, а не тонет в per-estimate warning'ах estimator._load_sber_index_series (#audit-5a). - app/tasks/sber_freshness_monitor.py: чистая evaluate_sber_freshness() (frozen-now, без БД) + check_sber_freshness() (один SELECT max(period_month) вторичного сегмента по региону, #R2-H1 фильтр как в эстиматоре; WARNING при stale, mark_done при алерте — это монитор, не сбой; mark_failed только при пустой таблице). - app/services/product_handlers.py: _job_sber_freshness_monitor + Handler в build_product_handlers (run_in_executor, как deals-монитор). - data/sql/180_seed_sber_freshness_monitor.sql: seed scrape_schedules (enabled, daily 09:00-10:00 UTC, lag_allowance_days=25). - tests/test_sber_freshness_monitor.py: frozen-now (fresh/stale/границы) + FakeDB (fresh/stale/empty/кастомный lag) + свойства миграции + registry. Порог алерта: sber_index_max_age_days (35) + lag_allowance (25) = 60д. +25 — запас на инхерентный лаг публикации источника (1-2 мес), чтобы не шуметь на штатном отставании. Прод 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
227 lines
8.6 KiB
Python
227 lines
8.6 KiB
Python
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
|
||
|
||
Покрывает:
|
||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
|
||
- fresh: age <= max_age_days (алерта нет);
|
||
- stale: age > max_age_days (алерт);
|
||
- граница порога (== max_age_days → нет алерта; +1 день → алерт).
|
||
2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
|
||
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
|
||
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
|
||
"""
|
||
|
||
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.core.config import settings
|
||
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"
|
||
|
||
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
|
||
_MAY_2026 = date(2026, 5, 1)
|
||
|
||
|
||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||
|
||
|
||
def _now(y: int, m: int, d: int) -> datetime:
|
||
return datetime(y, m, d, tzinfo=UTC)
|
||
|
||
|
||
def test_fresh_within_max_age() -> None:
|
||
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
|
||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
|
||
assert v.stale is False
|
||
assert v.age_days == 30
|
||
assert v.latest_period == _MAY_2026
|
||
|
||
|
||
def test_stale_beyond_max_age() -> None:
|
||
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
|
||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
|
||
assert v.stale is True
|
||
assert v.age_days == 72
|
||
|
||
|
||
def test_threshold_boundary_exact_no_alert() -> None:
|
||
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
|
||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
|
||
assert v.age_days == 60
|
||
assert v.stale is False
|
||
|
||
|
||
def test_threshold_boundary_next_day_alert() -> None:
|
||
"""Порог + 1 день (age=61) — первый алерт."""
|
||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
|
||
assert v.age_days == 61
|
||
assert v.stale is True
|
||
|
||
|
||
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
|
||
|
||
|
||
class _Row:
|
||
def __init__(self, latest: date | None) -> None:
|
||
self.latest = latest
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, latest: date | None) -> None:
|
||
self._latest = latest
|
||
|
||
def first(self) -> _Row:
|
||
return _Row(self._latest)
|
||
|
||
|
||
class _FakeDB:
|
||
def __init__(self, latest: date | None) -> None:
|
||
self._latest = latest
|
||
self.rolled_back = False
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||
return _FakeResult(self._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 test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _patch_runs(monkeypatch)
|
||
db = _FakeDB(_MAY_2026)
|
||
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
|
||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
|
||
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
|
||
assert calls["done"] == out
|
||
assert calls["failed"] is None
|
||
|
||
|
||
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _patch_runs(monkeypatch)
|
||
db = _FakeDB(_MAY_2026)
|
||
# @2026-07-12 (прод): age=72 > 60 → алерт.
|
||
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||
assert out["alert"] == 1
|
||
assert out["age_days"] == 72
|
||
assert out["latest_month"] == 5
|
||
# Монитор НЕ падает при алерте — прогон done, а не failed.
|
||
assert calls["done"] == out
|
||
assert calls["failed"] is None
|
||
|
||
|
||
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls = _patch_runs(monkeypatch)
|
||
db = _FakeDB(None)
|
||
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||
assert out["alert"] == 0
|
||
assert calls["done"] is None
|
||
assert calls["failed"] is not None
|
||
|
||
|
||
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
_patch_runs(monkeypatch)
|
||
db = _FakeDB(_MAY_2026)
|
||
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
|
||
out = mon.check_sber_freshness(
|
||
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
|
||
) # type: ignore[arg-type]
|
||
assert out["alert"] == 1
|
||
|
||
|
||
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
|
||
_patch_runs(monkeypatch)
|
||
db = _FakeDB(_MAY_2026)
|
||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
|
||
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
|
||
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
|
||
assert out_exact["age_days"] == threshold
|
||
assert out_exact["alert"] == 0
|
||
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
|
||
assert out_over["alert"] == 1
|
||
|
||
|
||
# ── Миграция 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_lag_allowance_25() -> None:
|
||
sql = _MIGRATION_180.read_text("utf-8")
|
||
assert "lag_allowance_days" in sql
|
||
assert "25" in sql
|
||
|
||
|
||
def test_migration_180_no_psycopg_trap() -> None:
|
||
sql = _MIGRATION_180.read_text("utf-8")
|
||
assert not re.search(r":\w+::", sql)
|
||
|
||
|
||
# ── Регистрация в 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
|