Добавлен POST /api/v1/delivery/suggest-address
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
Reference in New Issue
Block a user