"""Тесты для dadata_client.clean_address (#2177 geo-pass). httpx.Client замокан — реальная сеть/DaData не дёргается. settings-credentials патчатся через monkeypatch (литерала ключа нигде нет). """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch import httpx import pytest from app.services import dadata_client from app.services.dadata_client import clean_address @pytest.fixture(autouse=True) def _creds(monkeypatch: pytest.MonkeyPatch) -> None: """Задаём фейковые credentials, чтобы clean_address не отваливался на guard.""" monkeypatch.setattr(dadata_client.settings, "dadata_api_token", "test-token") monkeypatch.setattr(dadata_client.settings, "dadata_api_secret", "test-secret") def _mock_client(response: MagicMock) -> MagicMock: """httpx.Client() context-manager, post() → response.""" client = MagicMock() client.__enter__ = MagicMock(return_value=client) client.__exit__ = MagicMock(return_value=False) client.post.return_value = response return client def _resp(status: int, json_body: Any) -> MagicMock: resp = MagicMock() resp.status_code = status resp.json.return_value = json_body resp.text = "" return resp def test_clean_address_returns_coords() -> None: """Успешный ответ с geo_lat/geo_lon → (lat, lon) float-пара.""" resp = _resp(200, [{"geo_lat": "56.8389", "geo_lon": "60.6057", "qc_geo": 0}]) with patch("httpx.Client", return_value=_mock_client(resp)): result = clean_address("г Екатеринбург, ул Малышева, 125") assert result == (56.8389, 60.6057) def test_clean_address_no_coords_returns_none() -> None: """Ответ без geo_lat/geo_lon (адрес не геокодирован) → None.""" resp = _resp(200, [{"qc_geo": 5, "result": None}]) with patch("httpx.Client", return_value=_mock_client(resp)): assert clean_address("нераспознаваемо") is None def test_clean_address_empty_array_returns_none() -> None: """Пустой массив → None.""" resp = _resp(200, []) with patch("httpx.Client", return_value=_mock_client(resp)): assert clean_address("г Екатеринбург, ул X, 1") is None def test_clean_address_short_input_returns_none() -> None: """Короткий/пустой адрес → None без сетевого вызова.""" assert clean_address("") is None assert clean_address("ab") is None def test_clean_address_no_credentials_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: """Нет credentials → graceful None (geo-pass reject'ит, не падает).""" monkeypatch.setattr(dadata_client.settings, "dadata_api_token", None) monkeypatch.setattr(dadata_client.settings, "dadata_api_secret", None) assert clean_address("г Екатеринбург, ул Малышева, 125") is None def test_clean_address_429_returns_none() -> None: """HTTP 429 quota → None.""" resp = _resp(429, {}) with patch("httpx.Client", return_value=_mock_client(resp)): assert clean_address("г Екатеринбург, ул X, 1") is None def test_clean_address_timeout_returns_none() -> None: """Сетевой timeout → None, не пробрасываем исключение.""" client = MagicMock() client.__enter__ = MagicMock(return_value=client) client.__exit__ = MagicMock(return_value=False) client.post.side_effect = httpx.TimeoutException("timed out") with patch("httpx.Client", return_value=client): assert clean_address("г Екатеринбург, ул X, 1") is None def test_clean_address_403_falls_back_to_suggest(monkeypatch) -> None: """Фича CLEAN отключена (403) → фолбэк на suggest/address отдаёт координаты. Прод-инцидент 2026-07-03: «Feature 'CLEAN' disabled for token» при живом бесплатном suggestions API. """ from app.services import dadata_client as dc monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") class _Resp: def __init__(self, status_code, payload): self.status_code = status_code self._payload = payload def json(self): return self._payload class _Client: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, **kwargs): if "cleaner" in url: return _Resp(403, {"message": "Feature 'CLEAN' disabled"}) assert "suggestions" in url assert "X-Secret" not in kwargs.get("headers", {}) return _Resp( 200, { "suggestions": [ { "value": "г Екатеринбург, ул Мира, д 19", "data": {"geo_lat": "56.8447", "geo_lon": "60.6547"}, } ] }, ) monkeypatch.setattr(dc.httpx, "Client", _Client) coords = dc.clean_address("г Екатеринбург, ул Мира, 19") assert coords is not None assert abs(coords[0] - 56.8447) < 1e-6 and abs(coords[1] - 60.6547) < 1e-6 # ── #2464: дрейф формы `data` не должен ронять весь гео-проход ────────────────── def _suggest_fallback_client(data_field: Any): """Клиент, у которого cleaner отдаёт 403 (фолбэк на suggest), а suggest — ответ с произвольным содержимым поля `data`.""" class _Resp: def __init__(self, status_code, payload): self.status_code = status_code self._payload = payload def json(self): return self._payload class _Client: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, **kwargs): if "cleaner" in url: return _Resp(403, {"message": "Feature 'CLEAN' disabled"}) return _Resp(200, {"suggestions": [{"value": "адрес", "data": data_field}]}) return _Client @pytest.mark.parametrize( "bad_data", [ pytest.param(["не словарь"], id="список"), pytest.param("строка", id="строка"), pytest.param(42, id="число"), ], ) def test_suggest_fallback_survives_non_dict_data(monkeypatch, bad_data: Any) -> None: """`data` не словарь → None, а не AttributeError. Раньше стояло `data = suggestions[0].get("data") or {}` — `or {}` ловит только falsy, поэтому истинное не-словарное значение доходило до `.get` и роняло AttributeError. Летел он НАРУЖУ: в suggest попадают из clean_address по фолбэку 403, то есть уже за пределами её try/except, а у вызывающего (objective_backfill._geocode) обёртки нет вовсе — один такой ответ убил бы весь гео-проход, а не один адрес. Соседние уровни того же файла проверяются через isinstance (`payload`, `suggestions[0]`, `item`); защита пропала ровно на уровень глубже. """ from app.services import dadata_client as dc monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") monkeypatch.setattr(dc.httpx, "Client", _suggest_fallback_client(bad_data)) assert dc.clean_address("г Екатеринбург, ул Мира, 19") is None def test_suggest_fallback_still_reads_a_proper_dict(monkeypatch) -> None: """Контроль: правильная форма по-прежнему отдаёт координаты (зелёный с обеих сторон — иначе «починка» могла бы свестись к «всегда None»).""" from app.services import dadata_client as dc monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") monkeypatch.setattr( dc.httpx, "Client", _suggest_fallback_client({"geo_lat": "56.8447", "geo_lon": "60.6547"}), ) coords = dc.clean_address("г Екатеринбург, ул Мира, 19") assert coords is not None assert abs(coords[0] - 56.8447) < 1e-6