"""Freshness-монитор данных deals по max(deal_date) — #2212. Покрывает: 1. Чистую логику evaluate_deals_freshness (frozen now, без БД): - нет алерта: 2026Q1 @ 2026-07-02 (порог 2026-08-15 не наступил); - алерт: 2026Q1 @ 2026-08-20 (days_overdue=5); - граница порога (== threshold → нет алерта; +1 день → алерт); - кастомный lag_allowance_days. 2. check_deals_freshness с FakeDB (no-alert / alert / empty→mark_failed). 3. Свойства миграции 162 (по образцу test_deactivate_stale_listings). 4. Регистрацию в kit product_handlers (registry остаётся зелёным). """ from __future__ import annotations import os import re from datetime import UTC, date, datetime 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 deals_freshness_monitor as mon _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_162 = _SQL_DIR / "162_seed_deals_freshness_monitor.sql" # max(deal_date) = 2026Q1 label (проверено на проде 2026-07-02). _Q1_2026 = date(2026, 1, 1) # ── evaluate_deals_freshness (чистая логика, frozen now) ────────────────────── def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) def test_no_alert_now_2026_07_02() -> None: """Сейчас: 2026Q1 @ 2026-07-02 — порог просрочки 2026-08-15 не наступил.""" v = mon.evaluate_deals_freshness(_Q1_2026, _now(2026, 7, 2)) assert v.overdue is False assert v.days_overdue == 0 assert v.latest_q_end == date(2026, 4, 1) assert v.overdue_threshold == date(2026, 8, 15) def test_alert_2026_08_20() -> None: """К 2026-08-20 Q2 не импортирован — алерт, просрочка 5 дней (порог 2026-08-15).""" v = mon.evaluate_deals_freshness(_Q1_2026, _now(2026, 8, 20)) assert v.overdue is True assert v.days_overdue == 5 def test_threshold_boundary_exact_no_alert() -> None: """Ровно в день порога (2026-08-15) алерта ещё нет (строгое >).""" v = mon.evaluate_deals_freshness(_Q1_2026, _now(2026, 8, 15)) assert v.overdue is False assert v.days_overdue == 0 def test_threshold_boundary_next_day_alert() -> None: """Порог + 1 день (2026-08-16) — первый алерт, просрочка 1 день.""" v = mon.evaluate_deals_freshness(_Q1_2026, _now(2026, 8, 16)) assert v.overdue is True assert v.days_overdue == 1 def test_custom_lag_allowance_shifts_threshold() -> None: """lag_allowance_days=0 сдвигает порог на конец следующего квартала (2026-07-01).""" v = mon.evaluate_deals_freshness(_Q1_2026, _now(2026, 7, 2), lag_allowance_days=0) assert v.overdue_threshold == date(2026, 7, 1) assert v.overdue is True assert v.days_overdue == 1 def test_add_months_quarter_starts() -> None: assert mon._add_months(date(2026, 1, 1), 3) == date(2026, 4, 1) assert mon._add_months(date(2026, 1, 1), 6) == date(2026, 7, 1) assert mon._add_months(date(2026, 10, 1), 3) == date(2027, 1, 1) def test_add_months_end_of_month_clamp() -> None: # 31 янв + 1 мес → 28 фев (зажим на конец месяца). assert mon._add_months(date(2026, 1, 31), 1) == date(2026, 2, 28) # ── check_deals_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_no_alert_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: calls = _patch_runs(monkeypatch) db = _FakeDB(_Q1_2026) out = mon.check_deals_freshness(db, run_id=1, params={}, now=_now(2026, 7, 2)) # type: ignore[arg-type] assert out == {"latest_year": 2026, "latest_quarter": 1, "days_overdue": 0, "alert": 0} assert calls["done"] == out assert calls["failed"] is None def test_check_overdue_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None: calls = _patch_runs(monkeypatch) db = _FakeDB(_Q1_2026) out = mon.check_deals_freshness(db, run_id=2, params={}, now=_now(2026, 8, 20)) # type: ignore[arg-type] assert out["alert"] == 1 assert out["days_overdue"] == 5 # Монитор НЕ падает при алерте — прогон done, а не failed. assert calls["done"] == out assert calls["failed"] is None def test_check_empty_deals_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: calls = _patch_runs(monkeypatch) db = _FakeDB(None) out = mon.check_deals_freshness(db, run_id=3, params={}, now=_now(2026, 7, 2)) # 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(_Q1_2026) # lag=0 → порог 2026-07-01 → @2026-07-02 уже просрочено. out = mon.check_deals_freshness( db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 2) ) # type: ignore[arg-type] assert out["alert"] == 1 # ── Миграция 162 ────────────────────────────────────────────────────────────── def test_migration_162_exists() -> None: assert _MIGRATION_162.is_file(), f"missing migration: {_MIGRATION_162}" def test_migration_162_seeds_source() -> None: sql = _MIGRATION_162.read_text("utf-8") assert "'deals_freshness_monitor'" in sql def test_migration_162_enabled_true() -> None: sql = _MIGRATION_162.read_text("utf-8") assert "true" in sql def test_migration_162_is_idempotent() -> None: sql = _MIGRATION_162.read_text("utf-8") assert "ON CONFLICT (source) DO NOTHING" in sql def test_migration_162_is_transactional() -> None: sql = _MIGRATION_162.read_text("utf-8") assert "BEGIN;" in sql assert "COMMIT;" in sql def test_migration_162_window_8_to_9_utc() -> None: sql = _MIGRATION_162.read_text("utf-8") assert re.search(r"\b8\b", sql), "window_start_hour 8 missing" assert re.search(r"\b9\b", sql), "window_end_hour 9 missing" def test_migration_162_lag_allowance_45() -> None: sql = _MIGRATION_162.read_text("utf-8") assert "lag_allowance_days" in sql assert "45" in sql def test_migration_162_no_psycopg_trap() -> None: sql = _MIGRATION_162.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 "deals_freshness_monitor" in handlers