diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index b49df03..5cd0aca 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -22,7 +22,7 @@ from app.schemas.payment import ( InitPaymentResponse, TBankPaymentNotification, ) -from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest +from app.schemas.request import DeliveryCalculationRequest, SuggestAddressRequest from app.schemas.response import AddressSuggestion, DeliveryPrice from app.services.aggregator import ( AddressSuggestionsUnavailableError, @@ -149,12 +149,11 @@ async def get_delivery_price( response_model=list[AddressSuggestion], ) async def suggest_addresses( - address_request: AddressSuggestRequest, + address_request: SuggestAddressRequest, service: AggregatorService = Depends(get_aggregator_service), ) -> list[AddressSuggestion]: logger.info( "suggest_address_requested", - country_code=address_request.country_code, city=address_request.city, query=address_request.query, limit=address_request.limit, diff --git a/app/schemas/request.py b/app/schemas/request.py index fd90a3e..a600c7a 100644 --- a/app/schemas/request.py +++ b/app/schemas/request.py @@ -26,7 +26,21 @@ class DeliveryCalculationRequest(BaseModel): parcel_type: ParcelType | None = None +class SuggestAddressRequest(BaseModel): + """API request for the /suggest-address endpoint. + + ``city`` is a city identifier (key in ``cities_map``); the city name and + country code are resolved from it by the service layer. + """ + + city: str = Field(min_length=1) + query: str = Field(min_length=1) + limit: int | None = Field(default=None, gt=0) + + class AddressSuggestRequest(BaseModel): + """Provider-facing address suggestion request.""" + country_code: str = Field(min_length=2, max_length=2) city: str = Field(min_length=1) query: str = Field(min_length=1) diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 225f939..ded463d 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -49,7 +49,12 @@ from app.schemas.payment import ( InitPaymentResponse, TBankPaymentNotification, ) -from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest +from app.cities import cities_map +from app.schemas.request import ( + AddressSuggestRequest, + DeliveryCalculationRequest, + SuggestAddressRequest, +) from app.schemas.response import AddressSuggestion, DeliveryPrice logger = structlog.get_logger(__name__) @@ -254,12 +259,15 @@ class AggregatorService: return [self._coerce_delivery_price(price) for price in filtered_and_sorted] async def suggest_addresses( - self, request: AddressSuggestRequest + self, request: SuggestAddressRequest ) -> list[AddressSuggestion]: - provider = self._resolve_address_suggestion_provider(request.country_code) + provider_request = self._build_address_suggest_request(request) + provider = self._resolve_address_suggestion_provider( + provider_request.country_code + ) try: - suggestions = await provider.suggest(request) + suggestions = await provider.suggest(provider_request) except AddressSuggestionRequestError as exc: raise InvalidAddressSuggestRequestError( "Address suggestion request is invalid for the configured provider." @@ -739,6 +747,30 @@ class AggregatorService: def _coerce_address_suggestion(value: object) -> AddressSuggestion: return AddressSuggestion.model_validate(value, from_attributes=True) + @staticmethod + def _build_address_suggest_request( + request: SuggestAddressRequest, + ) -> AddressSuggestRequest: + city_entry = cities_map.get(request.city) + if not isinstance(city_entry, dict): + raise InvalidAddressSuggestRequestError( + f"City is not configured for id {request.city}." + ) + + city_name = city_entry.get("city") + country_code = city_entry.get("country") + if not isinstance(city_name, str) or not isinstance(country_code, str): + raise InvalidAddressSuggestRequestError( + f"City mapping is invalid for id {request.city}." + ) + + return AddressSuggestRequest( + country_code=country_code, + city=city_name, + query=request.query, + limit=request.limit, + ) + def _resolve_address_suggestion_provider( self, country_code: str ) -> AddressSuggestionProvider: diff --git a/tests/controllers/v1/test_address_suggestions.py b/tests/controllers/v1/test_address_suggestions.py index 4d2c617..9073adb 100644 --- a/tests/controllers/v1/test_address_suggestions.py +++ b/tests/controllers/v1/test_address_suggestions.py @@ -6,7 +6,7 @@ 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.request import AddressSuggestRequest, SuggestAddressRequest from app.schemas.response import AddressSuggestion from app.services.aggregator import ( AddressSuggestionsUnavailableError, @@ -19,9 +19,9 @@ class StubAggregatorService: def __init__(self, *, response: object, error: Exception | None = None) -> None: self._response = response self._error = error - self.calls: list[AddressSuggestRequest] = [] + self.calls: list[SuggestAddressRequest] = [] - async def suggest_addresses(self, request: AddressSuggestRequest) -> object: + async def suggest_addresses(self, request: SuggestAddressRequest) -> object: self.calls.append(request) if self._error is not None: raise self._error @@ -37,8 +37,7 @@ def _install_service_override(app, service: StubAggregatorService) -> None: def _valid_payload(**overrides: object) -> dict[str, object]: payload = { - "country_code": "RU", - "city": "Moscow", + "city": "4097", "query": "Lenina", "limit": 5, } @@ -192,8 +191,7 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( app = create_app() request_payload = _valid_payload( - country_code="DE", - city="Berlin", + city="1124", query="Alexanderplatz 1", ) @@ -223,7 +221,14 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( assert http_client_timeouts == [10.0] assert stub_dadata_provider.calls == [] assert stub_yandex_provider.calls == [] - assert stub_tomtom_provider.calls == [AddressSuggestRequest(**request_payload)] + assert stub_tomtom_provider.calls == [ + AddressSuggestRequest( + country_code="DE", + city="Берлин", + query="Alexanderplatz 1", + limit=5, + ) + ] def test_post_address_suggest_returns_response_and_delegates_to_service() -> None: @@ -255,12 +260,12 @@ def test_post_address_suggest_returns_response_and_delegates_to_service() -> Non assert response.status_code == 200 assert response.json() == [item.model_dump(mode="json") for item in expected_response] - assert service.calls == [AddressSuggestRequest(**_valid_payload())] + assert service.calls == [SuggestAddressRequest(**_valid_payload())] @pytest.mark.parametrize( ("field_name", "field_value"), - [("country_code", "RUS"), ("limit", 0), ("query", "")], + [("city", ""), ("limit", 0), ("query", "")], ) def test_post_address_suggest_rejects_invalid_payload( field_name: str, diff --git a/tests/services/test_address_suggestions.py b/tests/services/test_address_suggestions.py index 1864676..d42e6e1 100644 --- a/tests/services/test_address_suggestions.py +++ b/tests/services/test_address_suggestions.py @@ -6,7 +6,8 @@ from app.adapters.address_suggestions.base import ( AddressSuggestionClientError, AddressSuggestionRequestError, ) -from app.schemas.request import AddressSuggestRequest +from app.cities import cities_map +from app.schemas.request import AddressSuggestRequest, SuggestAddressRequest from app.schemas.response import AddressSuggestion from app.services.aggregator import ( AddressSuggestionsUnavailableError, @@ -17,6 +18,7 @@ from app.services.aggregator import ( _DADATA_COUNTRIES = ("RU", "BY", "KZ") _YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ") +# UA is intentionally absent: cities_map has no Ukrainian city to resolve. _TOMTOM_COUNTRIES = ( "AL", "AT", @@ -47,10 +49,16 @@ _TOMTOM_COUNTRIES = ( "SE", "SI", "SK", - "UA", ) +def _first_city(country_code: str) -> tuple[str, str]: + for city_id, entry in cities_map.items(): + if isinstance(entry, dict) and entry.get("country") == country_code: + return city_id, entry["city"] + raise AssertionError(f"cities_map has no city for country {country_code}") + + class StubAddressSuggestionProvider: def __init__( self, @@ -71,15 +79,21 @@ class StubAddressSuggestionProvider: 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_request(country_code: str, *, query: str = "Lenina") -> SuggestAddressRequest: + city_id, _ = _first_city(country_code) + return SuggestAddressRequest(city=city_id, query=query, limit=5) + + +def _expected_provider_request( + country_code: str, *, query: str = "Lenina" +) -> AddressSuggestRequest: + _, city_name = _first_city(country_code) + return AddressSuggestRequest( + country_code=country_code, + city=city_name, + query=query, + limit=5, + ) def _make_suggestion( @@ -139,11 +153,7 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata( address_suggestion_providers=[dadata, yandex_geosuggest, tomtom], address_suggestion_country_to_provider=_make_country_mapping(), ) - request = _make_request( - country_code=country_code, - city="Moscow", - query="Khabarovskaya", - ) + request = _make_request(country_code, query="Khabarovskaya") result = asyncio.run(service.suggest_addresses(request)) @@ -156,7 +166,9 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata( postal_code="107241", ) ] - assert dadata.calls == [request] + assert dadata.calls == [ + _expected_provider_request(country_code, query="Khabarovskaya") + ] assert yandex_geosuggest.calls == [] assert tomtom.calls == [] @@ -182,13 +194,15 @@ def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest( address_suggestion_providers=[dadata, yandex_geosuggest, tomtom], address_suggestion_country_to_provider=_make_country_mapping(), ) - request = _make_request(country_code=country_code, city="Yerevan", query="Tumanyan") + request = _make_request(country_code, query="Tumanyan") result = asyncio.run(service.suggest_addresses(request)) assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")] assert dadata.calls == [] - assert yandex_geosuggest.calls == [request] + assert yandex_geosuggest.calls == [ + _expected_provider_request(country_code, query="Tumanyan") + ] assert tomtom.calls == [] @@ -220,11 +234,7 @@ def test_suggest_addresses_routes_european_countries_to_tomtom( address_suggestion_providers=[dadata, yandex_geosuggest, tomtom], address_suggestion_country_to_provider=_make_country_mapping(), ) - request = _make_request( - country_code=country_code, - city="Berlin", - query="Alexanderplatz 1", - ) + request = _make_request(country_code, query="Alexanderplatz 1") result = asyncio.run(service.suggest_addresses(request)) @@ -239,7 +249,29 @@ def test_suggest_addresses_routes_european_countries_to_tomtom( ] assert dadata.calls == [] assert yandex_geosuggest.calls == [] - assert tomtom.calls == [request] + assert tomtom.calls == [ + _expected_provider_request(country_code, query="Alexanderplatz 1") + ] + + +def test_suggest_addresses_raises_for_unknown_city_id() -> 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(InvalidAddressSuggestRequestError): + asyncio.run( + service.suggest_addresses( + SuggestAddressRequest(city="nonexistent-city-id", query="Lenina") + ) + ) def test_suggest_addresses_raises_for_unsupported_country() -> None: @@ -256,9 +288,7 @@ def test_suggest_addresses_raises_for_unsupported_country() -> None: with pytest.raises(UnsupportedAddressSuggestionCountryError): asyncio.run( - service.suggest_addresses( - _make_request(country_code="DE", city="Berlin", query="Alexanderplatz") - ) + service.suggest_addresses(_make_request("DE", query="Alexanderplatz")) ) @@ -275,11 +305,7 @@ def test_suggest_addresses_raises_for_unregistered_provider_mapping() -> None: ) with pytest.raises(UnsupportedAddressSuggestionCountryError): - asyncio.run( - service.suggest_addresses( - _make_request(country_code="AM", city="Yerevan", query="Tumanyan") - ) - ) + asyncio.run(service.suggest_addresses(_make_request("AM", query="Tumanyan"))) def test_suggest_addresses_maps_provider_request_error_to_invalid_request() -> None: @@ -295,7 +321,7 @@ def test_suggest_addresses_maps_provider_request_error_to_invalid_request() -> N ) with pytest.raises(InvalidAddressSuggestRequestError): - asyncio.run(service.suggest_addresses(_make_request())) + asyncio.run(service.suggest_addresses(_make_request("RU"))) def test_suggest_addresses_maps_provider_client_error_to_unavailable() -> None: @@ -311,4 +337,4 @@ def test_suggest_addresses_maps_provider_client_error_to_unavailable() -> None: ) with pytest.raises(AddressSuggestionsUnavailableError): - asyncio.run(service.suggest_addresses(_make_request())) + asyncio.run(service.suggest_addresses(_make_request("RU")))