"""#2660: админ-счётчики честно разделяют «активно» и «из них не виделись N дней». Почему это баг, а не косметика: `is_active` снимается только деактиватором протухших, а он покрывает не все источники — прод (2026-08-05) показывал cian 18 530 «активных» при 12 683 не виденных 14+ дней и 10 212 не виденных 30+. Именно поэтому #2574 месяц читалась как «всё собирается». Счётчик не прячем (иначе потеряем видимость проблемы) — отдаём вторым числом рядом. Покрыто (db мокается, NO live network/DB): - GET /api/v1/admin/scraper/data-quality — stale_count per source + stale_days; - GET /api/v1/trade-in/cache-stats — listings_active_stale + listings_stale_days; - порог свежести = LISTINGS_FRESH_DAYS эстиматора (не второе определение); - оба запроса считают протухшее по last_seen_at И только среди is_active. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from typing import Any import pytest from fastapi import FastAPI from fastapi.testclient import TestClient class _FakeResult: def __init__( self, *, rows: list[dict] | None = None, one: dict | None = None, scalar_value: Any = None, ) -> None: self._rows = rows or [] self._one = one self._scalar = scalar_value def scalar(self) -> Any: return self._scalar def mappings(self) -> Any: outer = self class _Mappings: def all(self) -> list[dict]: return outer._rows def one(self) -> dict: assert outer._one is not None return outer._one def fetchone(self) -> dict | None: return outer._one return _Mappings() class _FakeDB: """Session stand-in: execute() отдаёт очередь заготовленных результатов.""" def __init__(self, results: list[_FakeResult]) -> None: self._results = list(results) self.executed: list[tuple[Any, Any]] = [] def execute(self, clause: Any, params: Any = None) -> _FakeResult: self.executed.append((clause, params)) return self._results.pop(0) # Одна строка coverage-выборки: source + счётчики + fill-агрегаты по всем полям. def _dq_row(source: str, active: int, stale: int) -> dict[str, Any]: from app.api.v1.admin import _DQ_LISTING_FIELDS row: dict[str, Any] = {"source": source, "active_count": active, "stale_count": stale} for name, _expr in _DQ_LISTING_FIELDS: row[f"f_{name}"] = active # 100% fill — не предмет этого теста return row _HOUSES_ROW = {"total": 10, "validated_cnt": 5, "rating_cnt": 5, "house_type_cnt": 5} def _make_client(db: _FakeDB, router_module: str, prefix: str) -> TestClient: import importlib from app.core.db import get_db module = importlib.import_module(router_module) app = FastAPI() app.include_router(module.router, prefix=prefix) app.dependency_overrides[get_db] = lambda: db return TestClient(app) # ── GET /scraper/data-quality ──────────────────────────────────────────────── @pytest.fixture def dq_db() -> _FakeDB: return _FakeDB( [ _FakeResult( rows=[ # Прод-пропорции 2026-08-05: cian почти весь протухший, avito живой. _dq_row("cian", 18_530, 12_683), _dq_row("avito", 4_975, 0), ] ), _FakeResult(one=_HOUSES_ROW), _FakeResult(scalar_value=0), ] ) def test_data_quality_reports_stale_next_to_active(dq_db: _FakeDB) -> None: """«Активно» остаётся как было, рядом появляется «из них не виделись N дней».""" client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") r = client.get("/api/v1/admin/scraper/data-quality") assert r.status_code == 200 body = r.json() by_source = {s["source"]: s for s in body["sources"]} assert by_source["cian"]["active_count"] == 18_530 assert by_source["cian"]["stale_count"] == 12_683 # Живой источник — протухших нет, счётчик не «шумит» на здоровых данных. assert by_source["avito"]["active_count"] == 4_975 assert by_source["avito"]["stale_count"] == 0 def test_data_quality_exposes_the_threshold_used(dq_db: _FakeDB) -> None: """Порог отдаётся в ответе — UI подписывает число, а не хардкодит своё N.""" from app.services.estimator import LISTINGS_FRESH_DAYS client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") body = client.get("/api/v1/admin/scraper/data-quality").json() assert body["stale_days"] == LISTINGS_FRESH_DAYS def test_data_quality_counts_stale_by_last_seen_within_active_only(dq_db: _FakeDB) -> None: """Протухшее считается по last_seen_at (буквально «не виделись») и ТОЛЬКО среди активных — иначе счётчик смешает деактивированные строки и перестанет быть сопоставимым с active_count.""" from app.services.estimator import LISTINGS_FRESH_DAYS client = _make_client(dq_db, "app.api.v1.admin", "/api/v1/admin") client.get("/api/v1/admin/scraper/data-quality") clause, params = dq_db.executed[0] sql = str(clause) assert "COUNT(*) FILTER (" in sql assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql assert "WHERE is_active = true" in sql assert params == {"fresh_days": LISTINGS_FRESH_DAYS} # ── GET /trade-in/cache-stats ──────────────────────────────────────────────── def _cache_stats_row() -> dict[str, Any]: return { "geocode_cache": 1, "geocode_cache_fresh": 1, "listings_active": 37_900, "listings_active_stale": 20_935, "listings_last_scraped": None, "deals": 0, "cad_buildings": 0, "house_metadata": 0, "estimates_total": 0, "avg_median_price": None, "repeat_address_pct": None, } def test_cache_stats_splits_active_and_stale() -> None: from app.services.estimator import LISTINGS_FRESH_DAYS db = _FakeDB([_FakeResult(one=_cache_stats_row())]) client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") body = client.get("/api/v1/trade-in/cache-stats").json() assert body["listings_active"] == 37_900 assert body["listings_active_stale"] == 20_935 assert body["listings_stale_days"] == LISTINGS_FRESH_DAYS def test_cache_stats_counts_stale_by_last_seen_within_active_only() -> None: from app.services.estimator import LISTINGS_FRESH_DAYS db = _FakeDB([_FakeResult(one=_cache_stats_row())]) client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") client.get("/api/v1/trade-in/cache-stats") clause, params = db.executed[0] sql = str(clause) assert "last_seen_at <= NOW() - (:fresh_days || ' days')::interval" in sql assert "listings_active_stale" in sql assert params == {"fresh_days": LISTINGS_FRESH_DAYS} def test_cache_stats_empty_db_returns_empty_dict_not_a_fake_threshold() -> None: """Нет строки — отдаём {}, а не {listings_stale_days: N} с пустыми счётчиками (иначе UI отрисует подпись к несуществующему числу).""" db = _FakeDB([_FakeResult(one=None)]) client = _make_client(db, "app.api.v1.trade_in", "/api/v1/trade-in") assert client.get("/api/v1/trade-in/cache-stats").json() == {}