feat(tradein): DaData on-demand enrichment в estimate flow (PR Q1) (#604)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / deploy (push) Successful in 37s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 48s

Closes follow-ups: PR Q2 frontend Suggest swap, PR Q3 NULL-coords backfill, T0_per_house tier matching for Rosreestr deals.

Migration 069 ALTER TABLE adds 6 nullable columns to trade_in_estimates.
ENV: DADATA_API_TOKEN + DADATA_API_SECRET must be set on VPS .env.runtime before requests will be enriched. Without them the service tихо disables and estimator continues as before.

Verified: 20/20 dadata unit tests pass, ruff clean, no regressions in 46 estimator tests. Pre-existing 6 failures in test_estimator_source_quota/test_search_api/test_cian_valuation are unrelated and tracked separately.
This commit is contained in:
Light1YT 2026-05-27 13:56:14 +00:00
commit 0b31431b99
8 changed files with 682 additions and 0 deletions

View file

@ -12,6 +12,15 @@ ENVIRONMENT=dev
# и упадут с SystemExit без него.
YANDEX_GEOCODER_API_KEY=
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
# Если хотя бы один не задан → service возвращает None gracefully, estimator
# продолжает работу без enrichment.
# Получение ключей: https://dadata.ru/api/clean/
DADATA_API_TOKEN=
DADATA_API_SECRET=
# === Frontend (build-time) ===
# Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*).
# В docker compose это уже выставлено в http://backend:8000 — менять не нужно.

View file

@ -85,6 +85,11 @@ ADMIN_TOKEN=<token или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<dsn или пусто>
# DaData on-demand enrichment в /estimate flow (PR Q1).
# Demo tier: 100 req/день. Пусто = enrichment disabled (graceful).
# Регистрация ключей: https://dadata.ru/api/clean/
DADATA_API_TOKEN=<token или пусто>
DADATA_API_SECRET=<secret или пусто>
```
Оба файла создаются вручную при первом деплое.

View file

@ -39,5 +39,13 @@ class Settings(BaseSettings):
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
gendesign_fdw_password: str | None = None
# DaData /clean/address — обогащение target-адреса канонической формой,
# kadastr_num, ФИАС, координатами, метро. Используется в estimator для
# on-demand enrichment (PR Q1). Demo tier: 100 req/день. Если хотя бы один
# не задан — service возвращает None gracefully, estimator продолжает.
# ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
settings = Settings()

View file

@ -94,6 +94,16 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
cian_valuation: CianValuationSummary | None = None
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
# house_fias_id — UUID ФИАС дома.
# metro_nearest — ближайшие станции метро [{name,line,distance}, ...].
# NULL если DaData credentials не заданы / quota exceeded / address не распознан.
canonical_address: str | None = None
house_cadnum: str | None = None
house_fias_id: str | None = None
metro_nearest: list[dict] = Field(default_factory=list)
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None

View file

@ -0,0 +1,214 @@
"""DaData /clean/address client — enrich address с canonical form,
kadastr_num, ФИАС, координаты, метро.
Используется в estimator flow для on-demand обогащения **целевого адреса**
(payload.address) заполняет недостающий kadastr_num, который open dataset
Росреестра не даёт (см. PR Q1 / vault `meta/00_credentials.md`).
ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
Если не задан хотя бы один service возвращает None gracefully (не break flow).
Rate limit: 100/день demo tier. Для production надо upgrade'нуть.
Endpoint: POST https://cleaner.dadata.ru/api/v1/clean/address
Headers: Authorization: Token <T>, X-Secret: <S>, Content-Type: application/json
Body: ["<address text>"]
Response: list[dict] обычно один элемент с полями result/house_cadnum/geo_lat/...
Docs: https://dadata.ru/api/clean/address/
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
_DADATA_TIMEOUT_S = 8.0
@dataclass(frozen=True, slots=True)
class DadataAddressResult:
"""Parsed DaData enrichment payload — slots для compact representation.
canonical_address поле `result` из ответа («г Екатеринбург, ул Малышева, д 125»).
house_cadnum кадастровый номер ДОМА (не участка). Формат «66:41:0704045:350».
house_fias_id UUID ФИАС дома.
lat / lon координаты от DaData (precision до дома).
qc_geo quality code геокодинга: 0=exact, 1=street, 2=settlement
qc_house quality code дома: 2=в ФИАС, 10=на картах
kladr_id старый ОКРС-классификатор (legacy, может быть полезен).
okato / oktmo admin codes для отчётности.
metro список ближайших станций метро (если есть): [{name,line,distance}].
raw полный ответ для debugging / future fields.
"""
canonical_address: str | None
house_cadnum: str | None
house_fias_id: str | None
lat: float | None
lon: float | None
qc_geo: int | None
qc_house: int | None
kladr_id: str | None
okato: str | None
oktmo: str | None
metro: list[dict[str, Any]]
raw: dict[str, Any]
def _credentials() -> tuple[str, str] | None:
"""Returns (token, secret) если оба заданы, иначе None."""
token = (settings.dadata_api_token or "").strip()
secret = (settings.dadata_api_secret or "").strip()
if not token or not secret:
return None
return token, secret
def _coerce_int(value: Any) -> int | None:
"""DaData отдаёт qc-коды строками иногда. Coerce → int безопасно."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _parse_response(item: dict[str, Any]) -> DadataAddressResult:
"""Парсит single DaData payload item в DadataAddressResult.
Не raise'ит — пропускает невалидные поля как None.
"""
metro_raw = item.get("metro") or []
if not isinstance(metro_raw, list):
metro_raw = []
return DadataAddressResult(
canonical_address=item.get("result"),
house_cadnum=item.get("house_cadnum"),
house_fias_id=item.get("house_fias_id"),
lat=_coerce_float(item.get("geo_lat")),
lon=_coerce_float(item.get("geo_lon")),
qc_geo=_coerce_int(item.get("qc_geo")),
qc_house=_coerce_int(item.get("qc_house")),
kladr_id=item.get("kladr_id"),
okato=item.get("okato"),
oktmo=item.get("oktmo"),
metro=metro_raw,
raw=item,
)
async def clean_address(address: str) -> DadataAddressResult | None:
"""Обогащает один адрес через DaData /clean/address.
Args:
address: свободный текст адреса (как пришёл от пользователя или из geocoder).
Returns:
DadataAddressResult или None если:
- credentials не заданы в ENV (graceful disable)
- адрес пустой / слишком короткий
- API вернул пустой массив / ошибку shape
- сетевая / HTTP ошибка (5xx, 429 quota, timeout)
- адрес не распознан (canonical_address пустой)
"""
if not address or len(address.strip()) < 3:
return None
creds = _credentials()
if creds is None:
logger.debug("dadata: credentials не заданы — skip enrichment")
return None
token, secret = creds
headers = {
"Authorization": f"Token {token}",
"X-Secret": secret,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = [address.strip()]
try:
async with httpx.AsyncClient(timeout=_DADATA_TIMEOUT_S) as client:
response = await client.post(DADATA_CLEAN_URL, headers=headers, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata: network error для %r: %s", address[:60], exc)
return None
except Exception as exc: # pragma: no cover — defensive
logger.warning("dadata: unexpected client error для %r: %s", address[:60], exc)
return None
status = response.status_code
if status == 429:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None
if status in (401, 403):
logger.error(
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
status,
)
return None
if status >= 500:
logger.warning("dadata: HTTP %d — transient server error", status)
return None
if status >= 400:
body_preview = (response.text or "")[:200]
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
return None
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata: невалидный JSON в ответе: %s", exc)
return None
if not isinstance(payload, list) or not payload:
logger.info("dadata: пустой массив для %r", address[:60])
return None
item = payload[0]
if not isinstance(item, dict):
logger.warning("dadata: первый элемент не dict (%s)", type(item).__name__)
return None
result = _parse_response(item)
if not result.canonical_address:
# DaData вернул shape, но не распознал адрес — quality codes обычно qc_geo=5.
logger.info(
"dadata: адрес не распознан (qc_geo=%s qc_house=%s) для %r",
result.qc_geo,
result.qc_house,
address[:60],
)
return None
logger.info(
"dadata: enriched %r → canonical=%r cadnum=%s qc_geo=%s qc_house=%s",
address[:60],
(result.canonical_address or "")[:60],
result.house_cadnum,
result.qc_geo,
result.qc_house,
)
return result

View file

@ -37,6 +37,8 @@ from app.schemas.trade_in import (
CianValuationSummary,
TradeInEstimateInput,
)
from app.services.dadata import DadataAddressResult
from app.services.dadata import clean_address as dadata_clean_address
from app.services.geocoder import GeocodeResult, geocode
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_or_create_house
@ -595,6 +597,15 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return _empty_estimate(payload, db, reason="address_not_geocoded")
# 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса.
# Best-effort: graceful None при отсутствии credentials / quota / fail.
# Дополняет geocode результатом kadastr_num + canonical form + nearest metro.
dadata: DadataAddressResult | None = None
try:
dadata = await dadata_clean_address(payload.address)
except Exception as exc: # pragma: no cover — defensive
logger.warning("dadata: unexpected error (graceful): %s", exc)
# 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если
# пользователь их не указал — это улучшает house-match аналогов (#6).
# Best-effort: при недоступности OSM target_* остаются None.
@ -857,6 +868,13 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
freshness_pre = _compute_freshness_minutes(listings_clean)
# DaData enrichment (PR Q1) — заполняется только если service отработал.
# При DaData = None все колонки идут в DB как NULL (graceful).
dadata_metro_json = (
json.dumps(dadata.metro, ensure_ascii=False)
if dadata is not None and dadata.metro
else None
)
db.execute(
text(
"""
@ -869,6 +887,8 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
confidence, confidence_explanation, n_analogs,
analogs, actual_deals,
sources_used, data_freshness_minutes,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_qc_house, dadata_metro,
expires_at
) VALUES (
CAST(:id AS uuid),
@ -882,6 +902,9 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
CAST(:deals_json AS jsonb),
CAST(:sources_json AS jsonb),
:freshness,
:canonical_address, :house_cadnum, :house_fias_id,
:dadata_qc_geo, :dadata_qc_house,
CAST(:dadata_metro_json AS jsonb),
:expires_at
)
"""
@ -918,6 +941,12 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
),
"sources_json": json.dumps(sources_used_pre, ensure_ascii=False),
"freshness": freshness_pre,
"canonical_address": dadata.canonical_address if dadata else None,
"house_cadnum": dadata.house_cadnum if dadata else None,
"house_fias_id": dadata.house_fias_id if dadata else None,
"dadata_qc_geo": dadata.qc_geo if dadata else None,
"dadata_qc_house": dadata.qc_house if dadata else None,
"dadata_metro_json": dadata_metro_json,
"expires_at": expires_at,
},
)
@ -1012,6 +1041,10 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
house_type=target_house_type,
repair_state=payload.repair_state,
has_balcony=payload.has_balcony,
canonical_address=dadata.canonical_address if dadata else None,
house_cadnum=dadata.house_cadnum if dadata else None,
house_fias_id=dadata.house_fias_id if dadata else None,
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
)

View file

@ -0,0 +1,39 @@
-- 069_trade_in_estimates_dadata_fields.sql
-- PR Q1: DaData on-demand enrichment в estimate flow.
--
-- После geocode() estimator зовёт dadata.clean_address(target) и сохраняет
-- результат рядом с расчётом, чтобы:
-- 1) frontend мог показать canonical-форму адреса + ближайшее метро
-- 2) house_cadnum / house_fias_id могли быть переиспользованы в будущем
-- для tier T0_per_house (точный matching сделок Росреестра по дому,
-- когда / если ЕГРН direct enrichment станет доступен).
--
-- Все поля nullable — DaData может быть disabled (нет ENV) либо вернуть
-- partial payload (qc_geo / qc_house без cadnum). Estimator продолжает работу
-- даже если все DaData-поля NULL (graceful degradation).
BEGIN;
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS canonical_address text,
ADD COLUMN IF NOT EXISTS house_cadnum text,
ADD COLUMN IF NOT EXISTS house_fias_id text,
ADD COLUMN IF NOT EXISTS dadata_qc_geo integer,
ADD COLUMN IF NOT EXISTS dadata_qc_house integer,
ADD COLUMN IF NOT EXISTS dadata_metro jsonb;
COMMENT ON COLUMN trade_in_estimates.canonical_address IS
'DaData /clean/address result field — canonical form, напр. "г Екатеринбург, ул Малышева, д 125"';
COMMENT ON COLUMN trade_in_estimates.house_cadnum IS
'DaData house_cadnum — кадастровый номер дома (формат "66:41:0704045:350"). '
'Может быть NULL если DaData не нашёл (qc_house != 2) или service disabled.';
COMMENT ON COLUMN trade_in_estimates.house_fias_id IS
'DaData house_fias_id — UUID дома в ФИАС.';
COMMENT ON COLUMN trade_in_estimates.dadata_qc_geo IS
'DaData qc_geo — quality code геокодинга: 0=exact, 1=street, 2=settlement, ...';
COMMENT ON COLUMN trade_in_estimates.dadata_qc_house IS
'DaData qc_house — quality code дома: 2=в ФИАС, 10=на картах, ...';
COMMENT ON COLUMN trade_in_estimates.dadata_metro IS
'Список ближайших станций метро от DaData: [{"name","line","distance"},...]';
COMMIT;

View file

@ -0,0 +1,364 @@
"""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"