Добавляет app/services/dadata.py — async client для DaData /clean/address, обогащающий target адрес canonical-формой, kadastr_num, ФИАС, координатами и ближайшим метро. Migration 069 расширяет trade_in_estimates 6 nullable колонками; estimator.estimate_quality вызывает service после geocode и сохраняет результат в основной INSERT. AggregatedEstimate отдаёт canonical_address / house_cadnum / house_fias_id / metro_nearest наружу. Service graceful: пустые credentials / quota 429 / 5xx / network fail → None, estimator продолжает работу. Demo tier 100 req/день — хватит для low-traffic prod и тестов. ENV: DADATA_API_TOKEN, DADATA_API_SECRET. Tests: 20 unit-тестов через httpx.MockTransport, zero real DaData calls. Покрыто: happy path, отсутствие creds, короткий адрес, timeout/network, HTTP 429/401/403/500/502/503, пустой массив, malformed shape, partial payload, coerce строковых qc-кодов в int, проверка request body+headers. Закладывает базу для PR Q2 (frontend DaData Suggest, 10k/день free) и PR Q3 (backfill 339 NULL-coords houses) — обе используют тот же сервис.
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""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"
|