"""Тесты premises_lookup (#96 MVP on-demand per-building помещения/паркинг). Сетевые вызовы НЕ делаем — мокаем sync NSPDClient.search_by_cad (резолв objdoc) и async NSPDBulkClient.list_objects_in_building (tab-group counts). Проверяем happy path + graceful-фоллбэки (здание не найдено / нет objdoc / WAF). """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch from app.schemas.nspd_bulk import ObjectsListing from app.services.scrapers.nspd_client import NspdLiteWafError MODULE = "app.services.site_finder.premises_lookup" def _run_returns(listing: ObjectsListing | None) -> Any: """side_effect для мока asyncio.run: закрывает переданную корутину и отдаёт listing. Без close() реальная корутина _fetch_listing(...) остаётся never-awaited (RuntimeWarning), т.к. мок asyncio.run её не исполняет. Закрываем явно. """ def _side_effect(coro: Any) -> ObjectsListing | None: coro.close() return listing return _side_effect def _search_result(options: dict[str, Any] | None, feature_id: str | None = "999") -> MagicMock: """Замокать NSPDSearchResult.first → NSPDFeature-подобный объект.""" result = MagicMock() if options is None and feature_id is None: result.first = None return result feature = MagicMock() feature.properties = {"options": options or {}} feature.feature_id = feature_id result.first = feature return result def test_get_building_premises_happy_path() -> None: """search → objdocId → list_objects → ObjectsListing с parking_ratio.""" from app.services.site_finder.premises_lookup import get_building_premises listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run, ): mock_client_cls.return_value.search_by_cad.return_value = _search_result( {"objdocId": 40995027} ) out = get_building_premises("66:41:0106036:183") assert out is listing assert out.parking_ratio == 0.5 mock_run.assert_called_once() def test_get_building_premises_snake_case_objdoc() -> None: """objdoc_id в snake_case тоже резолвится (bulk-shape).""" from app.services.site_finder.premises_lookup import get_building_premises listing = ObjectsListing(objdoc_id=777, flats_count=10, parking_count=2) with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)), ): mock_client_cls.return_value.search_by_cad.return_value = _search_result( {"objdoc_id": 777} ) out = get_building_premises("66:41:0106036:183") assert out is listing def test_get_building_premises_fallback_to_feature_id() -> None: """Нет objdocId в options → fallback на feature.id (verified live: id=objdoc).""" from app.services.site_finder.premises_lookup import get_building_premises listing = ObjectsListing(objdoc_id=12345, flats_count=5, parking_count=0) with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run, ): mock_client_cls.return_value.search_by_cad.return_value = _search_result( {}, feature_id="12345" ) out = get_building_premises("66:41:0106036:183") assert out is listing # objdoc_id=12345 должен был уйти в _fetch_listing mock_run.assert_called_once() def test_get_building_premises_not_found_returns_none() -> None: """Здание не найдено в НСПД (result.first=None) → None, async не дёргается.""" from app.services.site_finder.premises_lookup import get_building_premises with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run") as mock_run, ): mock_client_cls.return_value.search_by_cad.return_value = _search_result( None, feature_id=None ) out = get_building_premises("66:41:0106036:183") assert out is None mock_run.assert_not_called() def test_get_building_premises_no_objdoc_returns_none() -> None: """Нет ни objdocId, ни feature.id → None (нельзя дёрнуть tab-group).""" from app.services.site_finder.premises_lookup import get_building_premises with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run") as mock_run, ): mock_client_cls.return_value.search_by_cad.return_value = _search_result( {}, feature_id=None ) out = get_building_premises("66:41:0106036:183") assert out is None mock_run.assert_not_called() def test_get_building_premises_waf_returns_none() -> None: """WAF/сеть на search_by_cad → None (graceful, analyze не падает).""" from app.services.site_finder.premises_lookup import get_building_premises with ( patch(f"{MODULE}.NSPDClient") as mock_client_cls, patch(f"{MODULE}.asyncio.run") as mock_run, ): mock_client_cls.return_value.search_by_cad.side_effect = NspdLiteWafError("HTTP 403 WAF") out = get_building_premises("66:41:0106036:183") assert out is None mock_run.assert_not_called()