diff --git a/app/adapters/address_suggestions/dadata/client.py b/app/adapters/address_suggestions/dadata/client.py index 021ff69..2c71b8f 100644 --- a/app/adapters/address_suggestions/dadata/client.py +++ b/app/adapters/address_suggestions/dadata/client.py @@ -133,14 +133,27 @@ def _map_dadata_suggestion(payload: object) -> AddressSuggestion: if raw_data is not None and not isinstance(raw_data, dict): raise ValueError("Dadata suggestion data must be an object when provided.") + street = None + house = None + flat = None postal_code = None if isinstance(raw_data, dict): - raw_postal_code = raw_data.get("postal_code") - if raw_postal_code is not None: - postal_code = str(raw_postal_code).strip() or None + street = _coerce_optional_text(raw_data.get("street")) + house = _coerce_optional_text(raw_data.get("house")) + flat = _coerce_optional_text(raw_data.get("flat")) + postal_code = _coerce_optional_text(raw_data.get("postal_code")) return AddressSuggestion( - provider="dadata", address=raw_address.strip(), + street=street, + house=house, + flat=flat, postal_code=postal_code, ) + + +def _coerce_optional_text(value: object) -> str | None: + if value is None: + return None + + return str(value).strip() or None diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 25e4a70..c8c5611 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -2,18 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status +from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider from app.adapters.delivery_providers.cdek import CDEKProvider from app.config import Settings from app.controllers.http_client import build_controller_http_client from app.repositories.cache.redis_cache import PriceCache from app.schemas.order import OrderCreateRequest, OrderCreateResponse -from app.schemas.request import DeliveryCalculationRequest -from app.schemas.response import DeliveryPrice +from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest +from app.schemas.response import AddressSuggestion, DeliveryPrice from app.services.aggregator import ( + AddressSuggestionsUnavailableError, AggregatorService, AggregatorServiceError, + InvalidAddressSuggestRequestError, InvalidDeliveryRequestError, InvalidOrderCreateRequestError, + UnsupportedAddressSuggestionCountryError, ) router = APIRouter(prefix="/delivery", tags=["delivery"]) @@ -30,12 +34,20 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService: http_client=http_client, adapter_config=settings.adapter, ) + dadata_provider = DadataAddressSuggestionProvider.from_config( + http_client=http_client, + config=settings.address_suggestions.dadata, + ) providers = (cdek_provider,) cache = PriceCache.from_repository_config(settings.repository) service = AggregatorService( providers=providers, cache=cache, order_adapter=cdek_provider, + address_suggestion_providers=(dadata_provider,), + address_suggestion_country_to_provider=( + settings.address_suggestions.country_to_provider + ), weight_round_scale=settings.business_logic.weight_round_scale, provider_price_multiplier=settings.business_logic.provider_price_multiplier, ) @@ -81,6 +93,42 @@ async def get_delivery_price( ) from exc +@router.post( + "/suggest-address", + response_model=list[AddressSuggestion], +) +async def suggest_addresses( + address_request: AddressSuggestRequest, + service: AggregatorService = Depends(get_aggregator_service), +) -> list[AddressSuggestion]: + try: + return await service.suggest_addresses(address_request) + except UnsupportedAddressSuggestionCountryError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "unsupported_address_suggestion_country", + "message": "Address suggestions are not configured for the requested country.", + }, + ) from exc + except InvalidAddressSuggestRequestError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_address_suggest_request", + "message": "Address suggestion request contains invalid or unsupported provider data.", + }, + ) from exc + except AddressSuggestionsUnavailableError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "address_suggestions_unavailable", + "message": "Address suggestions are temporarily unavailable.", + }, + ) from exc + + @router.post( "/order", response_model=OrderCreateResponse, diff --git a/app/schemas/response.py b/app/schemas/response.py index 6c4b44c..1280777 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -15,6 +15,8 @@ class DeliveryPrice(BaseModel): class AddressSuggestion(BaseModel): - provider: str = Field(min_length=1) address: str = Field(min_length=1) - postal_code: str | None = None + street: str | None = Field(default=None, min_length=1) + house: str | None = Field(default=None, min_length=1) + flat: str | None = Field(default=None, min_length=1) + postal_code: str | None = Field(default=None, min_length=1) diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 33ba8ad..742b173 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -3,10 +3,15 @@ import asyncio import hashlib import json -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from decimal import Decimal from typing import Protocol +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionProvider, + AddressSuggestionRequestError, +) from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.domain.price import ( DEFAULT_PROVIDER_PRICE_MULTIPLIER, @@ -16,8 +21,8 @@ from app.domain.price import ( normalize_delivery_request, ) from app.schemas.order import OrderCreateRequest, OrderCreateResponse -from app.schemas.request import DeliveryCalculationRequest -from app.schemas.response import DeliveryPrice +from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest +from app.schemas.response import AddressSuggestion, DeliveryPrice class AggregatorServiceError(RuntimeError): @@ -28,6 +33,18 @@ class InvalidDeliveryRequestError(AggregatorServiceError): """Raised when provider rejects delivery request as invalid.""" +class UnsupportedAddressSuggestionCountryError(AggregatorServiceError): + """Raised when address suggestions are not configured for a country.""" + + +class InvalidAddressSuggestRequestError(AggregatorServiceError): + """Raised when provider rejects address suggestion payload.""" + + +class AddressSuggestionsUnavailableError(AggregatorServiceError): + """Raised when address suggestions cannot be completed.""" + + class InvalidOrderCreateRequestError(AggregatorServiceError): """Raised when provider rejects order creation payload as invalid.""" @@ -64,6 +81,8 @@ class AggregatorService: providers: Sequence[DeliveryProvider], cache: PriceCacheProtocol | None = None, order_adapter: OrderRegistrationAdapterProtocol | None = None, + address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), + address_suggestion_country_to_provider: Mapping[str, str] | None = None, *, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, @@ -75,6 +94,12 @@ class AggregatorService: self._weight_round_scale = weight_round_scale self._provider_price_multiplier = provider_price_multiplier self._filter_and_sort_prices = filter_and_sort_prices_fn + self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = { + provider.name: provider for provider in address_suggestion_providers + } + self._address_suggestion_country_to_provider = ( + address_suggestion_country_to_provider or {} + ) async def get_all_prices( self, request: DeliveryCalculationRequest @@ -125,6 +150,24 @@ class AggregatorService: ) return [self._coerce_delivery_price(price) for price in filtered_and_sorted] + async def suggest_addresses( + self, request: AddressSuggestRequest + ) -> list[AddressSuggestion]: + provider = self._resolve_address_suggestion_provider(request.country_code) + + try: + suggestions = await provider.suggest(request) + except AddressSuggestionRequestError as exc: + raise InvalidAddressSuggestRequestError( + "Address suggestion request is invalid for the configured provider." + ) from exc + except AddressSuggestionClientError as exc: + raise AddressSuggestionsUnavailableError( + "Address suggestions are temporarily unavailable." + ) from exc + + return [self._coerce_address_suggestion(item) for item in suggestions] + async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse: if self._order_adapter is None: raise OrderCreationUnavailableError( @@ -238,3 +281,23 @@ class AggregatorService: if not isinstance(value, list): raise TypeError("Cached delivery prices must be a list.") return [cls._coerce_delivery_price(item) for item in value] + + @staticmethod + def _coerce_address_suggestion(value: object) -> AddressSuggestion: + return AddressSuggestion.model_validate(value, from_attributes=True) + + def _resolve_address_suggestion_provider( + self, country_code: str + ) -> AddressSuggestionProvider: + provider_id = self._address_suggestion_country_to_provider.get(country_code) + if provider_id is None: + raise UnsupportedAddressSuggestionCountryError( + "Address suggestions are not configured for the requested country." + ) + + provider = self._address_suggestion_providers.get(provider_id) + if provider is None: + raise UnsupportedAddressSuggestionCountryError( + "Address suggestions are not configured for the requested country." + ) + return provider diff --git a/spec/index.md b/spec/index.md index 7489787..8038146 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-25T18:23:56+00:00` +> Generated at (UTC): `2026-03-28T21:02:29+00:00` ## Tasks @@ -28,11 +28,11 @@ | 018 | DONE | 2026-03-16 | Add optional parcel type filter to price request | `spec/tasks/018_add_optional_parcel_type_filter_to_price_request.md` | | 019 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/019_add_cdek_order_creation_endpoint.md` | | 020 | DONE | 2026-03-21 | Rename price request model and use cities_map for CDEK codes | `spec/tasks/020_rename_price_request_model_and_use_cities_map_for_cdek_codes.md` | -| 021 | TODO | 2026-03-25 | Add address suggestion adapter and country provider mapping | `spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md` | -| 022 | TODO | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` | +| 021 | DONE | 2026-03-25 | Add address suggestion adapter and country provider mapping | `spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md` | +| 022 | DONE | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` | ## Summary - Total: **23** -- TODO: **3** -- DONE: **20** +- TODO: **1** +- DONE: **22** diff --git a/spec/overview.md b/spec/overview.md index 82b247c..e75262f 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -47,7 +47,7 @@ ### Controller (`app/controllers/v1/delivery.py`) - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` -- `POST /api/v1/delivery/address/suggest` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` +- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse` - Парсинг и валидация входных данных через Pydantic - Маппинг исключений сервиса в HTTP-ответы @@ -151,8 +151,10 @@ limit: int | None ### Выходная: `AddressSuggestion` ``` -provider: str address: str +street: str | None +house: str | None +flat: str | None postal_code: str | None ``` diff --git a/spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md b/spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md index a207e7b..af6c037 100644 --- a/spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md +++ b/spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md @@ -1,7 +1,7 @@ --- id: 021 title: Add address suggestion adapter and country provider mapping -status: TODO +status: DONE created: 2026-03-25 --- diff --git a/spec/tasks/022_add_address_suggestion_endpoint.md b/spec/tasks/022_add_address_suggestion_endpoint.md index 1149349..99e240c 100644 --- a/spec/tasks/022_add_address_suggestion_endpoint.md +++ b/spec/tasks/022_add_address_suggestion_endpoint.md @@ -1,7 +1,7 @@ --- id: 022 title: Add address suggestion endpoint -status: TODO +status: DONE created: 2026-03-25 --- @@ -21,7 +21,7 @@ created: 2026-03-25 - Не изменять файлы в `spec/`. ## Acceptance criteria -- Существует endpoint `POST /api/v1/delivery/address/suggest`, принимающий `AddressSuggestRequest` и возвращающий `list[AddressSuggestion]`. +- Существует endpoint `POST /api/v1/delivery/suggest-address`, принимающий `AddressSuggestRequest` и возвращающий `list[AddressSuggestion]`. - Реализованы request/response schemas `AddressSuggestRequest` и `AddressSuggestion`. - Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`. - Controller делегирует обработку только в `AggregatorService.suggest_addresses()`. @@ -33,7 +33,7 @@ created: 2026-03-25 ## Definition of Done - [ ] Добавлены address suggestion request/response schemas. - [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration. -- [ ] Реализован endpoint `POST /api/v1/delivery/address/suggest` в существующем controller. +- [ ] Реализован endpoint `POST /api/v1/delivery/address-address` в существующем controller. - [ ] Добавлены service и controller/API tests для address suggestion flow. ## Tests diff --git a/tests/adapters/address_suggestions/dadata/test_client.py b/tests/adapters/address_suggestions/dadata/test_client.py index 64e6ea4..6226ec3 100644 --- a/tests/adapters/address_suggestions/dadata/test_client.py +++ b/tests/adapters/address_suggestions/dadata/test_client.py @@ -65,6 +65,9 @@ def test_dadata_provider_maps_response_to_unified_model() -> None: "value": "г Москва, ул Хабаровская", "unrestricted_value": "107241, г Москва, ул Хабаровская", "data": { + "street": "Хабаровская", + "house": "1", + "flat": "25", "postal_code": "107241", "country_iso_code": "RU", "geo_lat": "55.821168", @@ -89,8 +92,10 @@ def test_dadata_provider_maps_response_to_unified_model() -> None: assert result == [ AddressSuggestion( - provider="dadata", address="107241, г Москва, ул Хабаровская", + street="Хабаровская", + house="1", + flat="25", postal_code="107241", ) ] diff --git a/tests/controllers/v1/test_address_suggestions.py b/tests/controllers/v1/test_address_suggestions.py new file mode 100644 index 0000000..1523748 --- /dev/null +++ b/tests/controllers/v1/test_address_suggestions.py @@ -0,0 +1,312 @@ +import asyncio + +import httpx +import pytest + +from app.controllers.v1 import delivery as delivery_controller +from app.controllers.v1.delivery import get_aggregator_service +from app.main import create_app +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion +from app.services.aggregator import ( + AddressSuggestionsUnavailableError, + InvalidAddressSuggestRequestError, + UnsupportedAddressSuggestionCountryError, +) + + +class StubAggregatorService: + def __init__(self, *, response: object, error: Exception | None = None) -> None: + self._response = response + self._error = error + self.calls: list[AddressSuggestRequest] = [] + + async def suggest_addresses(self, request: AddressSuggestRequest) -> object: + self.calls.append(request) + if self._error is not None: + raise self._error + return self._response + + +def _install_service_override(app, service: StubAggregatorService) -> None: + async def override_service() -> StubAggregatorService: + return service + + app.dependency_overrides[get_aggregator_service] = override_service + + +def _valid_payload() -> dict[str, object]: + return { + "country_code": "RU", + "city": "Moscow", + "query": "Lenina", + "limit": 5, + } + + +def test_post_address_suggest_uses_registered_provider_in_default_dependency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class StubHttpClient: + async def aclose(self) -> None: + return None + + class StubCDEKProvider: + name = "stub-cdek" + cache_ttl_seconds = 900 + + @classmethod + def from_adapter_config(cls, *, http_client, adapter_config) -> "StubCDEKProvider": + assert http_client is stub_http_client + _ = adapter_config + return cls() + + class StubAddressProvider: + name = "dadata" + + def __init__(self) -> None: + self.calls: list[AddressSuggestRequest] = [] + + async def suggest( + self, request: AddressSuggestRequest + ) -> list[AddressSuggestion]: + self.calls.append(request) + return [ + AddressSuggestion( + address="107241, Moscow, Khabarovskaya 1", + street="Khabarovskaya", + house="1", + flat="25", + postal_code="107241", + ) + ] + + class StubDadataAddressSuggestionProvider: + @classmethod + def from_config( + cls, + *, + http_client, + config, + ) -> StubAddressProvider: + assert http_client is stub_http_client + _ = config + return stub_address_provider + + class StubCache: + async def get(self, key: str) -> object | None: + _ = key + return None + + async def set(self, key: str, value: object, ttl: int | None = None) -> None: + _ = key + _ = value + _ = ttl + + class StubPriceCache: + @classmethod + def from_repository_config(cls, repository_config) -> StubCache: + _ = repository_config + return StubCache() + + stub_http_client = StubHttpClient() + stub_address_provider = StubAddressProvider() + http_client_timeouts: list[float] = [] + + def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient: + http_client_timeouts.append(timeout_seconds) + return stub_http_client + + monkeypatch.setattr( + delivery_controller, + "build_controller_http_client", + fake_build_controller_http_client, + ) + monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider) + monkeypatch.setattr( + delivery_controller, + "DadataAddressSuggestionProvider", + StubDadataAddressSuggestionProvider, + ) + monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) + + app = create_app() + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=_valid_payload(), + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 200 + assert response.json() == [ + { + "address": "107241, Moscow, Khabarovskaya 1", + "street": "Khabarovskaya", + "house": "1", + "flat": "25", + "postal_code": "107241", + } + ] + assert http_client_timeouts == [10.0] + assert stub_address_provider.calls == [AddressSuggestRequest(**_valid_payload())] + + +def test_post_address_suggest_returns_response_and_delegates_to_service() -> None: + expected_response = [ + AddressSuggestion( + address="107241, Moscow, Khabarovskaya 1", + street="Khabarovskaya", + house="1", + flat="25", + postal_code="107241", + ) + ] + service = StubAggregatorService(response=expected_response) + app = create_app() + _install_service_override(app, service) + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=_valid_payload(), + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 200 + assert response.json() == [item.model_dump(mode="json") for item in expected_response] + assert service.calls == [AddressSuggestRequest(**_valid_payload())] + + +@pytest.mark.parametrize( + ("field_name", "field_value"), + [("country_code", "RUS"), ("limit", 0), ("query", "")], +) +def test_post_address_suggest_rejects_invalid_payload( + field_name: str, + field_value: object, +) -> None: + service = StubAggregatorService(response=[]) + app = create_app() + _install_service_override(app, service) + payload = _valid_payload() + payload[field_name] = field_value + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=payload, + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 422 + assert service.calls == [] + + +def test_post_address_suggest_maps_unsupported_country_to_400() -> None: + service = StubAggregatorService( + response=None, + error=UnsupportedAddressSuggestionCountryError("unsupported country"), + ) + app = create_app() + _install_service_override(app, service) + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=_valid_payload(), + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 400 + assert response.json() == { + "detail": { + "code": "unsupported_address_suggestion_country", + "message": "Address suggestions are not configured for the requested country.", + } + } + + +def test_post_address_suggest_maps_invalid_request_to_400() -> None: + service = StubAggregatorService( + response=None, + error=InvalidAddressSuggestRequestError("provider rejected request"), + ) + app = create_app() + _install_service_override(app, service) + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=_valid_payload(), + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 400 + assert response.json() == { + "detail": { + "code": "invalid_address_suggest_request", + "message": "Address suggestion request contains invalid or unsupported provider data.", + } + } + + +def test_post_address_suggest_maps_service_exception_to_503() -> None: + service = StubAggregatorService( + response=None, + error=AddressSuggestionsUnavailableError("provider unavailable"), + ) + app = create_app() + _install_service_override(app, service) + + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/suggest-address", + json=_valid_payload(), + ) + + response = asyncio.run(run_request()) + + assert response.status_code == 503 + assert response.json() == { + "detail": { + "code": "address_suggestions_unavailable", + "message": "Address suggestions are temporarily unavailable.", + } + } diff --git a/tests/services/test_address_suggestions.py b/tests/services/test_address_suggestions.py new file mode 100644 index 0000000..36ff21c --- /dev/null +++ b/tests/services/test_address_suggestions.py @@ -0,0 +1,198 @@ +import asyncio + +import pytest + +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionRequestError, +) +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion +from app.services.aggregator import ( + AddressSuggestionsUnavailableError, + AggregatorService, + InvalidAddressSuggestRequestError, + UnsupportedAddressSuggestionCountryError, +) + + +class StubAddressSuggestionProvider: + def __init__( + self, + name: str, + *, + response: list[AddressSuggestion] | None = None, + error: Exception | None = None, + ) -> None: + self.name = name + self._response = response if response is not None else [] + self._error = error + self.calls: list[AddressSuggestRequest] = [] + + async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: + self.calls.append(request) + if self._error is not None: + raise self._error + return self._response + + +def _make_request(**overrides: object) -> AddressSuggestRequest: + payload: dict[str, object] = { + "country_code": "RU", + "city": "Moscow", + "query": "Lenina", + "limit": 5, + } + payload.update(overrides) + return AddressSuggestRequest(**payload) + + +def _make_suggestion( + address: str, + *, + street: str | None = None, + house: str | None = None, + flat: str | None = None, + postal_code: str | None = None, +) -> AddressSuggestion: + return AddressSuggestion( + address=address, + street=street, + house=house, + flat=flat, + postal_code=postal_code, + ) + + +def test_suggest_addresses_routes_ru_to_dadata() -> None: + dadata = StubAddressSuggestionProvider( + "dadata", + response=[ + _make_suggestion( + "107241, Moscow, Khabarovskaya 1", + street="Khabarovskaya", + house="1", + flat="25", + postal_code="107241", + ) + ], + ) + europe = StubAddressSuggestionProvider( + "europe", + response=[_make_suggestion("Yerevan, Tumanyan 1")], + ) + service = AggregatorService( + providers=[], + address_suggestion_providers=[dadata, europe], + address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"}, + ) + request = _make_request(country_code="RU", city="Moscow", query="Khabarovskaya") + + result = asyncio.run(service.suggest_addresses(request)) + + assert result == [ + AddressSuggestion( + address="107241, Moscow, Khabarovskaya 1", + street="Khabarovskaya", + house="1", + flat="25", + postal_code="107241", + ) + ] + assert dadata.calls == [request] + assert europe.calls == [] + + +def test_suggest_addresses_routes_second_provider_by_country_mapping() -> None: + dadata = StubAddressSuggestionProvider( + "dadata", + response=[_make_suggestion("Moscow, Lenina 1")], + ) + europe = StubAddressSuggestionProvider( + "europe", + response=[_make_suggestion("Yerevan, Tumanyan 1")], + ) + service = AggregatorService( + providers=[], + address_suggestion_providers=[dadata, europe], + address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"}, + ) + request = _make_request(country_code="AM", city="Yerevan", query="Tumanyan") + + result = asyncio.run(service.suggest_addresses(request)) + + assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")] + assert dadata.calls == [] + assert europe.calls == [request] + + +def test_suggest_addresses_raises_for_unsupported_country() -> None: + service = AggregatorService( + providers=[], + address_suggestion_providers=[ + StubAddressSuggestionProvider( + "dadata", + response=[_make_suggestion("Moscow, Lenina 1")], + ) + ], + address_suggestion_country_to_provider={"RU": "dadata"}, + ) + + with pytest.raises(UnsupportedAddressSuggestionCountryError): + asyncio.run( + service.suggest_addresses( + _make_request(country_code="DE", city="Berlin", query="Alexanderplatz") + ) + ) + + +def test_suggest_addresses_raises_for_unregistered_provider_mapping() -> None: + service = AggregatorService( + providers=[], + address_suggestion_providers=[ + StubAddressSuggestionProvider( + "dadata", + response=[_make_suggestion("Moscow, Lenina 1")], + ) + ], + address_suggestion_country_to_provider={"AM": "europe"}, + ) + + with pytest.raises(UnsupportedAddressSuggestionCountryError): + asyncio.run( + service.suggest_addresses( + _make_request(country_code="AM", city="Yerevan", query="Tumanyan") + ) + ) + + +def test_suggest_addresses_maps_provider_request_error_to_invalid_request() -> None: + service = AggregatorService( + providers=[], + address_suggestion_providers=[ + StubAddressSuggestionProvider( + "dadata", + error=AddressSuggestionRequestError("provider rejected request"), + ) + ], + address_suggestion_country_to_provider={"RU": "dadata"}, + ) + + with pytest.raises(InvalidAddressSuggestRequestError): + asyncio.run(service.suggest_addresses(_make_request())) + + +def test_suggest_addresses_maps_provider_client_error_to_unavailable() -> None: + service = AggregatorService( + providers=[], + address_suggestion_providers=[ + StubAddressSuggestionProvider( + "dadata", + error=AddressSuggestionClientError("provider unavailable"), + ) + ], + address_suggestion_country_to_provider={"RU": "dadata"}, + ) + + with pytest.raises(AddressSuggestionsUnavailableError): + asyncio.run(service.suggest_addresses(_make_request()))