gendesign/tradein-mvp/backend/tests/test_admin_stale_counters.py
bot-backend 837ad8cfd4
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
fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)
Пользовательская половина разбора #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
2026-08-05 22:39:48 +05:00

205 lines
8.3 KiB
Python
Raw 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.

"""#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() == {}