"""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"