Добавлен POST /api/v1/delivery/suggest-address

This commit is contained in:
Раис Юсупалиев
2026-03-29 02:05:21 +03:00
parent 17772e5337
commit 4a3737999f
11 changed files with 666 additions and 23 deletions
@@ -133,14 +133,27 @@ def _map_dadata_suggestion(payload: object) -> AddressSuggestion:
if raw_data is not None and not isinstance(raw_data, dict): if raw_data is not None and not isinstance(raw_data, dict):
raise ValueError("Dadata suggestion data must be an object when provided.") raise ValueError("Dadata suggestion data must be an object when provided.")
street = None
house = None
flat = None
postal_code = None postal_code = None
if isinstance(raw_data, dict): if isinstance(raw_data, dict):
raw_postal_code = raw_data.get("postal_code") street = _coerce_optional_text(raw_data.get("street"))
if raw_postal_code is not None: house = _coerce_optional_text(raw_data.get("house"))
postal_code = str(raw_postal_code).strip() or None flat = _coerce_optional_text(raw_data.get("flat"))
postal_code = _coerce_optional_text(raw_data.get("postal_code"))
return AddressSuggestion( return AddressSuggestion(
provider="dadata",
address=raw_address.strip(), address=raw_address.strip(),
street=street,
house=house,
flat=flat,
postal_code=postal_code, postal_code=postal_code,
) )
def _coerce_optional_text(value: object) -> str | None:
if value is None:
return None
return str(value).strip() or None
+50 -2
View File
@@ -2,18 +2,22 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status 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.adapters.delivery_providers.cdek import CDEKProvider
from app.config import Settings from app.config import Settings
from app.controllers.http_client import build_controller_http_client from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache from app.repositories.cache.redis_cache import PriceCache
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
AddressSuggestionsUnavailableError,
AggregatorService, AggregatorService,
AggregatorServiceError, AggregatorServiceError,
InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError, InvalidDeliveryRequestError,
InvalidOrderCreateRequestError, InvalidOrderCreateRequestError,
UnsupportedAddressSuggestionCountryError,
) )
router = APIRouter(prefix="/delivery", tags=["delivery"]) router = APIRouter(prefix="/delivery", tags=["delivery"])
@@ -30,12 +34,20 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
adapter_config=settings.adapter, adapter_config=settings.adapter,
) )
dadata_provider = DadataAddressSuggestionProvider.from_config(
http_client=http_client,
config=settings.address_suggestions.dadata,
)
providers = (cdek_provider,) providers = (cdek_provider,)
cache = PriceCache.from_repository_config(settings.repository) cache = PriceCache.from_repository_config(settings.repository)
service = AggregatorService( service = AggregatorService(
providers=providers, providers=providers,
cache=cache, cache=cache,
order_adapter=cdek_provider, 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, weight_round_scale=settings.business_logic.weight_round_scale,
provider_price_multiplier=settings.business_logic.provider_price_multiplier, provider_price_multiplier=settings.business_logic.provider_price_multiplier,
) )
@@ -81,6 +93,42 @@ async def get_delivery_price(
) from exc ) 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( @router.post(
"/order", "/order",
response_model=OrderCreateResponse, response_model=OrderCreateResponse,
+4 -2
View File
@@ -15,6 +15,8 @@ class DeliveryPrice(BaseModel):
class AddressSuggestion(BaseModel): class AddressSuggestion(BaseModel):
provider: str = Field(min_length=1)
address: 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)
+66 -3
View File
@@ -3,10 +3,15 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Mapping, Sequence
from decimal import Decimal from decimal import Decimal
from typing import Protocol 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.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.domain.price import ( from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_PROVIDER_PRICE_MULTIPLIER,
@@ -16,8 +21,8 @@ from app.domain.price import (
normalize_delivery_request, normalize_delivery_request,
) )
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
class AggregatorServiceError(RuntimeError): class AggregatorServiceError(RuntimeError):
@@ -28,6 +33,18 @@ class InvalidDeliveryRequestError(AggregatorServiceError):
"""Raised when provider rejects delivery request as invalid.""" """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): class InvalidOrderCreateRequestError(AggregatorServiceError):
"""Raised when provider rejects order creation payload as invalid.""" """Raised when provider rejects order creation payload as invalid."""
@@ -64,6 +81,8 @@ class AggregatorService:
providers: Sequence[DeliveryProvider], providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None, cache: PriceCacheProtocol | None = None,
order_adapter: OrderRegistrationAdapterProtocol | 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, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
@@ -75,6 +94,12 @@ class AggregatorService:
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn 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( async def get_all_prices(
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
@@ -125,6 +150,24 @@ class AggregatorService:
) )
return [self._coerce_delivery_price(price) for price in filtered_and_sorted] 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: async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
if self._order_adapter is None: if self._order_adapter is None:
raise OrderCreationUnavailableError( raise OrderCreationUnavailableError(
@@ -238,3 +281,23 @@ class AggregatorService:
if not isinstance(value, list): if not isinstance(value, list):
raise TypeError("Cached delivery prices must be a list.") raise TypeError("Cached delivery prices must be a list.")
return [cls._coerce_delivery_price(item) for item in value] 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
+5 -5
View File
@@ -1,7 +1,7 @@
# Spec Tasks Index # Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually. > ⚠️ 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 ## 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` | | 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` | | 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` | | 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` | | 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 | TODO | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` | | 022 | DONE | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` |
## Summary ## Summary
- Total: **23** - Total: **23**
- TODO: **3** - TODO: **1**
- DONE: **20** - DONE: **22**
+4 -2
View File
@@ -47,7 +47,7 @@
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `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` - `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
@@ -151,8 +151,10 @@ limit: int | None
### Выходная: `AddressSuggestion` ### Выходная: `AddressSuggestion`
``` ```
provider: str
address: str address: str
street: str | None
house: str | None
flat: str | None
postal_code: str | None postal_code: str | None
``` ```
@@ -1,7 +1,7 @@
--- ---
id: 021 id: 021
title: Add address suggestion adapter and country provider mapping title: Add address suggestion adapter and country provider mapping
status: TODO status: DONE
created: 2026-03-25 created: 2026-03-25
--- ---
@@ -1,7 +1,7 @@
--- ---
id: 022 id: 022
title: Add address suggestion endpoint title: Add address suggestion endpoint
status: TODO status: DONE
created: 2026-03-25 created: 2026-03-25
--- ---
@@ -21,7 +21,7 @@ created: 2026-03-25
- Не изменять файлы в `spec/`. - Не изменять файлы в `spec/`.
## Acceptance criteria ## 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`. - Реализованы request/response schemas `AddressSuggestRequest` и `AddressSuggestion`.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`. - Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.suggest_addresses()`. - Controller делегирует обработку только в `AggregatorService.suggest_addresses()`.
@@ -33,7 +33,7 @@ created: 2026-03-25
## Definition of Done ## Definition of Done
- [ ] Добавлены address suggestion request/response schemas. - [ ] Добавлены address suggestion request/response schemas.
- [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration. - [ ] Реализован метод `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. - [ ] Добавлены service и controller/API tests для address suggestion flow.
## Tests ## Tests
@@ -65,6 +65,9 @@ def test_dadata_provider_maps_response_to_unified_model() -> None:
"value": "г Москва, ул Хабаровская", "value": "г Москва, ул Хабаровская",
"unrestricted_value": "107241, г Москва, ул Хабаровская", "unrestricted_value": "107241, г Москва, ул Хабаровская",
"data": { "data": {
"street": "Хабаровская",
"house": "1",
"flat": "25",
"postal_code": "107241", "postal_code": "107241",
"country_iso_code": "RU", "country_iso_code": "RU",
"geo_lat": "55.821168", "geo_lat": "55.821168",
@@ -89,8 +92,10 @@ def test_dadata_provider_maps_response_to_unified_model() -> None:
assert result == [ assert result == [
AddressSuggestion( AddressSuggestion(
provider="dadata",
address="107241, г Москва, ул Хабаровская", address="107241, г Москва, ул Хабаровская",
street="Хабаровская",
house="1",
flat="25",
postal_code="107241", postal_code="107241",
) )
] ]
@@ -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.",
}
}
+198
View File
@@ -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()))