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
336 lines
16 KiB
Python
336 lines
16 KiB
Python
"""#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
|