All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m24s
Deploy / build-worker (push) Successful in 3m45s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
225 lines
8.9 KiB
Python
225 lines
8.9 KiB
Python
"""Тесты для 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
|