"""Unit tests for `app.services.dadata.clean_address`. Coverage: - happy path: mocked httpx 200 → parsed DadataAddressResult - no API key → None gracefully (no httpx call) - network failure (TimeoutException) → None + warning log - network failure (NetworkError) → None - HTTP 429 quota exceeded → None - HTTP 401 / 403 auth rejected → None - HTTP 5xx → None - empty response array → None - non-list response → None - response item missing canonical address → None - short address (< 3 chars) → None - credentials present but secret blank → None NEVER calls real DaData API — все запросы mock'аются через httpx.MockTransport (consistent с tests/test_backfill_house_coords.py + test_audit_address_mismatch.py). """ from __future__ import annotations import os import sys from unittest.mock import MagicMock, patch import httpx import pytest # DATABASE_URL required by config before any app import (см. test_cadastral_reverse.py). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint stub — not installed in CI без GTK _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) # Sample response mimics real DaData payload для «Екатеринбург, ул. Малышева, 125» # (см. PR Q1 task description в gendesign vault meta/00_credentials.md). SAMPLE_OK_PAYLOAD = [ { "source": "Екатеринбург, улица Малышева, 125", "result": "г Екатеринбург, ул Малышева, д 125", "house_cadnum": "66:41:0704045:350", "house_fias_id": "74457c2f-1234-5678-90ab-cdef01234567", "geo_lat": "56.840851", "geo_lon": "60.654071", "qc_geo": 0, "qc_house": 2, "kladr_id": "6600000100006480117", "okato": "65401000000", "oktmo": "65701000001", "metro": [ {"name": "Площадь 1905 года", "line": "Первая", "distance": 3.4}, {"name": "Геологическая", "line": "Первая", "distance": 4.1}, ], } ] def _mock_transport_returning( status_code: int = 200, payload: object = SAMPLE_OK_PAYLOAD ) -> httpx.MockTransport: """httpx MockTransport — returns desired status + JSON payload for every request.""" def handler(request: httpx.Request) -> httpx.Response: # Sanity: запрос должен идти на DaData /clean/address с правильными headers assert "cleaner.dadata.ru" in str(request.url) assert request.headers.get("Authorization", "").startswith("Token ") assert request.headers.get("X-Secret") return httpx.Response(status_code, json=payload) return httpx.MockTransport(handler) # Snapshot real httpx.AsyncClient ДО любых patch'ей — фабрика ниже использует # именно его, чтобы patch'нутый `httpx.AsyncClient` не вызывал recursion # (см. PR Q1 — fix recursion в test mocks). _REAL_ASYNC_CLIENT = httpx.AsyncClient def _async_client_factory(transport: httpx.MockTransport): """Returns drop-in replacement for httpx.AsyncClient that uses our MockTransport. Принимает любые kwargs конструктора (timeout=…) и игнорирует их — использует _REAL_ASYNC_CLIENT с подменённым transport. """ def factory(*_: object, **__: object) -> httpx.AsyncClient: return _REAL_ASYNC_CLIENT(transport=transport) return factory def _patch_settings(token: str | None = "fake-token", secret: str | None = "fake-secret"): """Patch app.services.dadata.settings with given credentials.""" fake = MagicMock() fake.dadata_api_token = token fake.dadata_api_secret = secret return patch("app.services.dadata.settings", fake) def _patch_async_client(transport: httpx.MockTransport): """Patch the httpx.AsyncClient used inside dadata module.""" return patch("app.services.dadata.httpx.AsyncClient", _async_client_factory(transport)) # ── happy path ──────────────────────────────────────────────────────────────── async def test_clean_address_happy_path_returns_parsed_result() -> None: """Mocked 200 → DadataAddressResult с canonical/cadnum/lat/lon/metro.""" from app.services import dadata transport = _mock_transport_returning(200, SAMPLE_OK_PAYLOAD) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Екатеринбург, ул. Малышева, 125") assert result is not None assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125" assert result.house_cadnum == "66:41:0704045:350" assert result.house_fias_id == "74457c2f-1234-5678-90ab-cdef01234567" assert result.lat is not None assert abs(result.lat - 56.840851) < 1e-6 assert result.lon is not None assert abs(result.lon - 60.654071) < 1e-6 assert result.qc_geo == 0 assert result.qc_house == 2 assert result.kladr_id == "6600000100006480117" assert result.okato == "65401000000" assert result.oktmo == "65701000001" assert len(result.metro) == 2 assert result.metro[0]["name"] == "Площадь 1905 года" # raw payload preserved для debugging assert result.raw["house_cadnum"] == "66:41:0704045:350" # ── credentials missing → graceful None ───────────────────────────────────── async def test_clean_address_returns_none_when_token_missing() -> None: """DADATA_API_TOKEN не задан → None, никакого httpx-вызова.""" from app.services import dadata with _patch_settings(token=None, secret="fake-secret"): # Не используем mock_transport — если код всё-таки попробует httpx, # это упадёт на конкретной сети (мы это поймаем в assert ниже). with patch("app.services.dadata.httpx.AsyncClient") as mock_client: result = await dadata.clean_address("Малышева 125") assert result is None mock_client.assert_not_called() async def test_clean_address_returns_none_when_secret_missing() -> None: """DADATA_API_SECRET пустая строка → None.""" from app.services import dadata with _patch_settings(token="fake-token", secret=""): with patch("app.services.dadata.httpx.AsyncClient") as mock_client: result = await dadata.clean_address("Малышева 125") assert result is None mock_client.assert_not_called() async def test_clean_address_returns_none_when_address_too_short() -> None: """Адрес < 3 символов → None, никакого вызова DaData.""" from app.services import dadata with _patch_settings(): with patch("app.services.dadata.httpx.AsyncClient") as mock_client: result = await dadata.clean_address("a") assert result is None result_blank = await dadata.clean_address("") assert result_blank is None mock_client.assert_not_called() # ── network failures → None ───────────────────────────────────────────────── async def test_clean_address_returns_none_on_timeout() -> None: """httpx TimeoutException → None gracefully.""" from app.services import dadata def handler(request: httpx.Request) -> httpx.Response: raise httpx.TimeoutException("read timed out") transport = httpx.MockTransport(handler) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None async def test_clean_address_returns_none_on_network_error() -> None: """httpx NetworkError (DNS / connection refused) → None.""" from app.services import dadata def handler(request: httpx.Request) -> httpx.Response: raise httpx.NetworkError("connection refused") transport = httpx.MockTransport(handler) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None # ── HTTP errors → None ────────────────────────────────────────────────────── @pytest.mark.parametrize("status", [429, 401, 403, 500, 502, 503]) async def test_clean_address_returns_none_on_http_error(status: int) -> None: """Любая 4xx/5xx → None (gracefully degrade).""" from app.services import dadata transport = _mock_transport_returning(status, {"error": "rejected"}) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None # ── bad response shapes → None ────────────────────────────────────────────── async def test_clean_address_returns_none_when_response_is_empty_array() -> None: """[] → None.""" from app.services import dadata transport = _mock_transport_returning(200, []) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None async def test_clean_address_returns_none_when_response_is_dict_not_list() -> None: """Dict вместо list → None (DaData всегда отдаёт list).""" from app.services import dadata transport = _mock_transport_returning(200, {"result": "wrong shape"}) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None async def test_clean_address_returns_none_when_item_is_not_dict() -> None: """[\"string\"] вместо [{...}] → None.""" from app.services import dadata transport = _mock_transport_returning(200, ["not a dict"]) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None async def test_clean_address_returns_none_when_canonical_address_missing() -> None: """DaData отдал shape, но result=None → не распознан, None.""" from app.services import dadata payload = [{"result": None, "qc_geo": 5, "qc_house": None}] transport = _mock_transport_returning(200, payload) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("какая-то ерунда") assert result is None async def test_clean_address_returns_none_on_invalid_json() -> None: """Сервер ответил 200, но body не JSON → None.""" from app.services import dadata def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"not json") transport = httpx.MockTransport(handler) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is None # ── partial payload handling ───────────────────────────────────────────────── async def test_clean_address_handles_partial_payload_without_cadnum() -> None: """Адрес распознан (есть `result`), но дома нет в ФИАС → cadnum=None, всё равно success.""" from app.services import dadata partial = [ { "result": "г Екатеринбург, ул Несуществующая, д 999", "house_cadnum": None, "house_fias_id": None, "geo_lat": "56.84", "geo_lon": "60.65", "qc_geo": 1, # only street found "qc_house": 5, # дом не найден "metro": None, } ] transport = _mock_transport_returning(200, partial) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Несуществующая 999") assert result is not None assert result.canonical_address == "г Екатеринбург, ул Несуществующая, д 999" assert result.house_cadnum is None assert result.house_fias_id is None assert result.qc_geo == 1 assert result.qc_house == 5 # metro=None coerce'нулся в пустой список assert result.metro == [] async def test_clean_address_coerces_string_quality_codes_to_int() -> None: """Иногда DaData отдаёт qc_geo строкой '0' → должно стать int.""" from app.services import dadata payload = [ { "result": "г Екатеринбург, ул Малышева, д 125", "qc_geo": "0", "qc_house": "2", "geo_lat": "56.84", "geo_lon": "60.65", } ] transport = _mock_transport_returning(200, payload) with _patch_settings(), _patch_async_client(transport): result = await dadata.clean_address("Малышева 125") assert result is not None assert result.qc_geo == 0 assert result.qc_house == 2 async def test_clean_address_sends_proper_request_body() -> None: """Тело запроса — array из одного элемента: [address]. Содержит правильный текст.""" from app.services import dadata captured: dict[str, object] = {} def handler(request: httpx.Request) -> httpx.Response: import json as _json captured["body"] = _json.loads(request.content.decode("utf-8")) captured["auth"] = request.headers.get("Authorization") captured["secret"] = request.headers.get("X-Secret") return httpx.Response(200, json=SAMPLE_OK_PAYLOAD) transport = httpx.MockTransport(handler) with _patch_settings(token="my-token", secret="my-secret"), _patch_async_client(transport): result = await dadata.clean_address(" Екатеринбург, Малышева 125 ") assert result is not None assert captured["body"] == ["Екатеринбург, Малышева 125"] # trimmed assert captured["auth"] == "Token my-token" assert captured["secret"] == "my-secret" # ───────────────────────────────────────────────────────────────────────────── # DaData /suggest/address tests (PR Q2) # ───────────────────────────────────────────────────────────────────────────── SAMPLE_SUGGEST_PAYLOAD: dict = { "suggestions": [ { "value": "г Екатеринбург, ул Малышева, д 30", "unrestricted_value": "620075, Свердловская обл, г Екатеринбург, ул Малышева, д 30", "data": { "geo_lat": "56.838011", "geo_lon": "60.601023", "fias_id": "00000000-aaaa-bbbb-cccc-000000000001", "fias_level": "8", "house": "30", "street": "Малышева", "city": "Екатеринбург", }, }, { "value": "г Екатеринбург, ул Малышева", "unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева", "data": { "geo_lat": "56.838", "geo_lon": "60.605", "fias_id": "00000000-aaaa-bbbb-cccc-000000000002", "fias_level": "7", "house": None, "street": "Малышева", "city": "Екатеринбург", }, }, { "value": "г Екатеринбург", "unrestricted_value": "Свердловская обл, г Екатеринбург", "data": { "geo_lat": "56.838", "geo_lon": "60.605", "fias_id": "00000000-aaaa-bbbb-cccc-000000000003", "fias_level": "4", "house": None, "street": None, "city": "Екатеринбург", }, }, ] } def _mock_suggest_transport( status_code: int = 200, payload: object = SAMPLE_SUGGEST_PAYLOAD ) -> httpx.MockTransport: """MockTransport для /suggest endpoint — отличается URL и headers от /clean.""" def handler(request: httpx.Request) -> httpx.Response: assert "suggestions.dadata.ru" in str(request.url) assert request.headers.get("Authorization", "").startswith("Token ") # Suggest endpoint НЕ требует X-Secret (отличие от /clean) assert request.headers.get("X-Secret") is None return httpx.Response(status_code, json=payload) return httpx.MockTransport(handler) async def test_suggest_addresses_happy_path() -> None: """200 → list из 3 DadataSuggestion с правильным kind для каждого fias_level.""" from app.services import dadata transport = _mock_suggest_transport(200, SAMPLE_SUGGEST_PAYLOAD) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("Малышева") assert len(results) == 3 # First — house (fias_level=8) assert results[0].value == "г Екатеринбург, ул Малышева, д 30" assert results[0].lat is not None assert abs(results[0].lat - 56.838011) < 1e-6 assert results[0].lon is not None assert abs(results[0].lon - 60.601023) < 1e-6 assert results[0].fias_id == "00000000-aaaa-bbbb-cccc-000000000001" assert results[0].house == "30" assert results[0].street == "Малышева" assert results[0].city == "Екатеринбург" assert results[0].kind == "house" # Second — street (fias_level=7) assert results[1].kind == "street" # Third — city (fias_level=4) assert results[2].kind == "city" async def test_suggest_addresses_returns_empty_on_no_token() -> None: """DADATA_API_TOKEN не задан → [], никакого httpx-вызова.""" from app.services import dadata with _patch_settings(token=None, secret="anything"): with patch("app.services.dadata.httpx.AsyncClient") as mock_client: results = await dadata.suggest_addresses("Малышева") assert results == [] mock_client.assert_not_called() async def test_suggest_addresses_returns_empty_on_short_query() -> None: """Query < 2 символов → [], никакого вызова DaData.""" from app.services import dadata with _patch_settings(): with patch("app.services.dadata.httpx.AsyncClient") as mock_client: assert await dadata.suggest_addresses("") == [] assert await dadata.suggest_addresses("a") == [] assert await dadata.suggest_addresses(" ") == [] mock_client.assert_not_called() async def test_suggest_addresses_returns_empty_on_network_error() -> None: """httpx TimeoutException → [] gracefully.""" from app.services import dadata def handler(request: httpx.Request) -> httpx.Response: raise httpx.TimeoutException("read timed out") transport = httpx.MockTransport(handler) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("Малышева") assert results == [] @pytest.mark.parametrize("status", [429, 401, 403, 500, 502, 503]) async def test_suggest_addresses_returns_empty_on_http_error(status: int) -> None: """4xx/5xx → [].""" from app.services import dadata transport = _mock_suggest_transport(status, {"error": "rejected"}) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("Малышева") assert results == [] async def test_suggest_addresses_returns_empty_on_malformed_response() -> None: """Нет ключа `suggestions` / не dict / список вместо dict → [].""" from app.services import dadata # Case 1: dict без ключа `suggestions` transport = _mock_suggest_transport(200, {"other": "key"}) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("Малышева") assert results == [] # Case 2: пустой suggestions list transport2 = _mock_suggest_transport(200, {"suggestions": []}) with _patch_settings(), _patch_async_client(transport2): results = await dadata.suggest_addresses("Малышева") assert results == [] # Case 3: response — list вместо dict transport3 = _mock_suggest_transport(200, ["wrong shape"]) with _patch_settings(), _patch_async_client(transport3): results = await dadata.suggest_addresses("Малышева") assert results == [] async def test_suggest_addresses_filters_null_coords() -> None: """Suggest должен парсить запись даже без координат — фильтр координат отвечает _dadata_suggest в geocoder.py. Сам suggest_addresses возвращает DadataSuggestion с lat=None/lon=None (не отбрасывает на этом уровне).""" from app.services import dadata payload = { "suggestions": [ { "value": "г Екатеринбург, район Кировский", "unrestricted_value": "Свердловская обл, г Екатеринбург, р-н Кировский", "data": { "geo_lat": None, "geo_lon": None, "fias_level": "6", "city": "Екатеринбург", }, }, { "value": "г Екатеринбург, ул Малышева, д 30", "unrestricted_value": "620075, Свердловская обл, г Екатеринбург, ул Малышева, д 30", "data": { "geo_lat": "56.838", "geo_lon": "60.601", "fias_level": "8", "house": "30", "street": "Малышева", "city": "Екатеринбург", }, }, ] } transport = _mock_suggest_transport(200, payload) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("Кировский") # suggest_addresses вернёт оба — фильтр координат в _dadata_suggest (geocoder) assert len(results) == 2 assert results[0].lat is None assert results[0].lon is None assert results[1].lat is not None async def test_suggest_addresses_classifies_kind() -> None: """fias_level → kind. 8→house, 7→street, ≤6→city. Также coerces str→int.""" from app.services import dadata payload = { "suggestions": [ { "value": "Дом", "unrestricted_value": "Дом", "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "8"}, }, { "value": "Улица", "unrestricted_value": "Улица", "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "7"}, }, { "value": "Город", "unrestricted_value": "Город", "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "4"}, }, { "value": "Регион", "unrestricted_value": "Регион", "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "1"}, }, { "value": "Квартира", # fias_level=9 (>= 8) → house bucket "unrestricted_value": "Квартира", "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "9"}, }, { "value": "Без уровня", # fias_level отсутствует → city safe default "unrestricted_value": "Без уровня", "data": {"geo_lat": "56.84", "geo_lon": "60.6"}, }, ] } transport = _mock_suggest_transport(200, payload) with _patch_settings(), _patch_async_client(transport): results = await dadata.suggest_addresses("test") assert len(results) == 6 assert results[0].kind == "house" # 8 assert results[1].kind == "street" # 7 assert results[2].kind == "city" # 4 assert results[3].kind == "city" # 1 assert results[4].kind == "house" # 9 (>=8) assert results[5].kind == "city" # missing → default async def test_suggest_addresses_sends_proper_request() -> None: """Body содержит query/count/locations, header только Authorization (без X-Secret).""" from app.services import dadata captured: dict[str, object] = {} def handler(request: httpx.Request) -> httpx.Response: import json as _json assert "suggestions.dadata.ru" in str(request.url) captured["body"] = _json.loads(request.content.decode("utf-8")) captured["auth"] = request.headers.get("Authorization") captured["x_secret"] = request.headers.get("X-Secret") return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD) transport = httpx.MockTransport(handler) with _patch_settings(token="my-token"), _patch_async_client(transport): results = await dadata.suggest_addresses(" Малышева ", limit=5, city="Екатеринбург") assert len(results) > 0 body = captured["body"] assert isinstance(body, dict) assert body["query"] == "Малышева" # trimmed assert body["count"] == 5 assert body["locations"] == [{"city": "Екатеринбург"}] assert captured["auth"] == "Token my-token" # Critical: suggest endpoint should NOT require X-Secret assert captured["x_secret"] is None async def test_suggest_addresses_clamps_limit() -> None: """count должен быть clamped в [1, 20] — DaData ограничение.""" from app.services import dadata captured: dict[str, object] = {} def handler(request: httpx.Request) -> httpx.Response: import json as _json captured["body"] = _json.loads(request.content.decode("utf-8")) return httpx.Response(200, json={"suggestions": []}) transport = httpx.MockTransport(handler) with _patch_settings(), _patch_async_client(transport): await dadata.suggest_addresses("Малышева", limit=100) body = captured["body"] assert isinstance(body, dict) assert body["count"] == 20 # clamped to max