diff --git a/app/adapters/address_suggestions/tomtom/__init__.py b/app/adapters/address_suggestions/tomtom/__init__.py new file mode 100644 index 0000000..18b6624 --- /dev/null +++ b/app/adapters/address_suggestions/tomtom/__init__.py @@ -0,0 +1,13 @@ +"""TomTom address suggestion adapter package.""" + +from app.adapters.address_suggestions.tomtom.client import ( + TomTomAddressSuggestionProvider, + TomTomClientError, + TomTomRequestError, +) + +__all__ = [ + "TomTomAddressSuggestionProvider", + "TomTomClientError", + "TomTomRequestError", +] diff --git a/app/adapters/address_suggestions/tomtom/client.py b/app/adapters/address_suggestions/tomtom/client.py new file mode 100644 index 0000000..ed70057 --- /dev/null +++ b/app/adapters/address_suggestions/tomtom/client.py @@ -0,0 +1,155 @@ +"""TomTom address suggestion adapter.""" + +from typing import Any +from urllib.parse import quote + +import httpx + +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionProvider, + AddressSuggestionRequestError, +) +from app.config import TomTomAddressSuggestionsConfig +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + +_TOMTOM_IDX_SET = "PAD,Addr,Str,EPP" + + +class TomTomClientError(AddressSuggestionClientError): + """Raised when TomTom Search requests fail.""" + + +class TomTomRequestError(TomTomClientError, AddressSuggestionRequestError): + """Raised when TomTom rejects request parameters.""" + + +class TomTomAddressSuggestionProvider(AddressSuggestionProvider): + name = "tomtom" + + def __init__( + self, + http_client: httpx.AsyncClient, + *, + url: str, + api_key: str, + timeout_seconds: float = 10.0, + ) -> None: + self._http_client = http_client + self._url = url.rstrip("/") + self._api_key = api_key + self._timeout_seconds = timeout_seconds + + @classmethod + def from_config( + cls, + *, + http_client: httpx.AsyncClient, + config: TomTomAddressSuggestionsConfig, + ) -> "TomTomAddressSuggestionProvider": + return cls( + http_client=http_client, + url=config.url, + api_key=config.api_key, + timeout_seconds=config.timeout_seconds, + ) + + async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: + request_url = self._build_request_url(request) + params = self._build_query_params(request) + try: + response = await self._http_client.get( + request_url, + params=params, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + raise TomTomClientError("TomTom Search request failed.") from exc + + if response.status_code == 400: + raise TomTomRequestError( + "TomTom Search request was rejected with status 400." + ) + + if response.status_code >= 500 or response.status_code in {403, 429}: + raise TomTomClientError( + "TomTom Search request failed with status " + f"{response.status_code}." + ) + + if 400 <= response.status_code < 500: + raise TomTomClientError( + "TomTom Search request failed with status " + f"{response.status_code}." + ) + + try: + response.raise_for_status() + raw_payload = response.json() + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise TomTomClientError("TomTom Search returned invalid payload.") from exc + + try: + return _map_tomtom_response(raw_payload) + except ValueError as exc: + raise TomTomClientError( + "TomTom Search response payload is invalid." + ) from exc + + def _build_request_url(self, request: AddressSuggestRequest) -> str: + search_query = f"{request.city.strip()} {request.query.strip()}".strip() + return f"{self._url}/{quote(search_query, safe='')}.json" + + def _build_query_params(self, request: AddressSuggestRequest) -> dict[str, Any]: + params: dict[str, Any] = { + "key": self._api_key, + "countrySet": request.country_code.strip().upper(), + "typeahead": "true", + "idxSet": _TOMTOM_IDX_SET, + } + if request.limit is not None: + params["limit"] = request.limit + return params + + +def _map_tomtom_response(payload: object) -> list[AddressSuggestion]: + if not isinstance(payload, dict): + raise ValueError("TomTom response must be a JSON object.") + + raw_results = payload.get("results") + if raw_results is None: + return [] + if not isinstance(raw_results, list): + raise ValueError("TomTom response must include results list.") + + return [_map_tomtom_result(item) for item in raw_results] + + +def _map_tomtom_result(payload: object) -> AddressSuggestion: + if not isinstance(payload, dict): + raise ValueError("TomTom result entry must be an object.") + + raw_address = payload.get("address") + if not isinstance(raw_address, dict): + raise ValueError("TomTom result address must be an object.") + + address = _coerce_optional_text(raw_address.get("freeformAddress")) + if address is None: + raise ValueError("TomTom result has no valid freeformAddress.") + + return AddressSuggestion( + address=address, + street=_coerce_optional_text(raw_address.get("streetName")), + house=_coerce_optional_text(raw_address.get("streetNumber")), + flat=None, + postal_code=_coerce_optional_text(raw_address.get("postalCode")), + ) + + +def _coerce_optional_text(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("TomTom address field must be a string.") + return value.strip() or None diff --git a/app/config.py b/app/config.py index a8b115b..9670164 100644 --- a/app/config.py +++ b/app/config.py @@ -60,6 +60,12 @@ class YandexGeosuggestAddressSuggestionsConfig(BaseModel): timeout_seconds: float = Field(default=10.0, gt=0) +class TomTomAddressSuggestionsConfig(BaseModel): + url: str = "https://api.tomtom.com/search/2/search" + api_key: str = "" + timeout_seconds: float = Field(default=10.0, gt=0) + + class AddressSuggestionsConfig(BaseModel): country_to_provider: dict[str, str] = Field(default_factory=dict) dadata: DadataAddressSuggestionsConfig = Field( @@ -68,6 +74,9 @@ class AddressSuggestionsConfig(BaseModel): yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field( default_factory=YandexGeosuggestAddressSuggestionsConfig ) + tomtom: TomTomAddressSuggestionsConfig = Field( + default_factory=TomTomAddressSuggestionsConfig + ) @field_validator("country_to_provider") @classmethod diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 524ff09..39da88d 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider +from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider from app.adapters.address_suggestions.yandex_geosuggest import ( YandexGeosuggestAddressSuggestionProvider, ) @@ -45,13 +46,21 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService: http_client=http_client, config=settings.address_suggestions.yandex_geosuggest, ) + tomtom_provider = TomTomAddressSuggestionProvider.from_config( + http_client=http_client, + config=settings.address_suggestions.tomtom, + ) 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, yandex_geosuggest_provider), + address_suggestion_providers=( + dadata_provider, + yandex_geosuggest_provider, + tomtom_provider, + ), address_suggestion_country_to_provider=( settings.address_suggestions.country_to_provider ), diff --git a/config.test.yaml b/config.test.yaml index 59c8d92..d551ba4 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -35,6 +35,36 @@ address_suggestions: TJ: "yandex_geosuggest" TM: "yandex_geosuggest" UZ: "yandex_geosuggest" + AL: "tomtom" + AT: "tomtom" + BE: "tomtom" + BG: "tomtom" + CH: "tomtom" + CZ: "tomtom" + DE: "tomtom" + DK: "tomtom" + EE: "tomtom" + ES: "tomtom" + FI: "tomtom" + FR: "tomtom" + GB: "tomtom" + GR: "tomtom" + HR: "tomtom" + HU: "tomtom" + IE: "tomtom" + IT: "tomtom" + LT: "tomtom" + LV: "tomtom" + NL: "tomtom" + "NO": "tomtom" + PL: "tomtom" + PT: "tomtom" + RO: "tomtom" + RS: "tomtom" + SE: "tomtom" + SI: "tomtom" + SK: "tomtom" + UA: "tomtom" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "test-dadata-api-key" @@ -43,6 +73,10 @@ address_suggestions: url: "https://suggest-maps.yandex.ru/v1/suggest" api_key: "test-yandex-geosuggest-api-key" timeout_seconds: 6.5 + tomtom: + url: "https://api.tomtom.com/search/2/search" + api_key: "test-tomtom-api-key" + timeout_seconds: 5.5 observability: enabled: false diff --git a/spec/index.md b/spec/index.md index 0e43a27..9b073f0 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,6 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-29T15:00:18+00:00` ## Tasks @@ -31,9 +30,10 @@ | 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` | | 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` | +| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` | ## Summary -- Total: **24** +- Total: **25** - TODO: **1** -- DONE: **23** +- DONE: **24** diff --git a/spec/overview.md b/spec/overview.md index 404a608..22380bd 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -22,6 +22,7 @@ - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest +- Для европейских стран, сопоставленных с provider id `tomtom`, использовать TomTom Search API Fuzzy Search - Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов - Опрашивать всех зарегистрированных провайдеров параллельно - Возвращать унифицированный список тарифов, отсортированных по цене @@ -106,10 +107,12 @@ ``` - `dadata/client.py` — HTTP-клиент `dadata.ru` для address suggestions - `yandex_geosuggest/client.py` — HTTP-клиент Yandex Geosuggest `GET https://suggest-maps.yandex.ru/v1/suggest` +- `tomtom/client.py` — HTTP-клиент TomTom Search API Fuzzy Search `GET https://api.tomtom.com/search/2/search/{query}.json` - provider-specific модули address suggestion adapters инкапсулируют внешние API-контракты, auth, serialization и error handling - В конфиге adapter layer хранится маппинг `country_code -> provider_id` для выбора address suggestion provider - Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service - Для Yandex Geosuggest adapter возвращает `postal_code=None`, так как контракт Geosuggest не используется как источник почтового индекса +- Для TomTom Search adapter использует `request.city` и `request.query` для формирования search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP`, а `postal_code` берёт из `address.postalCode`, если оно присутствует --- @@ -228,7 +231,9 @@ app/ │ │ ├── base.py # Интерфейс Adapter │ │ ├── dadata/ │ │ └── client.py -│ │ └── yandex_geosuggest/ +│ │ ├── yandex_geosuggest/ +│ │ └── client.py +│ │ └── tomtom/ │ │ └── client.py │ └── delivery_providers/ │ ├── base.py # Интерфейс Adapter diff --git a/spec/tasks/019_add_cdek_order_creation_endpoint.md b/spec/tasks/019_add_cdek_order_creation_endpoint.md index 055d7a8..1be370b 100644 --- a/spec/tasks/019_add_cdek_order_creation_endpoint.md +++ b/spec/tasks/019_add_cdek_order_creation_endpoint.md @@ -20,7 +20,7 @@ created: 2026-03-14 - `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса. - Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`. - В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы. -- Scope задачи не включает `POST /api/v1/delivery/address/suggest`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK. +- Scope задачи не включает `POST /api/v1/delivery/suggest-address`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK. - Не изменять файлы в `spec/`. ## Acceptance criteria diff --git a/spec/tasks/022_add_address_suggestion_endpoint.md b/spec/tasks/022_add_address_suggestion_endpoint.md index 99e240c..8566605 100644 --- a/spec/tasks/022_add_address_suggestion_endpoint.md +++ b/spec/tasks/022_add_address_suggestion_endpoint.md @@ -9,7 +9,7 @@ created: 2026-03-25 Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран. ## Goal -Добавить `POST /api/v1/delivery/address/suggest` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping. +Добавить `POST /api/v1/delivery/suggest-address` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping. ## Constraints - Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses. @@ -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-address` в существующем controller. +- [ ] Реализован endpoint `POST /api/v1/delivery/suggest-address` в существующем controller. - [ ] Добавлены service и controller/API tests для address suggestion flow. ## Tests diff --git a/spec/tasks/024_add_tomtom_address_suggestion_adapter.md b/spec/tasks/024_add_tomtom_address_suggestion_adapter.md new file mode 100644 index 0000000..6944de4 --- /dev/null +++ b/spec/tasks/024_add_tomtom_address_suggestion_adapter.md @@ -0,0 +1,56 @@ +--- +id: 024 +title: Add TomTom address suggestion adapter and Europe routing +status: DONE +created: 2026-03-29 +--- + +## Context +Текущий flow подсказок адреса поддерживает `dadata` для `RU`, `BY`, `KZ` и `yandex_geosuggest` для части стран СНГ. Следующий этап требует добавить третий provider `TomTom` для европейских стран, сохранив текущий публичный API и routing через YAML-конфиг. + +## Goal +Добавить новый adapter `tomtom` для address suggestions на базе TomTom Search API Fuzzy Search, настроить routing европейских стран на provider id `tomtom` через конфиг и покрыть это config, adapter и service tests без изменения публичного API endpoint. + +## Constraints +- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты. +- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять. +- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики. +- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/tomtom/`. +- Новый adapter должен следовать официальному HTTP-контракту TomTom Search API Fuzzy Search: `GET https://api.tomtom.com/search/2/search/{query}.json`. +- TomTom adapter должен формировать `query` из `request.city` и `request.query`, передавать `countrySet=request.country_code`, `typeahead=true`, а `request.limit` при наличии маппить в query-параметр `limit`. +- Для исключения POI из address suggestion flow adapter должен отправлять address-oriented `idxSet=PAD,Addr,Str,EPP`. +- Routing европейских стран должен определяться только YAML-маппингом `country_code -> provider_id`; запрещено добавлять в Service hardcoded классификацию Европы. +- Unified mapping наружу должен возвращать только `AddressSuggestion` без утечки provider-specific payload. +- Для TomTom Search поля должны маппиться детерминированно: `address.freeformAddress -> address`, `address.streetName -> street`, `address.streetNumber -> house`, `address.postalCode -> postal_code`, `flat=None`. +- Ошибки TomTom `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path. +- Scope задачи не включает изменение order flow, price flow, новых endpoint'ов, расширение internal model `AddressSuggestion` и изменение контрактов существующих adapters `dadata` и `yandex_geosuggest`. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- В конфиге address suggestion providers добавлен provider id `tomtom` с параметрами, необходимыми для вызова TomTom Search API Fuzzy Search. +- Country mapping в конфиге маршрутизирует на `tomtom` европейские страны, явно перечисленные в YAML, при этом routing для `RU`, `BY`, `KZ` на `dadata` и для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest` сохраняется без изменений. +- Реализован adapter `app/adapters/address_suggestions/tomtom/client.py`, который отправляет запрос в TomTom Search API Fuzzy Search по официальному контракту и возвращает `list[AddressSuggestion]`. +- Adapter объединяет `request.city` и `request.query` в search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP` и при наличии `request.limit` маппит его в `limit`. +- Поля `address`, `street`, `house` и `postal_code` детерминированно извлекаются из ответа TomTom (`freeformAddress`, `streetName`, `streetNumber`, `postalCode`), а `flat` возвращается как `None`. +- `AggregatorService.suggest_addresses()` по `country_code` выбирает `tomtom` для европейских стран, явно сопоставленных в YAML-конфиге, и сохраняет существующий routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран. +- При ответе TomTom с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path. + +## Definition of Done +- [ ] Добавлена конфигурация `tomtom` и обновлён country mapping для европейских стран. +- [ ] Реализован новый TomTom adapter без утечки HTTP-деталей в Service. +- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта. +- [ ] Добавлены tests для config, adapter mapping/error handling и service routing на `tomtom` с сохранением существующего routing на `dadata` и `yandex_geosuggest`. +- [ ] Пройдены все команды из раздела Commands. + +## Tests +- Обновить `tests/config/test_config_sections.py` для проверки секции `tomtom` и country mapping, в котором европейские страны маршрутизируются на `tomtom`, а существующие маппинги `RU`, `BY`, `KZ` -> `dadata` и `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest` сохраняются. +- Добавить `tests/adapters/address_suggestions/tomtom/test_client.py` для success case, mapping `limit`, формирования search query из `city` и `query`, передачи `typeahead=true` и `idxSet=PAD,Addr,Str,EPP`, возврата `flat=None`, извлечения `address`/`street`/`house`/`postal_code` и error scenarios `400`, `403`, `429`, `5xx`. +- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `tomtom` по европейским странам из YAML-конфига и сохранения routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран. +- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API. + +## Commands +- `poetry run pytest tests/config/test_config_sections.py -q` +- `poetry run pytest tests/adapters/address_suggestions/tomtom/test_client.py -q` +- `poetry run pytest tests/services/test_address_suggestions.py -q` +- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/address_suggestions/tomtom/test_client.py b/tests/adapters/address_suggestions/tomtom/test_client.py new file mode 100644 index 0000000..d8f6664 --- /dev/null +++ b/tests/adapters/address_suggestions/tomtom/test_client.py @@ -0,0 +1,258 @@ +import asyncio +from typing import Any + +import httpx +import pytest + +from app.adapters.address_suggestions.tomtom.client import ( + TomTomAddressSuggestionProvider, + TomTomClientError, + TomTomRequestError, +) +from app.config import TomTomAddressSuggestionsConfig +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + + +class SequenceHTTPClient: + def __init__(self, results: list[Any]) -> None: + self._results = results + self.calls: list[dict[str, Any]] = [] + + def _next_result(self) -> Any: + return self._results[len(self.calls) - 1] + + async def get( + self, + url: str, + *, + params: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> httpx.Response: + self.calls.append( + { + "method": "GET", + "url": url, + "params": params, + "timeout": timeout, + } + ) + result = self._next_result() + if isinstance(result, Exception): + raise result + return result + + +def _make_request(**overrides: object) -> AddressSuggestRequest: + payload: dict[str, object] = { + "country_code": "DE", + "city": "Berlin", + "query": "Alexanderplatz 1", + "limit": 5, + } + payload.update(overrides) + return AddressSuggestRequest(**payload) + + +def test_tomtom_provider_maps_response_to_unified_model() -> None: + response = httpx.Response( + 200, + json={ + "results": [ + { + "address": { + "freeformAddress": "Alexanderplatz 1, 10178 Berlin", + "streetName": "Alexanderplatz", + "streetNumber": "1", + "postalCode": "10178", + } + } + ] + }, + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = TomTomAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://api.tomtom.test/search/2/search", + api_key="test-api-key", + timeout_seconds=5.5, + ) + + result = asyncio.run(provider.suggest(_make_request())) + + assert result == [ + AddressSuggestion( + address="Alexanderplatz 1, 10178 Berlin", + street="Alexanderplatz", + house="1", + flat=None, + postal_code="10178", + ) + ] + assert http_client.calls == [ + { + "method": "GET", + "url": "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + "params": { + "key": "test-api-key", + "countrySet": "DE", + "typeahead": "true", + "idxSet": "PAD,Addr,Str,EPP", + "limit": 5, + }, + "timeout": 5.5, + } + ] + + +def test_tomtom_provider_uses_config_factory_and_omits_limit_when_missing() -> None: + response = httpx.Response( + 200, + json={ + "results": [ + { + "address": { + "freeformAddress": "Dam Square, 1012 JS Amsterdam", + "streetName": "Dam Square", + "postalCode": "1012 JS", + } + } + ] + }, + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Amsterdam%20Dam%20Square.json", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = TomTomAddressSuggestionProvider.from_config( + http_client=http_client, # type: ignore[arg-type] + config=TomTomAddressSuggestionsConfig( + url="https://api.tomtom.test/search/2/search", + api_key="config-api-key", + timeout_seconds=4.25, + ), + ) + + result = asyncio.run( + provider.suggest( + _make_request( + country_code="NL", + city="Amsterdam", + query="Dam Square", + limit=None, + ) + ) + ) + + assert result == [ + AddressSuggestion( + address="Dam Square, 1012 JS Amsterdam", + street="Dam Square", + house=None, + flat=None, + postal_code="1012 JS", + ) + ] + assert http_client.calls == [ + { + "method": "GET", + "url": "https://api.tomtom.test/search/2/search/Amsterdam%20Dam%20Square.json", + "params": { + "key": "config-api-key", + "countrySet": "NL", + "typeahead": "true", + "idxSet": "PAD,Addr,Str,EPP", + }, + "timeout": 4.25, + } + ] + + +def test_tomtom_provider_maps_400_to_request_error() -> None: + response = httpx.Response( + 400, + json={"errorText": "bad request"}, + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = TomTomAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://api.tomtom.test/search/2/search", + api_key="test-api-key", + ) + + with pytest.raises(TomTomRequestError, match="status 400"): + asyncio.run(provider.suggest(_make_request())) + + +@pytest.mark.parametrize("status_code", [403, 429, 503]) +def test_tomtom_provider_maps_unavailable_statuses_to_client_error( + status_code: int, +) -> None: + response = httpx.Response( + status_code, + json={"errorText": "provider error"}, + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = TomTomAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://api.tomtom.test/search/2/search", + api_key="test-api-key", + ) + + with pytest.raises(TomTomClientError, match=f"status {status_code}"): + asyncio.run(provider.suggest(_make_request())) + + +def test_tomtom_provider_maps_transport_error_to_client_error() -> None: + transport_error = httpx.ReadTimeout( + "read timeout", + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + ), + ) + http_client = SequenceHTTPClient([transport_error]) + provider = TomTomAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://api.tomtom.test/search/2/search", + api_key="test-api-key", + ) + + with pytest.raises(TomTomClientError, match="request failed"): + asyncio.run(provider.suggest(_make_request())) + + +def test_tomtom_provider_raises_on_invalid_payload_shape() -> None: + response = httpx.Response( + 200, + json={"results": [{"address": {"freeformAddress": 1}}]}, + request=httpx.Request( + "GET", + "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = TomTomAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://api.tomtom.test/search/2/search", + api_key="test-api-key", + ) + + with pytest.raises( + TomTomClientError, + match="response payload is invalid", + ): + asyncio.run(provider.suggest(_make_request())) diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 51f85e3..7685528 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -19,6 +19,7 @@ address_suggestions: country_to_provider: RU: "dadata" AM: "yandex_geosuggest" + DE: "tomtom" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "default-dadata-key" @@ -26,6 +27,10 @@ address_suggestions: url: "https://suggest-maps.yandex.ru/v1/suggest" api_key: "default-yandex-key" timeout_seconds: 4.0 + tomtom: + url: "https://api.tomtom.com/search/2/search" + api_key: "default-tomtom-key" + timeout_seconds: 4.25 observability: enabled: false diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 7f22409..227af3c 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -19,6 +19,7 @@ address_suggestions: country_to_provider: RU: "dadata" AM: "yandex_geosuggest" + DE: "tomtom" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "override-dadata-key" @@ -27,6 +28,10 @@ address_suggestions: url: "https://suggest-maps.yandex.ru/v1/suggest" api_key: "override-yandex-key" timeout_seconds: 4.5 + tomtom: + url: "https://api.tomtom.com/search/2/search" + api_key: "override-tomtom-key" + timeout_seconds: 5.25 observability: enabled: true diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index e55fd71..570dadc 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -41,6 +41,51 @@ MISSING_OBSERVABILITY_ENDPOINT_CONFIG_FILE = ( / "fixtures" / "config.missing_observability_endpoint.yaml" ) +_DADATA_COUNTRIES = ("RU", "BY", "KZ") +_YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ") +_TOMTOM_COUNTRIES = ( + "AL", + "AT", + "BE", + "BG", + "CH", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FR", + "GB", + "GR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LV", + "NL", + "NO", + "PL", + "PT", + "RO", + "RS", + "SE", + "SI", + "SK", + "UA", +) + + +def _expected_country_mapping() -> dict[str, str]: + return { + **{country_code: "dadata" for country_code in _DADATA_COUNTRIES}, + **{ + country_code: "yandex_geosuggest" + for country_code in _YANDEX_COUNTRIES + }, + **{country_code: "tomtom" for country_code in _TOMTOM_COUNTRIES}, + } def _use_runtime_config_files( @@ -81,18 +126,7 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_cache_ttl_seconds == 900 - assert settings.address_suggestions.country_to_provider == { - "RU": "dadata", - "BY": "dadata", - "KZ": "dadata", - "AM": "yandex_geosuggest", - "AZ": "yandex_geosuggest", - "KG": "yandex_geosuggest", - "MD": "yandex_geosuggest", - "TJ": "yandex_geosuggest", - "TM": "yandex_geosuggest", - "UZ": "yandex_geosuggest", - } + assert settings.address_suggestions.country_to_provider == _expected_country_mapping() assert ( settings.address_suggestions.dadata.url == "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" @@ -108,6 +142,9 @@ def test_configuration_sections_are_loaded_from_yaml_file( == "test-yandex-geosuggest-api-key" ) assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 6.5 + assert settings.address_suggestions.tomtom.url == "https://api.tomtom.com/search/2/search" + assert settings.address_suggestions.tomtom.api_key == "test-tomtom-api-key" + assert settings.address_suggestions.tomtom.timeout_seconds == 5.5 assert settings.observability.enabled is False assert settings.observability.service_name == "g2s-aggregator-test" assert settings.observability.otlp_endpoint == "http://localhost:4317" @@ -178,11 +215,14 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.address_suggestions.country_to_provider == { "RU": "dadata", "AM": "yandex_geosuggest", + "DE": "tomtom", } assert settings.address_suggestions.dadata.api_key == "override-dadata-key" assert settings.address_suggestions.dadata.timeout_seconds == 3.0 assert settings.address_suggestions.yandex_geosuggest.api_key == "override-yandex-key" assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 4.5 + assert settings.address_suggestions.tomtom.api_key == "override-tomtom-key" + assert settings.address_suggestions.tomtom.timeout_seconds == 5.25 assert settings.observability.enabled is True assert settings.observability.service_name == "override-config-service" assert settings.observability.otlp_endpoint == "http://override-collector:4317" diff --git a/tests/controllers/v1/test_address_suggestions.py b/tests/controllers/v1/test_address_suggestions.py index 8296ade..4d2c617 100644 --- a/tests/controllers/v1/test_address_suggestions.py +++ b/tests/controllers/v1/test_address_suggestions.py @@ -99,6 +99,18 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( _ = config return stub_yandex_provider + class StubTomTomAddressSuggestionProvider: + @classmethod + def from_config( + cls, + *, + http_client, + config, + ) -> StubAddressProvider: + assert http_client is stub_http_client + _ = config + return stub_tomtom_provider + class StubCache: async def get(self, key: str) -> object | None: _ = key @@ -138,6 +150,17 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( ) ], ) + stub_tomtom_provider = StubAddressProvider( + "tomtom", + [ + AddressSuggestion( + address="Alexanderplatz 1, 10178 Berlin", + street="Alexanderplatz", + house="1", + postal_code="10178", + ) + ], + ) http_client_timeouts: list[float] = [] def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient: @@ -160,13 +183,18 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( "YandexGeosuggestAddressSuggestionProvider", StubYandexGeosuggestAddressSuggestionProvider, ) + monkeypatch.setattr( + delivery_controller, + "TomTomAddressSuggestionProvider", + StubTomTomAddressSuggestionProvider, + ) monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) app = create_app() request_payload = _valid_payload( - country_code="AM", - city="Yerevan", - query="Tumanyan 12", + country_code="DE", + city="Berlin", + query="Alexanderplatz 1", ) async def run_request() -> httpx.Response: @@ -185,16 +213,17 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( assert response.status_code == 200 assert response.json() == [ { - "address": "Tumanyan Street, 12, Yerevan", - "street": "Tumanyan Street", - "house": "12", + "address": "Alexanderplatz 1, 10178 Berlin", + "street": "Alexanderplatz", + "house": "1", "flat": None, - "postal_code": None, + "postal_code": "10178", } ] assert http_client_timeouts == [10.0] assert stub_dadata_provider.calls == [] - assert stub_yandex_provider.calls == [AddressSuggestRequest(**request_payload)] + assert stub_yandex_provider.calls == [] + assert stub_tomtom_provider.calls == [AddressSuggestRequest(**request_payload)] def test_post_address_suggest_returns_response_and_delegates_to_service() -> None: diff --git a/tests/services/test_address_suggestions.py b/tests/services/test_address_suggestions.py index 153e5ac..1864676 100644 --- a/tests/services/test_address_suggestions.py +++ b/tests/services/test_address_suggestions.py @@ -17,6 +17,38 @@ from app.services.aggregator import ( _DADATA_COUNTRIES = ("RU", "BY", "KZ") _YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ") +_TOMTOM_COUNTRIES = ( + "AL", + "AT", + "BE", + "BG", + "CH", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FR", + "GB", + "GR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LV", + "NL", + "NO", + "PL", + "PT", + "RO", + "RS", + "SE", + "SI", + "SK", + "UA", +) class StubAddressSuggestionProvider: @@ -74,6 +106,7 @@ def _make_country_mapping() -> dict[str, str]: country_code: "yandex_geosuggest" for country_code in _YANDEX_COUNTRIES }, + **{country_code: "tomtom" for country_code in _TOMTOM_COUNTRIES}, } @@ -97,9 +130,13 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata( "yandex_geosuggest", response=[_make_suggestion("Yerevan, Tumanyan 1")], ) + tomtom = StubAddressSuggestionProvider( + "tomtom", + response=[_make_suggestion("Alexanderplatz 1, 10178 Berlin")], + ) service = AggregatorService( providers=[], - address_suggestion_providers=[dadata, yandex_geosuggest], + address_suggestion_providers=[dadata, yandex_geosuggest, tomtom], address_suggestion_country_to_provider=_make_country_mapping(), ) request = _make_request( @@ -121,6 +158,7 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata( ] assert dadata.calls == [request] assert yandex_geosuggest.calls == [] + assert tomtom.calls == [] @pytest.mark.parametrize("country_code", _YANDEX_COUNTRIES) @@ -135,9 +173,13 @@ def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest( "yandex_geosuggest", response=[_make_suggestion("Yerevan, Tumanyan 1")], ) + tomtom = StubAddressSuggestionProvider( + "tomtom", + response=[_make_suggestion("Alexanderplatz 1, 10178 Berlin")], + ) service = AggregatorService( providers=[], - address_suggestion_providers=[dadata, 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") @@ -147,6 +189,57 @@ def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest( assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")] assert dadata.calls == [] assert yandex_geosuggest.calls == [request] + assert tomtom.calls == [] + + +@pytest.mark.parametrize("country_code", _TOMTOM_COUNTRIES) +def test_suggest_addresses_routes_european_countries_to_tomtom( + country_code: str, +) -> None: + dadata = StubAddressSuggestionProvider( + "dadata", + response=[_make_suggestion("Moscow, Lenina 1")], + ) + yandex_geosuggest = StubAddressSuggestionProvider( + "yandex_geosuggest", + response=[_make_suggestion("Yerevan, Tumanyan 1")], + ) + tomtom = StubAddressSuggestionProvider( + "tomtom", + response=[ + _make_suggestion( + "Alexanderplatz 1, 10178 Berlin", + street="Alexanderplatz", + house="1", + postal_code="10178", + ) + ], + ) + service = AggregatorService( + providers=[], + 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", + ) + + result = asyncio.run(service.suggest_addresses(request)) + + assert result == [ + AddressSuggestion( + address="Alexanderplatz 1, 10178 Berlin", + street="Alexanderplatz", + house="1", + flat=None, + postal_code="10178", + ) + ] + assert dadata.calls == [] + assert yandex_geosuggest.calls == [] + assert tomtom.calls == [request] def test_suggest_addresses_raises_for_unsupported_country() -> None: