All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 2m42s
Пользовательская половина разбора #2574: витрины читают listings без сегмента и без свежести, поэтому показывают числа, посчитанные не по тому пулу. 1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings(). 27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и девелоперский прайс (который не торгуется) формировал показываемый процент торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция намеренно смотрит и снятые объявления, иначе к сделке нечего подставить. Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205). 2. location_index — предикат свежести + сегментный гард в обоих запросах медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на тот же пул, на котором считается цена; окно свежести берётся импортом LISTINGS_FRESH_DAYS, второго определения константы не заводим. 3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем: рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе). Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть как «всё собирается». Refs #2660
205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
"""#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() == {}
|