"""Offline tests для unified scrapers-страницы admin-API (epic консолидации UI). Покрытие 3 эндпоинтов (db/httpx мокаются, NO live network/DB): - GET /api/v1/admin/scrape/runs — unified runs + total + фильтры - GET /api/v1/admin/scraper/health — fetch_mode + browser + providers - POST /api/v1/admin/scraper/{src}/rotate-ip — changeip + no-rotate-url кейс """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @pytest.fixture def client() -> TestClient: from app.api.v1 import admin as admin_module from app.core.db import get_db app = FastAPI() app.include_router(admin_module.router, prefix="/api/v1/admin") def fake_db(): yield MagicMock() app.dependency_overrides[get_db] = fake_db return TestClient(app) # ── API 1: GET /scrape/runs ────────────────────────────────────────────────── def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: """list_all → total + rows; ISO-форматирование дат + error_text/run_type.""" fake_rows = [ { "run_id": 5, "source": "avito_city_sweep", "run_type": "city_sweep", "status": "done", "params": {"pages_per_anchor": 3}, "counters": {"lots_fetched": 120}, "total_seen": 120, "new_count": 30, "started_at": datetime(2026, 6, 1, 10, tzinfo=UTC), "finished_at": datetime(2026, 6, 1, 11, tzinfo=UTC), "heartbeat_at": datetime(2026, 6, 1, 11, tzinfo=UTC), "error_text": None, } ] with patch("app.services.scrape_runs.list_all", return_value=(42, fake_rows)) as m: r = client.get("/api/v1/admin/scrape/runs") assert r.status_code == 200 body = r.json() assert body["total"] == 42 assert len(body["rows"]) == 1 row = body["rows"][0] assert row["run_id"] == 5 assert row["source"] == "avito_city_sweep" assert row["run_type"] == "city_sweep" assert row["total_seen"] == 120 assert row["new_count"] == 30 assert row["error_text"] is None assert "2026-06-01" in row["started_at"] # defaults передаются в сервис _, kwargs = m.call_args assert kwargs["source"] is None assert kwargs["status"] is None assert kwargs["limit"] == 50 assert kwargs["offset"] == 0 def test_unified_runs_passes_filters(client: TestClient) -> None: """source/status/limit/offset прокидываются в list_all.""" with patch("app.services.scrape_runs.list_all", return_value=(0, [])) as m: r = client.get( "/api/v1/admin/scrape/runs", params={ "source": "cian_city_sweep", "status": "banned", "limit": 10, "offset": 20, }, ) assert r.status_code == 200 _, kwargs = m.call_args assert kwargs["source"] == "cian_city_sweep" assert kwargs["status"] == "banned" assert kwargs["limit"] == 10 assert kwargs["offset"] == 20 def test_unified_runs_limit_over_max_422(client: TestClient) -> None: """limit=999 > max=200 → 422.""" r = client.get("/api/v1/admin/scrape/runs", params={"limit": 999}) assert r.status_code == 422 def test_unified_runs_negative_offset_422(client: TestClient) -> None: """offset=-1 < 0 → 422.""" r = client.get("/api/v1/admin/scrape/runs", params={"offset": -1}) assert r.status_code == 422 def test_unified_runs_invalid_status_422(client: TestClient) -> None: """status с непредусмотренным значением → 422 (Literal-валидация).""" r = client.get("/api/v1/admin/scrape/runs", params={"status": "unknown_status"}) assert r.status_code == 422 def test_unified_runs_valid_statuses_200(client: TestClient) -> None: """Все допустимые значения status проходят валидацию (ожидаем 200, не 422).""" valid_statuses = ["done", "running", "banned", "zombie", "failed", "cancelled"] for s in valid_statuses: with patch("app.services.scrape_runs.list_all", return_value=(0, [])): r = client.get("/api/v1/admin/scrape/runs", params={"status": s}) assert r.status_code == 200, f"status={s!r} должен возвращать 200, получили {r.status_code}" # ── API 2: GET /scraper/health ─────────────────────────────────────────────── def test_health_assembles_browser_and_providers(client: TestClient) -> None: """browser /health + ipify пробинг → fetch_mode + browser + 3 провайдера.""" from app.api.v1 import admin as admin_module async def fake_browser_health() -> admin_module.BrowserHealth: return admin_module.BrowserHealth( reachable=True, browsers={"avito": False, "cian": True, "yandex": False, "generic": True}, ) async def fake_current_ip(proxy_url: str | None) -> str | None: return "1.2.3.4" if proxy_url else None proxy_urls = { "avito": "http://u:p@host.a:10049", "cian": None, "yandex": "http://u:p@host.y:10051", } rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": None} with ( patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), patch.object(admin_module, "_provider_rotate_url", lambda s: rotate_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") assert r.status_code == 200 body = r.json() assert body["fetch_mode"] == "browser" assert body["browser"]["reachable"] is True assert body["browser"]["browsers"]["cian"] is True assert body["browser"]["browsers"]["avito"] is False by_source = {p["source"]: p for p in body["providers"]} assert set(by_source) == {"avito", "cian", "yandex"} assert by_source["avito"]["proxy_host"] == "host.a" assert by_source["avito"]["proxy_port"] == 10049 assert by_source["avito"]["rotate_supported"] is True assert by_source["avito"]["current_ip"] == "1.2.3.4" # cian без proxy → host/port/ip null, rotate_supported False assert by_source["cian"]["proxy_host"] is None assert by_source["cian"]["current_ip"] is None assert by_source["cian"]["rotate_supported"] is False # yandex имеет proxy но нет rotate_url assert by_source["yandex"]["proxy_host"] == "host.y" assert by_source["yandex"]["rotate_supported"] is False def test_health_browser_unreachable(client: TestClient) -> None: """httpx-ошибка на /health → reachable=False, browsers пуст.""" from app.api.v1 import admin as admin_module async def fake_current_ip(proxy_url: str | None) -> str | None: return None class _BoomClient: def __init__(self, *a: Any, **k: Any) -> None: pass async def __aenter__(self) -> _BoomClient: return self async def __aexit__(self, *a: Any) -> None: return None async def get(self, *a: Any, **k: Any) -> Any: raise RuntimeError("browser down") with ( patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_provider_proxy_url", lambda s: None), patch.object(admin_module, "_provider_rotate_url", lambda s: None), ): r = client.get("/api/v1/admin/scraper/health") assert r.status_code == 200 body = r.json() assert body["browser"]["reachable"] is False assert body["browser"]["browsers"] == {} # ── API 3: POST /scraper/{source}/rotate-ip ────────────────────────────────── def test_rotate_ip_calls_changeip(client: TestClient) -> None: """rotate_url задан → GET changeip + format=json, возврат new_ip из ответа.""" from app.api.v1 import admin as admin_module captured: dict[str, str] = {} class _FakeResp: def raise_for_status(self) -> None: return None def json(self) -> dict[str, str]: return {"new_ip": "9.9.9.9"} class _FakeClient: def __init__(self, *a: Any, **k: Any) -> None: pass async def __aenter__(self) -> _FakeClient: return self async def __aexit__(self, *a: Any) -> None: return None async def get(self, url: str, *a: Any, **k: Any) -> _FakeResp: captured["url"] = url return _FakeResp() with ( patch.object(admin_module.httpx, "AsyncClient", _FakeClient), patch.object(admin_module.settings, "avito_proxy_rotate_url", "http://ch/changeip?key=x"), ): r = client.post("/api/v1/admin/scraper/avito/rotate-ip") assert r.status_code == 200 body = r.json() assert body["ok"] is True assert body["new_ip"] == "9.9.9.9" assert "format=json" in captured["url"] # уже был '?' в URL → должен использовать '&' assert captured["url"].endswith("&format=json") def test_rotate_ip_no_rotate_url(client: TestClient) -> None: """rotate_url не задан → ok=False + reason про auto-rotate, changeip НЕ дёргается.""" from app.api.v1 import admin as admin_module with patch.object(admin_module.settings, "cian_proxy_rotate_url", None): r = client.post("/api/v1/admin/scraper/cian/rotate-ip") assert r.status_code == 200 body = r.json() assert body["ok"] is False assert body["new_ip"] is None assert "auto-rotate" in body["reason"] def test_rotate_ip_invalid_source_422(client: TestClient) -> None: """source вне {avito,cian,yandex} → 422 (Literal-валидация path).""" r = client.post("/api/v1/admin/scraper/domclick/rotate-ip") assert r.status_code == 422 def test_rotate_ip_changeip_error(client: TestClient) -> None: """httpx-ошибка changeip → ok=False + reason.""" from app.api.v1 import admin as admin_module class _BoomClient: def __init__(self, *a: Any, **k: Any) -> None: pass async def __aenter__(self) -> _BoomClient: return self async def __aexit__(self, *a: Any) -> None: return None async def get(self, *a: Any, **k: Any) -> Any: raise RuntimeError("changeip 500") with ( patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module.settings, "yandex_proxy_rotate_url", "http://ch/changeip"), ): r = client.post("/api/v1/admin/scraper/yandex/rotate-ip") assert r.status_code == 200 body = r.json() assert body["ok"] is False assert "changeip error" in body["reason"] # ── API 4: GET /scraper/pacing ─────────────────────────────────────────────── def test_pacing_get_proxies_browser_response(client: TestClient) -> None: """GET /scraper/pacing → парсит browser /pacing и возвращает PacingResponse.""" from app.api.v1 import admin as admin_module fake_browser_resp = { "providers": [ {"source": "avito", "interval_s": 3.0, "env_default_s": 2.0}, {"source": "cian", "interval_s": 18.0, "env_default_s": 2.0}, {"source": "yandex", "interval_s": 2.0, "env_default_s": 2.0}, {"source": "generic", "interval_s": 2.0, "env_default_s": 2.0}, ] } async def _fake_proxy() -> dict: return fake_browser_resp with patch.object(admin_module, "_proxy_browser_pacing_get", _fake_proxy): r = client.get("/api/v1/admin/scraper/pacing") assert r.status_code == 200 body = r.json() assert "providers" in body by_source = {p["source"]: p for p in body["providers"]} assert set(by_source) == {"avito", "cian", "yandex", "generic"} assert by_source["cian"]["interval_s"] == 18.0 assert by_source["avito"]["env_default_s"] == 2.0 def test_pacing_get_browser_unreachable_503(client: TestClient) -> None: """GET /scraper/pacing при недоступном browser → 503.""" from fastapi import HTTPException from app.api.v1 import admin as admin_module async def _boom() -> dict: raise HTTPException(status_code=503, detail="browser unreachable: ConnectError") with patch.object(admin_module, "_proxy_browser_pacing_get", _boom): r = client.get("/api/v1/admin/scraper/pacing") assert r.status_code == 503 # ── API 5: PUT /scraper/pacing/{source} ───────────────────────────────────── def test_pacing_put_proxies_to_browser(client: TestClient) -> None: """PUT /scraper/pacing/avito → проксирует на browser и отдаёт {ok,source,interval_s}.""" from app.api.v1 import admin as admin_module captured: dict[str, Any] = {} class _FakeResp: def raise_for_status(self) -> None: return None def json(self) -> dict: return {"ok": True, "source": "avito", "interval_s": 7.5} class _FakeClient: def __init__(self, *a: Any, **k: Any) -> None: pass async def __aenter__(self) -> _FakeClient: return self async def __aexit__(self, *a: Any) -> None: return None async def put(self, url: str, json: Any = None, **k: Any) -> _FakeResp: captured["url"] = url captured["json"] = json return _FakeResp() with patch.object(admin_module.httpx, "AsyncClient", _FakeClient): r = client.put( "/api/v1/admin/scraper/pacing/avito", json={"interval_s": 7.5}, ) assert r.status_code == 200 body = r.json() assert body["ok"] is True assert body["source"] == "avito" assert body["interval_s"] == 7.5 assert captured["json"]["source"] == "avito" assert captured["json"]["interval_s"] == 7.5 def test_pacing_put_invalid_source_422(client: TestClient) -> None: """PUT /scraper/pacing/domclick → 422 (Literal-валидация).""" r = client.put("/api/v1/admin/scraper/pacing/domclick", json={"interval_s": 5.0}) assert r.status_code == 422 def test_pacing_put_negative_interval_422(client: TestClient) -> None: """PUT /scraper/pacing/cian с interval_s < 0 → 422 (ge=0 валидация).""" r = client.put("/api/v1/admin/scraper/pacing/cian", json={"interval_s": -1.0}) assert r.status_code == 422 def test_pacing_put_over_max_interval_422(client: TestClient) -> None: """PUT /scraper/pacing/yandex с interval_s > 120 → 422 (le=120 валидация).""" r = client.put("/api/v1/admin/scraper/pacing/yandex", json={"interval_s": 200.0}) assert r.status_code == 422 def test_pacing_put_browser_unreachable_503(client: TestClient) -> None: """PUT /scraper/pacing/avito при недоступном browser → 503.""" from app.api.v1 import admin as admin_module class _BoomClient: def __init__(self, *a: Any, **k: Any) -> None: pass async def __aenter__(self) -> _BoomClient: return self async def __aexit__(self, *a: Any) -> None: return None async def put(self, *a: Any, **k: Any) -> Any: raise RuntimeError("connection refused") with patch.object(admin_module.httpx, "AsyncClient", _BoomClient): r = client.put( "/api/v1/admin/scraper/pacing/avito", json={"interval_s": 5.0}, ) assert r.status_code == 503 # ── API 6: GET /scraper/data-quality ──────────────────────────────────────── def _make_dq_db_mock() -> MagicMock: """Мок db-сессии для GET /scraper/data-quality.""" db = MagicMock() # listings rows: avito=1000 active, cian=500 active avito_row = { "source": "avito", "active_count": 1000, "f_description": 900, "f_photo_urls": 980, "f_address": 1000, "f_lat": 950, "f_lon": 950, "f_kitchen_area_m2": 600, "f_living_area_m2": 100, "f_ceiling_height": 50, "f_ceiling_height_m": 300, "f_metro_stations": 700, } cian_row = { "source": "cian", "active_count": 500, "f_description": 450, "f_photo_urls": 490, "f_address": 500, "f_lat": 480, "f_lon": 480, "f_kitchen_area_m2": 400, "f_living_area_m2": 400, "f_ceiling_height": 350, "f_ceiling_height_m": 0, "f_metro_stations": 100, } houses_row = { "total": 2000, "validated_cnt": 1800, "rating_cnt": 1200, "house_type_cnt": 1900, } listing_mappings = MagicMock() listing_mappings.all.return_value = [avito_row, cian_row] houses_mappings = MagicMock() houses_mappings.one.return_value = houses_row reviews_scalar = 4500 # db.execute() вызывается 3 раза: listings SQL, houses SQL, house_reviews COUNT execute_results = iter( [ MagicMock(mappings=lambda: listing_mappings), MagicMock(mappings=lambda: houses_mappings), MagicMock(scalar=lambda: reviews_scalar), ] ) db.execute.side_effect = lambda *a, **k: next(execute_results) return db def test_data_quality_shape(client: TestClient) -> None: """GET /scraper/data-quality → StructureCheck: sources + houses + reviews_count.""" from app.api.v1 import admin as admin_module # noqa: F401 from app.core.db import get_db app = client.app app.dependency_overrides[get_db] = lambda: _make_dq_db_mock() r = client.get("/api/v1/admin/scraper/data-quality") assert r.status_code == 200 body = r.json() assert "sources" in body assert "houses" in body # two sources sources = {s["source"]: s for s in body["sources"]} assert set(sources) >= {"avito", "cian"} avito = sources["avito"] assert avito["active_count"] == 1000 fields = avito["fields"] assert 0.0 <= fields["description"] <= 100.0 assert fields["description"] == pytest.approx(90.0, abs=0.1) assert fields["photo_urls"] == pytest.approx(98.0, abs=0.1) assert fields["kitchen_area_m2"] == pytest.approx(60.0, abs=0.1) cian = sources["cian"] assert cian["active_count"] == 500 assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1) assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1) houses = body["houses"] assert houses["total"] == 2000 assert houses["validated_pct"] == pytest.approx(90.0, abs=0.1) assert houses["rating_pct"] == pytest.approx(60.0, abs=0.1) assert houses["house_type_pct"] == pytest.approx(95.0, abs=0.1) assert houses["reviews_count"] == 4500 def test_data_quality_pct_in_range(client: TestClient) -> None: """Все fill% в [0,100].""" from app.core.db import get_db app = client.app app.dependency_overrides[get_db] = lambda: _make_dq_db_mock() r = client.get("/api/v1/admin/scraper/data-quality") assert r.status_code == 200 body = r.json() for src in body["sources"]: for field_name, pct in src["fields"].items(): assert ( 0.0 <= pct <= 100.0 ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" # ── Security: текст исключения не утекает в HTTP-ответ (#2234) ──────────────── def test_admin_error_detail_hides_exception_text(client: TestClient) -> None: """Внутренняя зависимость кидает исключение с 'секретным' текстом → в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний. Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением. """ secret = "postgres://user:SECRETPASS@internal-host:5432/db" with patch( "app.api.v1.admin.fetch_detail", new=AsyncMock(side_effect=Exception(secret)), ): r = client.post( "/api/v1/admin/scrape/avito-detail", params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"}, ) assert r.status_code == 502 body = r.json() detail = body["detail"] assert "SECRETPASS" not in detail assert secret not in detail assert "error-id:" in detail assert detail.startswith("fetch failed")