gendesign/tradein-mvp/backend/tests/services/test_dadata.py
Light1YT a7fa1a4ffa feat(tradein): DaData on-demand enrichment в estimate flow (PR Q1)
Добавляет 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) — обе используют тот же сервис.
2026-05-27 18:50:12 +05:00

364 lines
14 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"