gendesign/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
bot-backend 3eabbd0186
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m31s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 1m26s
fix(tradein/quality): витрина перестаёт рапортовать «доля с кадастром» (#2674) (#2850)
2026-08-12 20:37:16 +00:00

336 lines
16 KiB
Python
Raw Permalink 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.

"""#2674 — показатели админки, которые не могут быть ненулевыми.
Один класс: колонка есть, поверхность её показывает, писателя нет ни одного — и
ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой
показатель хуже отсутствующего, поэтому «честно убрать» здесь — такой же
правильный исход, как «реализовать».
Числа с прода (tradein-postgres, 2026-08-06, до фикса):
1. listings.is_outlier — 93 408 строк, true у 0, NULL у 0: только DEFAULT из
миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал
этот ноль как «выбросов не помечено».
2. scrape_runs.http_requests / http_errors / returning_count /
disappeared_count — ноль во всех 3244 прогонах с миграции 015 (74 дня у
самого старого), писателя нет вообще.
3. scrape_runs.run_type — 3244 строки, ровно одно значение 'city_sweep'
(DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630
прогонов), deactivate_stale_*, sber_index_pull.
4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а
`source = :source` — точное сравнение: строк с ТАКИМ source в таблице 0.
То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все»)
молча подставлял source вкладки — и тоже ноль. 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
БД и сеть замоканы — реального Postgres не нужно.
"""
from __future__ import annotations
import inspect
import os
import re
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import runs as kit_runs
from app.services import scrape_runs as runs_mod
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
_RUNS_TABLE_TSX = (
_BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx"
)
# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier.
_DEAD_RUN_COLUMNS = (
"run_type",
"http_requests",
"http_errors",
"returning_count",
"disappeared_count",
)
# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ──────────────────────
class _FakeRow:
def __init__(self, source: str) -> None:
self.source = source
class _FakeResult:
def __init__(self, rows: list[_FakeRow]) -> None:
self._rows = rows
def fetchall(self) -> list[_FakeRow]:
return self._rows
class _FakeDB:
"""Минимальный Session-стаб: любой execute отдаёт заданный список source'ов."""
def __init__(self, sources: list[str]) -> None:
self.sources = sources
self.statements: list[str] = []
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.statements.append(str(stmt))
return _FakeResult([_FakeRow(s) for s in self.sources])
@pytest.fixture
def client_factory():
from app.api.v1 import admin as admin_module
from app.core.db import get_db
def _make(db: Any) -> TestClient:
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
return _make
# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════
def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None:
"""Новый источник появляется в фильтре БЕЗ правки кода.
'domclick_city_sweep' — площадка, которой не было в захардкоженной тройке
(46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep'
заведомо несуществующий источник: если он доезжает до ответа, список
действительно приходит из данных, а не из литерала в коде.
"""
db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"])
r = client_factory(db).get("/api/v1/admin/scrape/runs/sources")
assert r.status_code == 200
assert r.json()["sources"] == [
"avito_city_sweep",
"domclick_city_sweep",
"newmarket_2031_sweep",
]
def test_sources_query_ignores_run_filters() -> None:
"""SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника
вырезал бы из выпадающего списка все остальные."""
sql = inspect.getsource(runs_mod.distinct_sources)
assert "DISTINCT source" in sql
assert "WHERE source = " not in sql
assert ":status" not in sql
def test_frontend_source_filter_has_no_hardcoded_list() -> None:
"""RunsTable.tsx не содержит литерального списка площадок.
Red на origin/main: там был `["", "avito", "cian", "yandex"] as const` —
три значения, которых в колонке source нет ни в одной из 3244 строк.
"""
tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8")
hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx)
assert hardcoded is None, (
"список источников снова захардкожен в RunsTable.tsx — он обязан приходить "
"из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять "
"останется невидимой для оператора"
)
assert "/scrape/runs/sources" in tsx
# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════
def test_unified_run_row_has_no_run_type(client_factory) -> None:
"""Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон
proxy_healthcheck честнее вообще нечем, кроме source."""
from unittest.mock import patch
row = {
"run_id": 5,
"source": "proxy_healthcheck",
"status": "done",
"params": None,
"counters": {"ok": 3},
"total_seen": None,
"new_count": None,
"started_at": None,
"finished_at": None,
"heartbeat_at": None,
"error_text": None,
}
with patch("app.services.scrape_runs.list_all", return_value=(1, [row])):
r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs")
assert r.status_code == 200
body_row = r.json()["rows"][0]
assert "run_type" not in body_row
assert body_row["source"] == "proxy_healthcheck"
@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all])
def test_list_all_selects_no_dropped_columns(list_all) -> None:
"""Обе копии list_all (app.services и scraper_kit) перестали читать колонки,
которых после миграции 214 в таблице нет — иначе админка отдаёт 500."""
sql = inspect.getsource(list_all)
still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)]
assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}"
# ══ 4b. Побочка починки фильтра: кнопка отмены открылась на все источники ═════
def test_cancel_flag_true_only_for_jobs_that_poll_cancellation() -> None:
"""honors_cancel = ровно те source'ы, чей джоб опрашивает runs.is_cancelled.
'yandex_newbuilding_sweep' в наборе НЕ должен быть, хотя и *_sweep: его таск
(app/tasks/yandex_newbuilding_sweep.py) отмену не опрашивает — поэтому правило
не может быть «любой sweep».
"""
honoring = [
"avito_city_sweep",
"cian_city_sweep_nizhniy_tagil",
"domclick_city_sweep",
"avito_full_load_exhaustive",
"cian_full_load",
"avito_newbuilding_sweep",
"rosreestr_dkp_import",
]
ignoring = [
"proxy_healthcheck",
"deactivate_stale_avito",
"refresh_search_matview",
"sber_index_pull",
"yandex_newbuilding_sweep",
"house_imv_backfill",
]
assert [s for s in honoring if not runs_mod.honors_cancel(s)] == []
assert [s for s in ignoring if runs_mod.honors_cancel(s)] == []
def test_row_carries_cancellable_so_ui_hides_the_button(client_factory) -> None:
"""Строка отдаёт cancellable — без него UI показал бы «Отменить» у любого
running-прогона, включая proxy_healthcheck (1631 из 3245)."""
from unittest.mock import patch
base = {
"status": "running",
"params": None,
"counters": None,
"total_seen": None,
"new_count": None,
"started_at": None,
"finished_at": None,
"heartbeat_at": None,
"error_text": None,
}
rows = [
{"run_id": 1, "source": "avito_city_sweep", **base},
{"run_id": 2, "source": "proxy_healthcheck", **base},
]
with patch("app.services.scrape_runs.list_all", return_value=(2, rows)):
r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs")
assert r.status_code == 200
assert [row["cancellable"] for row in r.json()["rows"]] == [True, False]
def test_mark_cancelled_refuses_non_cooperating_source() -> None:
"""Гейт на общем узле всех пяти ручек отмены: 'cancelled' у задачи, которая
отмену не опрашивает, — это враньё в статусе И снятие has_running_run-guard
(второй свип на том же прокси → бан, инцидент 2026-05-31)."""
db = MagicMock()
db.execute.return_value.fetchone.return_value = MagicMock(source="proxy_healthcheck")
assert runs_mod.mark_cancelled(db, 42) is False
# UPDATE не выполнялся — только SELECT source.
assert db.execute.call_count == 1
db.commit.assert_not_called()
# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════
def _migration_214() -> str:
matches = sorted(_SQL_DIR.glob("214_*.sql"))
assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)"
return matches[0].read_text(encoding="utf-8")
def test_migration_drops_every_dead_column() -> None:
"""Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно."""
sql = _migration_214()
missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql]
assert not missing, f"миграция не дропает: {missing}"
assert "DROP COLUMN IF EXISTS is_outlier" in sql
_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
def _latest_v_data_quality() -> tuple[str, str]:
"""(текст последней миграции, создающей v_data_quality; тело её SELECT).
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени — последний в проде.
"""
creators = sorted(
p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
)
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
sql = creators[-1].read_text(encoding="utf-8")
hit = _VIEW_MARKER.search(sql)
assert hit is not None
return sql, sql[hit.end() :].split(";")[0]
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
— показатель, который не мог быть ненулевым, потому что колонку не писал никто.
"""
_, body = _latest_v_data_quality()
assert "outliers_flagged" not in body
assert "is_outlier" not in body
def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
"""Тот же класс, третий случай: pct_cadastr (мигр. 259).
Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни
одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
читался как измеренное качество данных.
Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка
`... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
Замена источника на listings.building_cadastral_number — НЕ починка: та колонка
про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674
замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
в этой витрине.
"""
sql, body = _latest_v_data_quality()
assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
assert "cadastral_number" not in body, (
"в витрину подставили другой кадастр — под подписью «доля объявлений с "
"кадастром» это новая ложь вместо старой (см. шапку 259)"
)
# DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
# выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
assert "COMMENT ON VIEW v_data_quality" in sql