From a7fa1a4ffa84ebd185e2db5f26d16a6fad1ea475 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 18:50:12 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20DaData=20on-demand=20enrichmen?= =?UTF-8?q?t=20=D0=B2=20estimate=20flow=20(PR=20Q1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет 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) — обе используют тот же сервис. --- tradein-mvp/.env.example | 9 + tradein-mvp/DEPLOY.md | 5 + tradein-mvp/backend/app/core/config.py | 8 + tradein-mvp/backend/app/schemas/trade_in.py | 10 + tradein-mvp/backend/app/services/dadata.py | 214 ++++++++++ tradein-mvp/backend/app/services/estimator.py | 33 ++ .../069_trade_in_estimates_dadata_fields.sql | 39 ++ .../backend/tests/services/test_dadata.py | 364 ++++++++++++++++++ 8 files changed, 682 insertions(+) create mode 100644 tradein-mvp/backend/app/services/dadata.py create mode 100644 tradein-mvp/backend/data/sql/069_trade_in_estimates_dadata_fields.sql create mode 100644 tradein-mvp/backend/tests/services/test_dadata.py diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example index 2c8c0c83..f7c34ad3 100644 --- a/tradein-mvp/.env.example +++ b/tradein-mvp/.env.example @@ -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 — менять не нужно. diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md index 4ab4bb1b..f17cd93c 100644 --- a/tradein-mvp/DEPLOY.md +++ b/tradein-mvp/DEPLOY.md @@ -85,6 +85,11 @@ ADMIN_TOKEN= COOKIE_ENCRYPTION_KEY=<64-char hex> GENDESIGN_FDW_PASSWORD= GLITCHTIP_DSN= +# DaData on-demand enrichment в /estimate flow (PR Q1). +# Demo tier: 100 req/день. Пусто = enrichment disabled (graceful). +# Регистрация ключей: https://dadata.ru/api/clean/ +DADATA_API_TOKEN= +DADATA_API_SECRET= ``` Оба файла создаются вручную при первом деплое. diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 85341d6b..86e3296b 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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() diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index cba51c4e..9ae6a8ce 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py new file mode 100644 index 00000000..9390e75e --- /dev/null +++ b/tradein-mvp/backend/app/services/dadata.py @@ -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 , X-Secret: , Content-Type: application/json +Body: ["
"] +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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 613b467e..7ba91368 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 []), ) diff --git a/tradein-mvp/backend/data/sql/069_trade_in_estimates_dadata_fields.sql b/tradein-mvp/backend/data/sql/069_trade_in_estimates_dadata_fields.sql new file mode 100644 index 00000000..f5199115 --- /dev/null +++ b/tradein-mvp/backend/data/sql/069_trade_in_estimates_dadata_fields.sql @@ -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; diff --git a/tradein-mvp/backend/tests/services/test_dadata.py b/tradein-mvp/backend/tests/services/test_dadata.py new file mode 100644 index 00000000..b8a67a3d --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_dadata.py @@ -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"not json") + + 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"