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
Оживляет мёртвый 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
607 lines
24 KiB
Python
607 lines
24 KiB
Python
"""Тесты для 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)}"
|