gendesign/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py
bot-backend a9b87aa311
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
chore(tradein/scraper-kit): удалить 5 orphaned legacy-модулей (#2277 финальный шаг, частичный)
Удалены (0 runtime importers в app/, scripts/, packages/; проверено grep'ом,
включая intra-legacy импорты в оставшихся 21 legacy-модуле scrapers/):
- backend/app/services/scrapers/avito_imv.py
- backend/app/services/scrapers/domclick_detail.py
- backend/app/services/scrapers/ekb_geoportal_client.py
- backend/app/services/scrapers/yandex_detail.py
- backend/app/services/scrapers/yandex_valuation.py

Тесты — удалены (pure legacy-vs-kit parity, kit-only coverage уже есть в другом
месте):
- tests/scrapers/test_ekb_geoportal_client_kit_parity.py
- tests/scrapers/test_domclick_detail_kit_parity.py

Тесты — конвертированы в kit-only (импорт легаси заменён на scraper_kit.*,
покрытие сохранено без потерь):
- tests/scrapers/test_avito_imv_kit_parity.py (parity-тесты убраны, config-footgun
  regression на kit-стороне оставлен)
- tests/scrapers/test_avito_imv_browser_transport.py
- tests/scrapers/test_yandex_valuation_kit_migration.py (parity убрана, footgun-
  регрессия mandatory config/delay_provider оставлена)
- tests/scrapers/test_domclick_detail.py (+ exceptions переведены на
  scraper_kit.domclick_exceptions, т.к. kit parse_detail_html поднимает их)
- tests/test_avito_imv_parse.py
- tests/test_ekb_geoportal_ingest.py (client-часть; ingest/geocoder-часть не
  трогали — не зависят от удалённого модуля)
- tests/test_yandex_detail.py
- tests/test_yandex_detail_structural.py
- tests/test_yandex_valuation.py (YandexValuationScraper() → config=_KIT_CONFIG,
  kit конструктор требует обязательный config)
- tests/test_yandex_valuation_save.py
- tests/test_extval_house_id_write_path.py (только yandex_valuation-часть;
  cian_valuation остался нетронутым — живой легаси-модуль)
- tests/test_yandex_history_area_filter.py

Тесты — частично отредактированы (убраны только части про удалённые модули,
живые легаси-модули/их parity не тронуты):
- tests/scrapers/test_admin_domclick_ingest_kit_parity.py (base.py/ScrapedLot
  parity остался)
- tests/scrapers/test_admin_yandex_kit_parity.py (yandex_realty parity остался)
- tests/scrapers/test_admin_avito_kit_parity.py (avito.py/avito_houses.py parity
  остался; _parse_price parity убран)
- tests/scrapers/test_avito_unix_date_tz_consistency.py (IMV-ветка переведена на
  kit avito.imv, легаси avito/avito_houses/avito_shared/avito_detail не тронуты)
- tests/tasks/test_yandex_detail_backfill.py (subject — kit-задача, уже вызывает
  scraper_kit.providers.yandex.detail; save_detail_enrichment coverage-тесты
  внизу файла переведены на kit)
- tests/test_scraper_kit_yandex_golden_parity.py (detail/valuation parity убраны;
  serp/newbuilding/house_type_normalizer/build_url для yandex_realty/
  yandex_newbuilding не тронуты)
- tests/test_scraper_proxy.py (YandexValuation proxy-тесты переведены на kit
  config= DI; test_avito_imv_own_session_receives_proxies удалён — дублирует
  test_avito_imv_kit_parity.py footgun-тесты)
- tests/test_yandex_scrapers_delay_wiring.py (yandex_detail/yandex_valuation →
  kit delay_provider=; yandex_realty/yandex_newbuilding не тронуты)
- tests/test_scraper_kit_group_c_backfill_kit_parity.py (docstring уточнение,
  без функциональных изменений)

Полный backend-suite зелёный (3253 passed, 6 skipped) кроме известного
pre-existing flake test_search_api.py::test_search_cache_hit (#2208, не
регрессия этого PR). ruff check + ruff format — чисто на всех изменённых файлах.
2026-07-04 10:41:46 +03:00

335 lines
12 KiB
Python
Raw 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.

"""Unit tests for the EKB geoportal building ingest pipeline.
Covers:
- ekb_geoportal_client: GeoJSON FeatureCollection → TopoBuilding parsing + centroid,
skip of features without street/house, graceful empty on bad input.
- ekb_geoportal_ingest: house normalization ("7 б""7б"), part-feature grouping,
tile grid bounds.
- geocoder._geoportal_house_match + geocode() wiring: mock db row → GeocodeResult
(provider="cache", confidence="exact"); parse-fail / no-row → falls through.
All network + DB mocked.
Легаси `app.services.scrapers.ekb_geoportal_client` удалён (#2277 финальный шаг
scraper_kit-миграции, 0 runtime importers) — client-тесты переведены на kit-
эквивалент `scraper_kit.providers.ekb_geoportal.client` (уже используемый живым
`app.tasks.ekb_geoportal_ingest`), покрытие не изменилось.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from scraper_kit.providers.ekb_geoportal.client import ( # noqa: E402
TopoBuilding,
parse_feature_collection,
)
from app.tasks.ekb_geoportal_ingest import ( # noqa: E402
_group_buildings,
_tile_grid,
normalize_house,
normalize_street,
)
# ── client: GeoJSON parsing + centroid ───────────────────────────────────────
def _multipolygon(coords: list) -> dict:
return {"type": "MultiPolygon", "coordinates": coords}
# A unit square [0,0]-[2,2] → centroid (1, 1) in (lon, lat).
_SQUARE = [[[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]]]
def test_parse_feature_collection_basic() -> None:
fc = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": _multipolygon(_SQUARE),
"properties": {
"topo_street": "Космонавтов",
"topo_num_house": "7б",
"topo_floor": 9,
"topo_purpose": "жилое",
"topo_material": "3",
"key": 12345,
},
}
],
}
out = parse_feature_collection(fc)
assert len(out) == 1
b = out[0]
assert isinstance(b, TopoBuilding)
assert b.street == "Космонавтов"
assert b.house == "7б"
assert b.floors == 9
assert b.purpose == "жилое"
assert b.material == "3"
assert b.key == 12345
# centroid of the [0,0]-[2,2] square = (1, 1): lon=x, lat=y
assert b.lon == pytest.approx(1.0)
assert b.lat == pytest.approx(1.0)
def test_parse_skips_features_without_street_or_house() -> None:
fc = {
"features": [
{
"geometry": _multipolygon(_SQUARE),
"properties": {"topo_street": "", "topo_num_house": "7б"},
},
{
"geometry": _multipolygon(_SQUARE),
"properties": {"topo_street": "Ленина", "topo_num_house": " "},
},
{
"geometry": _multipolygon(_SQUARE),
"properties": {"topo_num_house": "5"}, # no street key
},
]
}
assert parse_feature_collection(fc) == []
def test_parse_skips_feature_without_geometry() -> None:
fc = {
"features": [
{
"geometry": None,
"properties": {"topo_street": "Ленина", "topo_num_house": "5"},
}
]
}
assert parse_feature_collection(fc) == []
def test_parse_empty_or_malformed_collection() -> None:
assert parse_feature_collection({}) == []
assert parse_feature_collection({"features": "nope"}) == []
assert parse_feature_collection({"features": [None, 42]}) == []
# ── normalization ────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
("7 б", "7б"),
("", "7б"),
(" 7б ", "7б"),
("1в/1", "1в/1"),
("12", "12"),
("3 А", "3а"),
],
)
def test_normalize_house(raw: str, expected: str) -> None:
assert normalize_house(raw) == expected
def test_normalize_street() -> None:
assert normalize_street(" Космонавтов ") == "космонавтов"
assert normalize_street("8 Марта") == "8 марта"
# ── grouping ─────────────────────────────────────────────────────────────────
def test_group_buildings_merges_parts() -> None:
parts = [
TopoBuilding("Космонавтов", "7 б", 9, "жилое", "3", 1, lat=2.0, lon=4.0),
TopoBuilding("Космонавтов", "7б", None, None, None, 2, lat=4.0, lon=6.0),
TopoBuilding("Космонавтов", "7а", 5, None, None, 3, lat=1.0, lon=1.0),
]
grouped = _group_buildings(parts)
# "7 б" and "7б" normalize to the same house → one merged entry
assert ("космонавтов", "7б") in grouped
assert ("космонавтов", "7а") in grouped
assert len(grouped) == 2
merged = grouped[("космонавтов", "7б")]
# centroid = mean of (2,4) and (4,6)
assert merged["lat_sum"] / merged["n"] == pytest.approx(3.0)
assert merged["lon_sum"] / merged["n"] == pytest.approx(5.0)
assert merged["floors"] == 9 # max across parts (other is None)
assert merged["purpose"] == "жилое"
assert merged["material"] == "3"
assert sorted(merged["source_keys"]) == [1, 2]
# ── tile grid ────────────────────────────────────────────────────────────────
def test_tile_grid_covers_bbox() -> None:
tiles = _tile_grid(60.45, 56.70, 60.85, 56.95)
assert len(tiles) > 0
# every tile within bbox bounds
for lon_lo, lat_lo, lon_hi, lat_hi in tiles:
assert 60.45 <= lon_lo < lon_hi <= 60.85 + 1e-9
assert 56.70 <= lat_lo < lat_hi <= 56.95 + 1e-9
# first tile anchored at SW corner
assert tiles[0][0] == pytest.approx(60.45)
assert tiles[0][1] == pytest.approx(56.70)
# ── geocoder._geoportal_house_match + geocode() wiring ───────────────────────
def test_geoportal_house_match_returns_suggestion() -> None:
from app.services.geocoder import GeocodeSuggestion, _geoportal_house_match
row = MagicMock()
row.street = "Космонавтов"
row.house = "7б"
row.lat = 56.864
row.lon = 60.611
db = MagicMock()
result = MagicMock()
result.first.return_value = row
db.execute.return_value = result
hit = _geoportal_house_match(db, "Космонавтов", "7 б")
assert isinstance(hit, GeocodeSuggestion)
assert hit.kind == "house"
assert hit.lat == 56.864
assert hit.lon == 60.611
# house param normalized: "7 б" → "7б"
params = db.execute.call_args.args[1]
assert params["house"] == "7б"
assert params["street"] == "космонавтов"
def test_geoportal_house_match_none_when_no_row() -> None:
from app.services.geocoder import _geoportal_house_match
db = MagicMock()
result = MagicMock()
result.first.return_value = None
db.execute.return_value = result
assert _geoportal_house_match(db, "Космонавтов", "7б") is None
def test_geoportal_house_match_none_on_db_error() -> None:
from app.services.geocoder import _geoportal_house_match
db = MagicMock()
db.execute.side_effect = RuntimeError("table missing")
assert _geoportal_house_match(db, "Космонавтов", "7б") is None
def test_geoportal_house_match_none_for_empty_house() -> None:
from app.services.geocoder import _geoportal_house_match
db = MagicMock()
assert _geoportal_house_match(db, "Космонавтов", " ") is None
db.execute.assert_not_called()
async def test_geocode_uses_geoportal_first() -> None:
"""Parse + geoportal hit → GeocodeResult(provider=cache, exact); cad NOT called."""
from app.services.geocoder import GeocodeSuggestion, geocode
db = MagicMock()
hit = GeocodeSuggestion(
label="Космонавтов, 7б",
full_address="Космонавтов, 7б",
lat=56.864,
lon=60.611,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geo,
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Космонавтов 7б", db)
assert result is not None
assert result.provider == "cache"
assert result.confidence == "exact"
assert result.lat == 56.864
assert result.lon == 60.611
mock_geo.assert_called_once()
# geoportal hit short-circuits everything downstream
mock_cad.assert_not_called()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
"""Geoportal None → cadastral house-match IS called next."""
from app.services.geocoder import GeocodeSuggestion, geocode
db = MagicMock()
cad_hit = GeocodeSuggestion(
label="ул. Серова, д. 27",
full_address="ул. Серова, д. 27, Екатеринбург",
lat=56.81188,
lon=60.59739,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None) as mock_geo,
patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
):
result = await geocode("Серова 27", db)
assert result is not None
assert result.lat == 56.81188
mock_geo.assert_called_once()
mock_cad.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_skips_geoportal_when_parse_fails() -> None:
"""Unparseable address → geoportal NOT called."""
from app.services.geocoder import geocode
db = MagicMock()
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geo,
patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db)
assert result is None
mock_geo.assert_not_called()
mock_cad.assert_not_called()
mock_forward.assert_called_once()