gendesign/backend/tests/scrapers/test_nspd_bulk_client.py
Light1YT 2c22c3f7ea
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
feat(site-finder): per-building помещения/машино-места + parking_ratio (on-demand MVP, #96)
Оживляет мёртвый foundation list_objects_in_building (#168 Q3-deferred). Fix реального
блокера: NSPDOptions не парсил objdocId (camelCase из NSPD search) → objdoc_id всегда None,
метод нельзя было вызвать. AliasChoices(objdoc_id, objdocId) + registers.

premises_lookup.get_building_premises(cad_num) — резолв objdoc → list_objects_in_building,
graceful (WAF/сеть/not-found → None). parking_ratio = машино-места/помещения (None при 0
помещений, 0.0 при реальном отсутствии паркинга). Verified live 66:41:0106036:183.

MVP on-demand (без bulk/схемы/prod-записей). parking_ratio готов, но НЕ wired в analyze:
конкуренты приходят из ДОМ.РФ без cad_num → нужен domrf↔cad_buildings geom-match (отдельная
задача). Сервис примет cad_num как только matching появится. 11 тестов, ruff clean.

Refs #96
2026-06-14 18:48:16 +05:00

607 lines
24 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.

"""Тесты для NSPDBulkClient (nspd_bulk_client.py) — PR 2/5 issue #168.
Структура:
- Unit тесты (без сети) — параметры, семафор, parse-логика
- E2E тесты (@pytest.mark.slow + @pytest.mark.skip по умолчанию) —
реальные запросы к NSPD, CI не дёргает
Запуск e2e вручную:
cd backend && uv run pytest tests/scrapers/test_nspd_bulk_client.py -m slow -s -v
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.schemas.nspd_bulk import NSPDBulkFeature, ObjectsListing, QuarterSnapshot
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkError,
NspdBulkWafError,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def sample_quarter_response() -> dict[str, Any]:
"""Минимальный валидный ответ search_by_quarter из NSPD.
Real NSPD shape (verified live on 66:41:0303161 in #177):
- `properties.category` (НЕ categoryId)
- `meta` лежит на top-level (не в data.meta)
"""
return {
"data": {
"type": "FeatureCollection",
"features": [
{
"id": 1001,
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6700000.0, 7700000.0],
[6701000.0, 7700000.0],
[6701000.0, 7701000.0],
[6700000.0, 7701000.0],
[6700000.0, 7700000.0],
],
],
},
"properties": {
"category": 36368,
"categoryName": "Земельные участки ЕГРН",
"options": {
"cad_num": "66:41:0303161:123",
"area": 500.0,
},
},
},
{
"id": 2002,
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [6700500.0, 7700500.0]},
"properties": {
"category": 36369,
"categoryName": "Здания",
"options": {
"cad_num": "66:41:0303161:456:1",
"floors": "5",
},
},
},
],
},
# meta — top-level, primary path в search_by_quarter
"meta": [
{"categoryId": 36368, "totalCount": 170},
{"categoryId": 36369, "totalCount": 256},
{"categoryId": 36383, "totalCount": 75},
{"categoryId": 37163, "totalCount": 5877},
{"categoryId": 39663, "totalCount": 1},
],
}
@pytest.fixture
def sample_wms_response() -> dict[str, Any]:
"""Минимальный WMS GetFeatureInfo response."""
return {
"type": "FeatureCollection",
"features": [
{
"id": 3003,
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6700100.0, 7700100.0],
[6700900.0, 7700100.0],
[6700900.0, 7700900.0],
[6700100.0, 7700900.0],
[6700100.0, 7700100.0],
],
],
},
"properties": {
"category": 36368,
"options": {"cad_num": "66:41:0303161:789"},
},
}
],
}
@pytest.fixture
def sample_tab_group_response() -> dict[str, Any]:
"""Минимальный tab-group-data response для здания."""
return {
"title": "Список объектов",
"object": [
{"title": "Помещения (количество)", "value": ["264"]},
{"title": "Машино-места (количество)", "value": ["4"]},
{
"title": "Помещения (список)",
"value": ["66:41:0303161:100:1", "66:41:0303161:100:2"],
},
{
"title": "Машино-места (список)",
"value": ["66:41:0303161:100:265", "66:41:0303161:100:266"],
},
],
}
# ── Unit tests: schemas ───────────────────────────────────────────────────────
def test_nspd_bulk_feature_parse_basic() -> None:
"""NSPDBulkFeature парсит id, geometry, properties (primary path: properties.category)."""
raw = {
"id": 42,
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [6700000.0, 7700000.0]},
"properties": {
"category": 36368,
"options": {"cad_num": "66:41:0303161:1", "area": 300.0},
},
}
feat = NSPDBulkFeature.model_validate(raw)
assert feat.id == 42
assert feat.category_id == 36368
assert feat.cad_num == "66:41:0303161:1"
assert feat.options.area == 300.0
def test_nspd_bulk_feature_extra_fields_preserved() -> None:
"""extra='allow' → неизвестные поля сохраняются."""
raw = {
"id": 1,
"geometry": None,
"properties": {},
"unknown_future_field": "value",
}
feat = NSPDBulkFeature.model_validate(raw)
assert feat.model_extra["unknown_future_field"] == "value" # type: ignore[index]
def test_nspd_options_effective_cad_num_prefers_cad_num() -> None:
"""effective_cad_num берёт cad_num если оба заданы."""
from app.schemas.nspd_bulk import NSPDOptions
opts = NSPDOptions(cad_num="66:41:0303161:1", cad_number="66:41:0303161:2")
assert opts.effective_cad_num == "66:41:0303161:1"
def test_nspd_options_fallback_to_cad_number() -> None:
"""effective_cad_num fallback → cad_number если cad_num is None."""
from app.schemas.nspd_bulk import NSPDOptions
opts = NSPDOptions(cad_number="66:41:0303161:2")
assert opts.effective_cad_num == "66:41:0303161:2"
def test_quarter_snapshot_overflow_categories() -> None:
"""overflow_categories = категории с totalCount > 20."""
snap = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T00:00:00+00:00",
meta_counts={36368: 170, 36369: 256, 36383: 5, 39663: 1},
)
overflow = snap.overflow_categories
assert 36368 in overflow
assert 36369 in overflow
assert 36383 not in overflow # 5 <= 20
assert 39663 not in overflow # 1 <= 20
def test_objects_listing_defaults() -> None:
"""ObjectsListing дефолтные значения."""
listing = ObjectsListing(objdoc_id=12345)
assert listing.flats_count == 0
assert listing.parking_count == 0
assert listing.flats_cad_nums == []
def test_nspd_options_parses_camelcase_objdoc_id() -> None:
"""#96: NSPD search отдаёт objdocId/registersId camelCase → AliasChoices ловит.
Regression: snake-only парсинг давал None (verified live на
66:41:0106036:183, objdocId=40995027) и ломал list_objects_in_building.
"""
from app.schemas.nspd_bulk import NSPDOptions
opts = NSPDOptions.model_validate({"objdocId": 40995027, "registersId": 36441})
assert opts.objdoc_id == 40995027
assert opts.registers_id == 36441
def test_nspd_options_snake_case_objdoc_still_works() -> None:
"""#96: bulk grid-walk snake_case objdoc_id по-прежнему парсится (back-compat)."""
from app.schemas.nspd_bulk import NSPDOptions
opts = NSPDOptions.model_validate({"objdoc_id": 777, "registers_id": 36441})
assert opts.objdoc_id == 777
assert opts.registers_id == 36441
def test_objects_listing_parking_ratio() -> None:
"""#96: parking_ratio = машино-места / помещения, округление 0.01."""
listing = ObjectsListing(objdoc_id=1, flats_count=200, parking_count=240)
assert listing.parking_ratio == 1.2
def test_objects_listing_parking_ratio_none_when_no_flats() -> None:
"""#96: parking_ratio = None при flats_count=0 (нельзя делить, не выдумываем 0)."""
listing = ObjectsListing(objdoc_id=1, flats_count=0, parking_count=0)
assert listing.parking_ratio is None
def test_objects_listing_parking_ratio_zero_parking() -> None:
"""#96: 0 машино-мест при N помещений → ratio 0.0 (реальный дефицит, не None)."""
listing = ObjectsListing(objdoc_id=1, flats_count=232, parking_count=0)
assert listing.parking_ratio == 0.0
# ── Unit tests: client parse logic (mocked HTTP) ─────────────────────────────
@pytest.mark.asyncio
async def test_search_by_quarter_parses_features(
sample_quarter_response: dict[str, Any],
) -> None:
"""search_by_quarter корректно парсит features и meta_counts."""
async with NSPDBulkClient() as client:
with patch.object(client, "_get_json", new=AsyncMock(return_value=sample_quarter_response)):
snap = await client.search_by_quarter("66:41:0303161")
assert snap.quarter == "66:41:0303161"
assert len(snap.features) == 2
assert snap.meta_counts[36368] == 170
assert snap.meta_counts[36369] == 256
assert snap.meta_counts[37163] == 5877
# overflow: категории с >20
overflow = snap.overflow_categories
assert 36368 in overflow
assert 36369 in overflow
assert 36383 in overflow
assert 37163 in overflow
assert 39663 not in overflow # totalCount=1 → no overflow
@pytest.mark.asyncio
async def test_search_by_quarter_empty_on_404() -> None:
"""search_by_quarter возвращает пустой snapshot при 404."""
async with NSPDBulkClient() as client:
with patch.object(
client,
"_get_json",
new=AsyncMock(side_effect=NspdBulkError("HTTP 404: not found")),
):
snap = await client.search_by_quarter("66:41:9999999")
assert snap.features == []
assert snap.meta_counts == {}
assert snap.quarter == "66:41:9999999"
@pytest.mark.asyncio
async def test_search_by_quarter_raises_on_waf() -> None:
"""search_by_quarter пробрасывает NspdBulkWafError (403) без retry."""
async with NSPDBulkClient() as client:
with patch.object(
client,
"_get_json",
new=AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF")),
):
with pytest.raises(NspdBulkWafError):
await client.search_by_quarter("66:41:0303161")
@pytest.mark.asyncio
async def test_wms_feature_info_parses_features(
sample_wms_response: dict[str, Any],
) -> None:
"""wms_feature_info парсит features из GeoJSON response."""
async with NSPDBulkClient() as client:
with patch.object(client, "_get_json", new=AsyncMock(return_value=sample_wms_response)):
features = await client.wms_feature_info(
layer_id=36368,
bbox=(6700000.0, 7700000.0, 6701000.0, 7701000.0),
click_xy=(256, 256),
)
assert len(features) == 1
assert features[0].category_id == 36368
assert features[0].cad_num == "66:41:0303161:789"
@pytest.mark.asyncio
async def test_wms_feature_info_empty_response() -> None:
"""wms_feature_info возвращает [] при пустом response."""
async with NSPDBulkClient() as client:
with patch.object(client, "_get_json", new=AsyncMock(return_value={"features": []})):
features = await client.wms_feature_info(
layer_id=36368,
bbox=(6700000.0, 7700000.0, 6701000.0, 7701000.0),
click_xy=(256, 256),
)
assert features == []
@pytest.mark.asyncio
async def test_list_objects_in_building_parses(
sample_tab_group_response: dict[str, Any],
) -> None:
"""list_objects_in_building парсит counts и cad_nums."""
async with NSPDBulkClient() as client:
with patch.object(
client, "_get_json", new=AsyncMock(return_value=sample_tab_group_response)
):
listing = await client.list_objects_in_building(objdoc_id=42065602)
assert listing.objdoc_id == 42065602
assert listing.flats_count == 264
assert listing.parking_count == 4
assert "66:41:0303161:100:1" in listing.flats_cad_nums
assert "66:41:0303161:100:265" in listing.parking_cad_nums
@pytest.mark.asyncio
async def test_rate_limit_semaphore_max_3_concurrent() -> None:
"""Не более 3 одновременных запросов через per-instance self._sem (PR #260).
Мокируем httpx.AsyncClient.get (нижний уровень) с задержкой, чтобы
реальный семафор работал. Считаем max in-flight внутри семафора.
"""
import httpx
import app.scrapers.nspd_bulk_client as bulk_mod
max_concurrent = 0
current = 0
lock = asyncio.Lock()
call_count = 0
async def slow_get(*args: Any, **kwargs: Any) -> httpx.Response:
nonlocal max_concurrent, current, call_count
# Фиксируем вход — уже внутри семафора (httpx.get вызывается после async with self._sem)
async with lock:
current += 1
call_count += 1
if current > max_concurrent:
max_concurrent = current
await asyncio.sleep(0.02) # симулируем I/O задержку
async with lock:
current -= 1
return httpx.Response(
200,
json={"data": {"features": [], "meta": []}},
request=MagicMock(),
)
async with NSPDBulkClient() as client:
assert client._client is not None
with patch.object(client._client, "get", new=AsyncMock(side_effect=slow_get)):
tasks = [client.search_by_quarter(f"66:41:{i:07d}") for i in range(6)]
await asyncio.gather(*tasks)
# self._sem(3) → не более 3 одновременно внутри slow_get
assert max_concurrent <= 3, f"Expected ≤3 concurrent, got {max_concurrent}"
assert call_count == 6 # все 6 вызовов прошли
# NB: per-instance self._sem cleanup проверяется через max_concurrent <= 3 выше.
# Module-level _SEMAPHORE удалён в PR #260 (cross-loop binding fix); smoke на capacity:
assert bulk_mod._SEMAPHORE_LIMIT == 3
@pytest.mark.asyncio
async def test_http_403_raises_waf_no_retry() -> None:
"""403 → NspdBulkWafError немедленно, без retry."""
import httpx
call_count = 0
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(403, text="Forbidden", request=MagicMock())
async with NSPDBulkClient() as client:
assert client._client is not None
with patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)):
with pytest.raises(NspdBulkWafError):
await client._get_json("https://nspd.gov.ru/test")
assert call_count == 1 # ровно 1 попытка, нет retry
@pytest.mark.asyncio
async def test_http_429_backoff_exhausted() -> None:
"""429 → exponential backoff 3 retries → NspdBulkRateLimitError."""
import httpx
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
call_count = 0
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(429, text="Too Many Requests", request=MagicMock())
# Патчим sleep чтобы тест не ждал 2+4+8 секунд
async with NSPDBulkClient(max_retries=3) as client:
assert client._client is not None
with (
patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)),
patch("app.scrapers.nspd_bulk_client.asyncio.sleep", new=AsyncMock()),
):
with pytest.raises(NspdBulkRateLimitError):
await client._get_json("https://nspd.gov.ru/test")
# 1 оригинальный + 3 retry = 4 вызова
assert call_count == 4
# ── Issue #252: 5xx / WMS ServiceException → NspdBulkServerError ──────────────
@pytest.mark.asyncio
async def test_http_500_raises_server_error() -> None:
"""500 → NspdBulkServerError (подкласс NspdBulkError) — для retry + skip."""
import httpx
from app.scrapers.nspd_bulk_client import NspdBulkError, NspdBulkServerError
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(500, text="Internal Server Error", request=MagicMock())
async with NSPDBulkClient() as client:
assert client._client is not None
with patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)):
with pytest.raises(NspdBulkServerError) as exc_info:
await client._get_json("https://nspd.gov.ru/test")
# Подкласс NspdBulkError → autoretry_for=(Exception,) его поймает, а
# except NspdBulkError-ветки (404/400) тоже сработают если понадобится.
assert isinstance(exc_info.value, NspdBulkError)
@pytest.mark.asyncio
async def test_wms_service_exception_in_200_body_raises_server_error() -> None:
"""HTTP 200 с XML <ServiceException> в теле → NspdBulkServerError (не JSONDecodeError)."""
import httpx
from app.scrapers.nspd_bulk_client import NspdBulkServerError
xml_body = (
'<?xml version="1.0"?><ServiceExceptionReport>'
"<ServiceException>Rendering error</ServiceException></ServiceExceptionReport>"
)
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
200, text=xml_body, headers={"content-type": "application/xml"}, request=MagicMock()
)
async with NSPDBulkClient() as client:
assert client._client is not None
with patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)):
with pytest.raises(NspdBulkServerError):
await client._get_json("https://nspd.gov.ru/wms")
@pytest.mark.asyncio
async def test_wms_feature_info_propagates_server_error(
sample_wms_response: dict[str, Any],
) -> None:
"""wms_feature_info пробрасывает NspdBulkServerError (caller считает fail-rate)."""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
async with NSPDBulkClient() as client:
with patch.object(
client,
"_get_json",
new=AsyncMock(side_effect=NspdBulkServerError("HTTP 500 server error")),
):
with pytest.raises(NspdBulkServerError):
await client.wms_feature_info(
layer_id=36368,
bbox=(6700000.0, 7700000.0, 6701000.0, 7701000.0),
click_xy=(256, 256),
)
# ── E2E tests (network, skipped by default) ───────────────────────────────────
pytestmark_slow = pytest.mark.slow
@pytest.mark.slow
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
@pytest.mark.asyncio
async def test_search_by_quarter_real_ekb() -> None:
"""E2E: поиск по реальному кварталу ЕКБ — 66:41:0303161.
Проверяет:
- features > 100 (квартал непустой)
- meta содержит ключи 36368/36369/36383 с totalCount > 0
- overflow_categories не пуст (значит totalCount > 20 для хотя бы одной)
- headers корректны (нет 403/429)
"""
async with NSPDBulkClient() as client:
snap = await client.search_by_quarter("66:41:0303161")
assert len(snap.features) > 0, "Ожидали хотя бы 1 feature"
# Meta должна содержать основные категории
assert 36368 in snap.meta_counts, "Нет categoryId 36368 (ЗУ ЕГРН) в meta"
assert 36369 in snap.meta_counts, "Нет categoryId 36369 (Здания) в meta"
assert snap.meta_counts[36368] > 0, "ЗУ ЕГРН totalCount должен быть > 0"
assert snap.meta_counts[36369] > 0, "Здания totalCount должен быть > 0"
# В этом квартале >170 ЗУ и >256 зданий → overflow
assert len(snap.overflow_categories) > 0, "Ожидали overflow_categories непустой"
# total_meta_count > 100 (квартал богатый данными)
assert snap.total_meta_count > 100
@pytest.mark.slow
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
@pytest.mark.asyncio
async def test_search_by_quarter_empty() -> None:
"""E2E: несуществующий квартал → пустой snapshot."""
async with NSPDBulkClient() as client:
snap = await client.search_by_quarter("66:41:9999999")
# Должно вернуть пустой snapshot (не raise)
assert snap.features == []
assert snap.quarter == "66:41:9999999"
@pytest.mark.slow
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
@pytest.mark.asyncio
async def test_wms_feature_info_real() -> None:
"""E2E: WMS GetFeatureInfo реальный bbox из ЕКБ.
Использует bbox центра квартала 66:41:0303161 в EPSG:3857.
"""
# Центральная точка квартала 66:41:0303161 в 3857 (примерно)
# ЕКБ центр ≈ 60.6°E, 56.84°N → 3857 ≈ (6748000, 7772000)
center_x = 6748000.0
center_y = 7772000.0
half = 500.0 # 500м bbox
bbox = (center_x - half, center_y - half, center_x + half, center_y + half)
async with NSPDBulkClient() as client:
features = await client.wms_feature_info(
layer_id=36368, # ЗУ ЕГРН
bbox=bbox,
click_xy=(256, 256),
width=512,
height=512,
)
# Хотя бы 1 feature в bbox центра ЕКБ квартала
assert len(features) >= 1, f"Ожидали ≥1 feature, получили {len(features)}"
@pytest.mark.slow
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
@pytest.mark.asyncio
async def test_list_objects_in_building_real() -> None:
"""E2E: реальный objdocId=42065602 → >150 flats_cad_nums."""
async with NSPDBulkClient() as client:
listing = await client.list_objects_in_building(objdoc_id=42065602)
assert listing.objdoc_id == 42065602
assert listing.flats_count > 150, f"Ожидали >150 помещений, получили {listing.flats_count}"
assert (
len(listing.flats_cad_nums) > 150
), f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"