From 17772e5337c38444aa2dce4e02f51df8e7396182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 28 Mar 2026 03:44:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20dadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adapters/address_suggestions/__init__.py | 13 + app/adapters/address_suggestions/base.py | 24 ++ .../address_suggestions/dadata/__init__.py | 13 + .../address_suggestions/dadata/client.py | 146 +++++++++++ app/config.py | 30 ++- app/schemas/request.py | 9 +- app/schemas/response.py | 8 +- config.test.yaml | 10 + .../address_suggestions/dadata/test_client.py | 231 ++++++++++++++++++ tests/config/fixtures/config.default.yaml | 7 + .../config.invalid_price_multiplier.yaml | 7 + .../fixtures/config.missing_adapter.yaml | 7 + .../config.missing_address_suggestions.yaml | 30 +++ .../config.missing_observability.yaml | 7 + ...config.missing_observability_endpoint.yaml | 7 + .../config.missing_price_multiplier.yaml | 7 + .../config/fixtures/config.test.override.yaml | 9 + tests/config/test_config_sections.py | 41 ++++ 18 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 app/adapters/address_suggestions/__init__.py create mode 100644 app/adapters/address_suggestions/base.py create mode 100644 app/adapters/address_suggestions/dadata/__init__.py create mode 100644 app/adapters/address_suggestions/dadata/client.py create mode 100644 tests/adapters/address_suggestions/dadata/test_client.py create mode 100644 tests/config/fixtures/config.missing_address_suggestions.yaml diff --git a/app/adapters/address_suggestions/__init__.py b/app/adapters/address_suggestions/__init__.py new file mode 100644 index 0000000..04b4e45 --- /dev/null +++ b/app/adapters/address_suggestions/__init__.py @@ -0,0 +1,13 @@ +"""Address suggestion adapters.""" + +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionProvider, + AddressSuggestionRequestError, +) + +__all__ = [ + "AddressSuggestionClientError", + "AddressSuggestionProvider", + "AddressSuggestionRequestError", +] diff --git a/app/adapters/address_suggestions/base.py b/app/adapters/address_suggestions/base.py new file mode 100644 index 0000000..fca944a --- /dev/null +++ b/app/adapters/address_suggestions/base.py @@ -0,0 +1,24 @@ +"""Base interface for address suggestion providers.""" + +from abc import ABC, abstractmethod + +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + + +class AddressSuggestionProvider(ABC): + name: str + + @abstractmethod + async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: + """Fetch address suggestions for a validated internal request.""" + + raise NotImplementedError + + +class AddressSuggestionClientError(RuntimeError): + """Raised when an address suggestion adapter call fails.""" + + +class AddressSuggestionRequestError(AddressSuggestionClientError): + """Raised when a provider rejects an address suggestion request.""" diff --git a/app/adapters/address_suggestions/dadata/__init__.py b/app/adapters/address_suggestions/dadata/__init__.py new file mode 100644 index 0000000..3e98850 --- /dev/null +++ b/app/adapters/address_suggestions/dadata/__init__.py @@ -0,0 +1,13 @@ +"""Dadata address suggestion adapter package.""" + +from app.adapters.address_suggestions.dadata.client import ( + DadataAddressSuggestionProvider, + DadataClientError, + DadataRequestError, +) + +__all__ = [ + "DadataAddressSuggestionProvider", + "DadataClientError", + "DadataRequestError", +] diff --git a/app/adapters/address_suggestions/dadata/client.py b/app/adapters/address_suggestions/dadata/client.py new file mode 100644 index 0000000..021ff69 --- /dev/null +++ b/app/adapters/address_suggestions/dadata/client.py @@ -0,0 +1,146 @@ +"""Dadata address suggestion adapter.""" + +from typing import Any +from urllib.parse import urljoin + +import httpx + +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionProvider, + AddressSuggestionRequestError, +) +from app.config import DadataAddressSuggestionsConfig +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + + +class DadataClientError(AddressSuggestionClientError): + """Raised when Dadata address suggestions fail.""" + + +class DadataRequestError(DadataClientError, AddressSuggestionRequestError): + """Raised when Dadata rejects a suggestion request.""" + + +class DadataAddressSuggestionProvider(AddressSuggestionProvider): + name = "dadata" + + 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 + self._api_key = api_key + self._timeout_seconds = timeout_seconds + + @classmethod + def from_config( + cls, + *, + http_client: httpx.AsyncClient, + config: DadataAddressSuggestionsConfig, + ) -> "DadataAddressSuggestionProvider": + 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]: + payload = self._build_payload(request) + try: + response = await self._http_client.post( + self._url, + json=payload, + headers={ + "Authorization": f"Token {self._api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + raise DadataClientError( + "Dadata address suggestion request failed." + ) from exc + + if 400 <= response.status_code < 500: + raise DadataRequestError( + "Dadata address suggestion request was rejected with status " + f"{response.status_code}." + ) + + if response.status_code >= 500: + raise DadataClientError( + "Dadata address suggestion 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 DadataClientError( + "Dadata address suggestion returned invalid payload." + ) from exc + + try: + return _map_dadata_response(raw_payload) + except ValueError as exc: + raise DadataClientError( + "Dadata address suggestion response payload is invalid." + ) from exc + + @staticmethod + def _build_payload(request: AddressSuggestRequest) -> dict[str, Any]: + payload: dict[str, Any] = { + "query": f"{request.city.strip()} {request.query.strip()}".strip(), + } + if request.limit is not None: + payload["count"] = request.limit + if request.country_code.strip().upper() != "RU": + payload["locations"] = [{"country": "*"}] + return payload + + +def _map_dadata_response(payload: object) -> list[AddressSuggestion]: + if not isinstance(payload, dict): + raise ValueError("Dadata response must be a JSON object.") + + raw_suggestions = payload.get("suggestions") + if not isinstance(raw_suggestions, list): + raise ValueError("Dadata response must include suggestions list.") + + return [_map_dadata_suggestion(item) for item in raw_suggestions] + + +def _map_dadata_suggestion(payload: object) -> AddressSuggestion: + if not isinstance(payload, dict): + raise ValueError("Dadata suggestion entry must be an object.") + + raw_address = payload.get("unrestricted_value") or payload.get("value") + if not isinstance(raw_address, str) or not raw_address.strip(): + raise ValueError("Dadata suggestion has no valid address value.") + + raw_data = payload.get("data") + if raw_data is not None and not isinstance(raw_data, dict): + raise ValueError("Dadata suggestion data must be an object when provided.") + + postal_code = None + if isinstance(raw_data, dict): + raw_postal_code = raw_data.get("postal_code") + if raw_postal_code is not None: + postal_code = str(raw_postal_code).strip() or None + + return AddressSuggestion( + provider="dadata", + address=raw_address.strip(), + postal_code=postal_code, + ) diff --git a/app/config.py b/app/config.py index b3a1938..122115f 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ from pathlib import Path import sys from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -48,6 +48,30 @@ class AdapterConfig(BaseModel): cdek_cache_ttl_seconds: int = Field(default=900, gt=0) +class DadataAddressSuggestionsConfig(BaseModel): + url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + 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( + default_factory=DadataAddressSuggestionsConfig + ) + + @field_validator("country_to_provider") + @classmethod + def _normalize_country_to_provider( + cls, + value: dict[str, str], + ) -> dict[str, str]: + return { + str(country_code).strip().upper(): str(provider_id).strip().lower() + for country_code, provider_id in value.items() + } + + class ObservabilityConfig(BaseModel): enabled: bool = False service_name: str = Field(..., min_length=1) @@ -65,6 +89,9 @@ class Settings(BaseSettings): business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig) adapter: AdapterConfig = Field(default_factory=AdapterConfig) + address_suggestions: AddressSuggestionsConfig = Field( + default_factory=AddressSuggestionsConfig + ) observability: ObservabilityConfig @classmethod @@ -93,6 +120,7 @@ class _RequiredYamlSections(BaseModel): business_logic: dict[str, Any] repository: dict[str, Any] adapter: dict[str, Any] + address_suggestions: dict[str, Any] observability: dict[str, Any] diff --git a/app/schemas/request.py b/app/schemas/request.py index 440978c..fd90a3e 100644 --- a/app/schemas/request.py +++ b/app/schemas/request.py @@ -1,4 +1,4 @@ -"""Input schemas for price calculation.""" +"""Input schemas for delivery-related API requests.""" from enum import StrEnum @@ -24,3 +24,10 @@ class DeliveryCalculationRequest(BaseModel): width_cm: float = Field(gt=0) height_cm: float = Field(gt=0) parcel_type: ParcelType | None = None + + +class AddressSuggestRequest(BaseModel): + country_code: str = Field(min_length=2, max_length=2) + city: str = Field(min_length=1) + query: str = Field(min_length=1) + limit: int | None = Field(default=None, gt=0) diff --git a/app/schemas/response.py b/app/schemas/response.py index 6b4b8f4..6c4b44c 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -1,4 +1,4 @@ -"""Output schemas for aggregated prices.""" +"""Output schemas for delivery-related API responses.""" from decimal import Decimal @@ -12,3 +12,9 @@ class DeliveryPrice(BaseModel): currency: str = Field(min_length=3, max_length=3) delivery_days_min: int = Field(ge=0) delivery_days_max: int = Field(ge=0) + + +class AddressSuggestion(BaseModel): + provider: str = Field(min_length=1) + address: str = Field(min_length=1) + postal_code: str | None = None diff --git a/config.test.yaml b/config.test.yaml index c1170d5..b347ce3 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -23,6 +23,16 @@ adapter: cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + BY: "dadata" + KZ: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "test-dadata-api-key" + timeout_seconds: 7.5 + observability: enabled: false service_name: "g2s-aggregator-test" diff --git a/tests/adapters/address_suggestions/dadata/test_client.py b/tests/adapters/address_suggestions/dadata/test_client.py new file mode 100644 index 0000000..64e6ea4 --- /dev/null +++ b/tests/adapters/address_suggestions/dadata/test_client.py @@ -0,0 +1,231 @@ +import asyncio +from typing import Any + +import httpx +import pytest + +from app.adapters.address_suggestions.dadata.client import ( + DadataAddressSuggestionProvider, + DadataClientError, + DadataRequestError, +) +from app.config import DadataAddressSuggestionsConfig +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 post( + self, + url: str, + *, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> httpx.Response: + self.calls.append( + { + "method": "POST", + "url": url, + "json": json, + "headers": headers, + "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": "RU", + "city": "Москва", + "query": "Хабаровская", + "limit": 5, + } + payload.update(overrides) + return AddressSuggestRequest(**payload) + + +def test_dadata_provider_maps_response_to_unified_model() -> None: + response = httpx.Response( + 200, + json={ + "suggestions": [ + { + "value": "г Москва, ул Хабаровская", + "unrestricted_value": "107241, г Москва, ул Хабаровская", + "data": { + "postal_code": "107241", + "country_iso_code": "RU", + "geo_lat": "55.821168", + }, + } + ] + }, + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = DadataAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="test-api-key", + timeout_seconds=7.5, + ) + + result = asyncio.run(provider.suggest(_make_request())) + + assert result == [ + AddressSuggestion( + provider="dadata", + address="107241, г Москва, ул Хабаровская", + postal_code="107241", + ) + ] + assert http_client.calls == [ + { + "method": "POST", + "url": "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + "json": { + "query": "Москва Хабаровская", + "count": 5, + }, + "headers": { + "Authorization": "Token test-api-key", + "Accept": "application/json", + "Content-Type": "application/json", + }, + "timeout": 7.5, + } + ] + + +def test_dadata_provider_uses_foreign_country_payload_and_config_factory() -> None: + response = httpx.Response( + 200, + json={"suggestions": []}, + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = DadataAddressSuggestionProvider.from_config( + http_client=http_client, # type: ignore[arg-type] + config=DadataAddressSuggestionsConfig( + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="config-api-key", + timeout_seconds=3.0, + ), + ) + + result = asyncio.run( + provider.suggest( + _make_request( + country_code="BY", + city="Гомель", + query="Советская", + limit=3, + ) + ) + ) + + assert result == [] + assert http_client.calls[0]["json"] == { + "query": "Гомель Советская", + "count": 3, + "locations": [{"country": "*"}], + } + assert http_client.calls[0]["headers"]["Authorization"] == "Token config-api-key" + assert http_client.calls[0]["timeout"] == 3.0 + + +def test_dadata_provider_maps_4xx_to_request_error() -> None: + response = httpx.Response( + 403, + json={"detail": "forbidden"}, + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = DadataAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="test-api-key", + ) + + with pytest.raises(DadataRequestError, match="status 403"): + asyncio.run(provider.suggest(_make_request())) + + +def test_dadata_provider_maps_5xx_to_client_error() -> None: + response = httpx.Response( + 503, + json={"detail": "unavailable"}, + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = DadataAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="test-api-key", + ) + + with pytest.raises(DadataClientError, match="status 503"): + asyncio.run(provider.suggest(_make_request())) + + +def test_dadata_provider_maps_transport_error_to_client_error() -> None: + transport_error = httpx.ReadTimeout( + "read timeout", + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([transport_error]) + provider = DadataAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="test-api-key", + ) + + with pytest.raises(DadataClientError, match="request failed"): + asyncio.run(provider.suggest(_make_request())) + + +def test_dadata_provider_raises_on_invalid_payload_shape() -> None: + response = httpx.Response( + 200, + json={"suggestions": [{"data": {"postal_code": "107241"}}]}, + request=httpx.Request( + "POST", + "https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = DadataAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggestions.dadata.test/suggestions/api/4_1/rs/suggest/address", + api_key="test-api-key", + ) + + with pytest.raises(DadataClientError, 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 ffe4e41..ea9ee3b 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -15,6 +15,13 @@ adapter: cdek_client_id: "yaml-id" cdek_client_secret: "yaml-secret" +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "default-dadata-key" + observability: enabled: false service_name: "default-config-service" diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml index 9645ed7..8c1906b 100644 --- a/tests/config/fixtures/config.invalid_price_multiplier.yaml +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -23,6 +23,13 @@ adapter: cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "invalid-price-multiplier-dadata-key" + observability: enabled: false service_name: "invalid-price-multiplier-service" diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index a7ce178..519f633 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -12,6 +12,13 @@ repository: redis_dsn: "redis://localhost:6379/0" price_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "missing-adapter-dadata-key" + observability: enabled: false service_name: "missing-adapter-service" diff --git a/tests/config/fixtures/config.missing_address_suggestions.yaml b/tests/config/fixtures/config.missing_address_suggestions.yaml new file mode 100644 index 0000000..24411b5 --- /dev/null +++ b/tests/config/fixtures/config.missing_address_suggestions.yaml @@ -0,0 +1,30 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "test-client-id" + cdek_client_secret: "test-client-secret" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 + +observability: + enabled: false + service_name: "missing-address-suggestions-service" + otlp_endpoint: "http://collector:4317" + otlp_insecure: true diff --git a/tests/config/fixtures/config.missing_observability.yaml b/tests/config/fixtures/config.missing_observability.yaml index 06525c4..a250375 100644 --- a/tests/config/fixtures/config.missing_observability.yaml +++ b/tests/config/fixtures/config.missing_observability.yaml @@ -22,3 +22,10 @@ adapter: cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 + +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "missing-observability-dadata-key" diff --git a/tests/config/fixtures/config.missing_observability_endpoint.yaml b/tests/config/fixtures/config.missing_observability_endpoint.yaml index 52a52f7..8dc0ced 100644 --- a/tests/config/fixtures/config.missing_observability_endpoint.yaml +++ b/tests/config/fixtures/config.missing_observability_endpoint.yaml @@ -23,6 +23,13 @@ adapter: cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "missing-observability-endpoint-dadata-key" + observability: enabled: true service_name: "g2s-aggregator" diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml index 6375deb..980143e 100644 --- a/tests/config/fixtures/config.missing_price_multiplier.yaml +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -22,6 +22,13 @@ adapter: cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "missing-price-multiplier-dadata-key" + observability: enabled: false service_name: "missing-price-multiplier-service" diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index c0a0f11..1e1f0cc 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -15,6 +15,15 @@ adapter: cdek_client_id: "test-id" cdek_client_secret: "test-secret" +address_suggestions: + country_to_provider: + RU: "dadata" + AM: "europe" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "override-dadata-key" + timeout_seconds: 3.0 + observability: enabled: true service_name: "override-config-service" diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index fd7885b..50bac8f 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -12,6 +12,13 @@ TEST_CONFIG_FILE = PROJECT_ROOT / "config.test.yaml" MISSING_REQUIRED_SECTION_CONFIG_FILE = ( PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_adapter.yaml" ) +MISSING_ADDRESS_SUGGESTIONS_CONFIG_FILE = ( + PROJECT_ROOT + / "tests" + / "config" + / "fixtures" + / "config.missing_address_suggestions.yaml" +) DEFAULT_CONFIG_FILE_FIXTURE = ( PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.default.yaml" ) @@ -74,6 +81,17 @@ 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", + } + assert ( + settings.address_suggestions.dadata.url + == "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + ) + assert settings.address_suggestions.dadata.api_key == "test-dadata-api-key" + assert settings.address_suggestions.dadata.timeout_seconds == 7.5 assert settings.observability.enabled is False assert settings.observability.service_name == "g2s-aggregator-test" assert settings.observability.otlp_endpoint == "http://localhost:4317" @@ -96,6 +114,22 @@ def test_get_settings_fails_when_required_yaml_section_is_missing( get_settings.cache_clear() +def test_get_settings_fails_when_address_suggestions_section_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_runtime_config_files( + monkeypatch, + test_config_file=MISSING_ADDRESS_SUGGESTIONS_CONFIG_FILE, + ) + + with pytest.raises(ValidationError) as error: + get_settings() + + locations = {tuple(item["loc"]) for item in error.value.errors()} + assert ("address_suggestions",) in locations + get_settings.cache_clear() + + def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -> None: _use_runtime_config_files(monkeypatch, test_config_file=TEST_CONFIG_FILE) @@ -105,6 +139,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) - assert first is second assert first.service.provider_timeout_seconds == 10.0 assert first.business_logic.provider_price_multiplier == Decimal("1.0") + assert first.address_suggestions.country_to_provider["RU"] == "dadata" assert first.observability.service_name == "g2s-aggregator-test" get_settings.cache_clear() @@ -124,6 +159,12 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.adapter.cdek_client_id == "test-id" + assert settings.address_suggestions.country_to_provider == { + "RU": "dadata", + "AM": "europe", + } + assert settings.address_suggestions.dadata.api_key == "override-dadata-key" + assert settings.address_suggestions.dadata.timeout_seconds == 3.0 assert settings.observability.enabled is True assert settings.observability.service_name == "override-config-service" assert settings.observability.otlp_endpoint == "http://override-collector:4317"