gendesign/tradein-mvp/backend/tests/services/test_dadata.py
Light1YT 386fa8c0e3 feat(tradein): DaData /suggest tier в /suggest endpoint (PR Q2)
Yandex Suggest заблокирован (демо 1k/день исчерпан) — DaData /suggest/address
(10k/день free, token-only auth, лучшее покрытие РФ) заменяет его как Tier 2
после Cadastral FDW. Yandex остаётся как Tier 3 fallback.

- app/services/dadata.py: DadataSuggestion dataclass + async suggest_addresses()
  (token-only, без X-Secret; graceful [] на все ошибки; 5s timeout;
  classifies fias_level→kind: 8=house, 7=street, ≤6=city; count clamp ≤20)
- app/services/geocoder.py: _dadata_suggest() обёртка, дропает entries без
  координат; suggest() теперь Cadastral → DaData → Yandex → Nominatim
- tests/services/test_dadata.py: 15 новых unit-тестов (happy + все error paths)
- tests/services/test_cadastral_reverse.py: +тест DaData до Yandex, update
  fallback теста под новый tier chain

Tests: 62 passed (35 dadata + 20 cadastral reverse + 7 geocode reverse api).
Ruff clean. Reuse DADATA_API_TOKEN from PR Q1 (#604) — secret не нужен для suggest.
2026-05-27 19:08:58 +05:00

673 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"<html>not json</html>")
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