gendesign/tradein-mvp/backend/tests/services/test_dadata.py
bot-backend e250869c9d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен
На проде у DaData-аккаунта выключена услуга CLEAN (Стандартизация) → /clean/address
отдаёт HTTP 403 «Feature CLEAN disabled», из-за чего house_fias_id (ключ join с
houses/ДОМ.РФ) и гео терялись, хотя /suggest их отдаёт бесплатно (token-only).

- enrich_address(): /clean → graceful fallback на /suggest (house-level кандидат);
  canonical_address/house_cadnum честно None (их даёт только CLEAN — не выдумываем).
- clean_address(): 403 «feature disabled» логируется отдельно от auth-rejection
  (токен валиден — refresh не поможет; чинить = включить услугу либо fallback).
- DadataSuggestion + _parse_suggestion: тянут house_fias_id/kladr_id/qc_geo.
- estimator: enrichment-вызов → enrich_address (алиас dadata_clean_address сохранён,
  это точка патча в 11 тестах — без churn).
- Тесты: feature-disabled лог, enrich clean-path/suggest-fallback/both-fail, новые поля.
  Проверено вживую на прод-кредах: clean→403, enrich→house_fias_id восстановлен.
2026-07-12 19:55:37 +03:00

887 lines
37 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_region_body_shape_no_restrict_value() -> None:
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
`restrict_value` is a top-level DaData body param (not a per-location dict
key) — putting it inside the locations object would be a silent no-op.
The region constraint in `locations` is already a hard filter on its own.
"""
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=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(token="my-token"), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
assert "restrict_value" not in body["locations"][0]
assert "restrict_value" not in body
async def test_suggest_addresses_region_overrides_city() -> None:
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
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=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
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
# ─────────────────────────────────────────────────────────────────────────────
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
# ─────────────────────────────────────────────────────────────────────────────
import logging as _logging # noqa: E402
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
CLEAN_FEATURE_DISABLED_BODY = {
"timestamp": "2026-07-12T16:37:54.585+00:00",
"status": 403,
"error": "Forbidden",
"message": (
"Feature 'CLEAN' disabled for token 'xxx'. "
"See https://dadata.userecho.com/topics/7784 for help."
),
"path": "/api/v1/clean/address",
}
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
SUGGEST_HOUSE_PAYLOAD = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева, д 4",
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
"data": {
"geo_lat": "56.831463",
"geo_lon": "60.580943",
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"kladr_id": "6600000100006480017",
"qc_geo": "0",
"fias_level": "8",
"house": "4",
"street": "Малышева",
"city": "Екатеринбург",
},
}
]
}
def _mock_enrich_transport(
clean_status: int, clean_body: object, suggest_body: object
) -> httpx.MockTransport:
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if "cleaner.dadata.ru" in url:
return httpx.Response(clean_status, json=clean_body)
if "suggestions.dadata.ru" in url:
return httpx.Response(200, json=suggest_body)
return httpx.Response(404, json={"error": "unexpected url"})
return httpx.MockTransport(handler)
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
from app.services import dadata
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
text = caplog.text
assert "Стандартизация" in text or "выключена" in text
# Не должны обвинять токен при feature-disabled.
assert "auth/secret rejected" not in text
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
"""401 (или 403 без 'disabled') → сообщение про креды."""
from app.services import dadata
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
assert "auth/secret rejected" in caplog.text
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
from app.services import dadata
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
results = await dadata.suggest_addresses("Малышева 4")
assert len(results) == 1
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert results[0].kladr_id == "6600000100006480017"
assert results[0].qc_geo == 0
async def test_enrich_address_uses_clean_when_available() -> None:
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
from app.services import dadata
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
assert result is not None
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
assert result.house_cadnum == "66:41:0704045:350"
assert result.raw.get("_source") != "suggest_fallback"
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
assert result is not None
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert result.kladr_id == "6600000100006480017"
assert result.qc_geo == 0
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
assert result.canonical_address is None
assert result.house_cadnum is None
assert result.raw.get("_source") == "suggest_fallback"
async def test_enrich_address_ignores_non_house_suggestions() -> None:
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
from app.services import dadata
street_only = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева",
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
}
]
}
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
assert result is None
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
"""CLEAN недоступен + suggest пустой → None (graceful)."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Малышева 4")
assert result is None