- browser/server.py: GET /pacing и PUT /pacing — read/write _MIN_PAGE_INTERVAL_BY_PROVIDER
in-memory; валидация source in PROVIDERS, interval_s >= 0; логирует изменения; сбрасывается
к env-дефолту на рестарте by design
- backend/admin.py: GET /scraper/pacing — прокси к browser /pacing, PacingResponse pydantic;
PUT /scraper/pacing/{source} — прокси PUT с валидацией ge=0 le=120; 502/503 при недоступности
- backend/admin.py: GET /scraper/data-quality — single-pass FILTER-агрегаты по listings WHERE
is_active GROUP BY source (description/photo_urls/address/lat/lon/kitchen_area_m2/living_area_m2/
ceiling_height/ceiling_height_m/metro_stations); houses (total/avito_validated_at%/rating_score%/
house_type%); house_reviews count
- browser/test_server_pacing.py: 10 новых тестов GET+PUT /pacing (16 total, все зелёные)
- backend/tests/test_scraper_admin_apis.py: тесты pacing-прокси + data-quality shape/pct-range
(21 total, все зелёные)
566 lines
20 KiB
Python
566 lines
20 KiB
Python
"""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 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]"
|