"""#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