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) — обе используют тот же сервис.
This commit is contained in:
parent
b0c03942be
commit
a7fa1a4ffa
8 changed files with 682 additions and 0 deletions
|
|
@ -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 — менять не нужно.
|
||||
|
|
|
|||
|
|
@ -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 или пусто>
|
||||
```
|
||||
|
||||
Оба файла создаются вручную при первом деплое.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
214
tradein-mvp/backend/app/services/dadata.py
Normal file
214
tradein-mvp/backend/app/services/dadata.py
Normal 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
|
||||
|
|
@ -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 []),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
364
tradein-mvp/backend/tests/services/test_dadata.py
Normal file
364
tradein-mvp/backend/tests/services/test_dadata.py
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue