diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index f6e13cc..b14abc3 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -48,8 +48,8 @@ class CDEKClient: self._http_client = http_client self._auth_client = auth_client normalized_base_url = base_url.rstrip("/") - self._city_lookup_url = f"{normalized_base_url}/location/cities" - self._tariff_url = f"{base_url.rstrip('/')}/calculator/tarifflist" + self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities" + self._tariff_url = f"{normalized_base_url}/calculator/tarifflist" self._timeout_seconds = timeout_seconds self._retry_attempts = retry_attempts self._retry_backoff_seconds = retry_backoff_seconds @@ -103,8 +103,14 @@ class CDEKClient: return status_code == 429 or status_code >= 500 async def _build_payload(self, request: DeliveryRequest) -> dict[str, Any]: - from_city_code = await self._resolve_city_code(request.from_city) - to_city_code = await self._resolve_city_code(request.to_city) + from_city_code = await self._resolve_city_code( + request.from_city, + country_code=request.country_code, + ) + to_city_code = await self._resolve_city_code( + request.to_city, + country_code=request.country_code, + ) return { "from_location": {"code": from_city_code}, "to_location": {"code": to_city_code}, @@ -118,21 +124,29 @@ class CDEKClient: ], } - async def _resolve_city_code(self, city: str) -> int: + async def _resolve_city_code(self, city: str, *, country_code: str | None) -> int: normalized_city = city.strip().casefold() - cached_code = self._city_code_cache.get(normalized_city) + normalized_country_code = ( + country_code.strip().upper() if country_code is not None else "" + ) + cache_key = f"{normalized_country_code}:{normalized_city}" + cached_code = self._city_code_cache.get(cache_key) if cached_code is not None: return cached_code for attempt in range(self._retry_attempts + 1): try: token = await self._auth_client.get_access_token() + params = {"name": city} + if normalized_country_code: + params["country_code"] = normalized_country_code response = await self._http_client.get( self._city_lookup_url, - params={"city": city, "country_codes": "RU", "size": 1}, + params=params, headers={"Authorization": f"Bearer {token}"}, timeout=self._timeout_seconds, ) + log.info(response.json()) except (httpx.TimeoutException, httpx.TransportError) as exc: if attempt < self._retry_attempts: await self._sleep(self._retry_delay(attempt)) @@ -148,7 +162,6 @@ class CDEKClient: raise CDEKClientError( f"CDEK city lookup failed with status {response.status_code}." ) - log.info(response.json()) try: response.raise_for_status() body = response.json() @@ -158,11 +171,17 @@ class CDEKClient: else: raise CDEKClientError("CDEK city lookup failed unexpectedly.") - if not isinstance(body, list) or not body: + if isinstance(body, dict): + first_item = body + elif isinstance(body, list) and body: + first_item = body[0] + elif isinstance(body, list): raise CDEKRequestError( f"CDEK city lookup returned no matches for '{city}'." ) - first_item = body[0] + else: + raise CDEKClientError("CDEK city lookup response must be an object or array.") + if not isinstance(first_item, dict): raise CDEKClientError("CDEK city lookup response entry must be an object.") raw_city_code = first_item.get("code") @@ -173,7 +192,7 @@ class CDEKClient: except (TypeError, ValueError) as exc: raise CDEKClientError("CDEK city lookup response city code is invalid.") from exc - self._city_code_cache[normalized_city] = city_code + self._city_code_cache[cache_key] = city_code return city_code diff --git a/app/domain/price.py b/app/domain/price.py index 0a0b708..8f96572 100644 --- a/app/domain/price.py +++ b/app/domain/price.py @@ -18,6 +18,7 @@ class RequestLike(Protocol): entity: object from_city: object to_city: object + country_code: object weight_kg: object length_cm: object width_cm: object @@ -29,6 +30,7 @@ class NormalizedDeliveryRequest: entity: str from_city: str to_city: str + country_code: str | None weight_kg: Decimal length_cm: Decimal width_cm: Decimal @@ -58,6 +60,7 @@ def normalize_delivery_request( entity=str(_get_required_attr(request, "entity")), from_city=_normalize_text(_get_required_attr(request, "from_city")), to_city=_normalize_text(_get_required_attr(request, "to_city")), + country_code=_normalize_country_code(_get_optional_attr(request, "country_code")), weight_kg=_normalize_weight( _to_decimal(_get_required_attr(request, "weight_kg")), normalized_weight_scale, @@ -167,6 +170,15 @@ def _normalize_text(value: object) -> str: return str(value).strip() +def _normalize_country_code(value: object) -> str | None: + if value is _MISSING or value is None: + return None + normalized = str(value).strip().upper() + if not normalized: + return None + return normalized + + def _clamp_scale(scale: int) -> int: if scale < 0: return 0 diff --git a/app/schemas/request.py b/app/schemas/request.py index 193b6c3..1e75ca1 100644 --- a/app/schemas/request.py +++ b/app/schemas/request.py @@ -14,6 +14,7 @@ class DeliveryRequest(BaseModel): entity: DeliveryEntity from_city: str = Field(min_length=1) to_city: str = Field(min_length=1) + country_code: str | None = None weight_kg: float = Field(gt=0) length_cm: float = Field(gt=0) width_cm: float = Field(gt=0) diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 78888c9..b0886a2 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -142,6 +142,7 @@ class AggregatorService: entity=request.entity, from_city=request.from_city, to_city=request.to_city, + country_code=request.country_code, weight_kg=request.weight_kg, length_cm=request.length_cm, width_cm=request.width_cm, @@ -155,6 +156,7 @@ class AggregatorService: "entity": request.entity, "from_city": request.from_city, "to_city": request.to_city, + "country_code": request.country_code, "weight_kg": str(request.weight_kg), "length_cm": str(request.length_cm), "width_cm": str(request.width_cm), diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 790ebe7..3caf7dd 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -94,11 +94,11 @@ class RecordingHTTPClient: request = httpx.Request("GET", url, params=params) self.city_lookup_timeouts.append(timeout) assert headers == {"Authorization": "Bearer provider-token"} - city = params.get("city") if params else None + city = params.get("name") if params else None code_by_city = {"Moscow": 44, "Kazan": 424} return httpx.Response( 200, - json=[{"code": code_by_city.get(city, 44), "city": city}], + json={"code": code_by_city.get(city, 44), "full_name": city}, request=request, ) @@ -140,29 +140,32 @@ class RecordingHTTPClient: ) -def _make_request() -> DeliveryRequest: - return DeliveryRequest( - entity=DeliveryEntity.INDIVIDUAL, - from_city="Moscow", - to_city="Kazan", - weight_kg=1.2, - length_cm=20, - width_cm=10, - height_cm=5, - ) +def _make_request(**overrides: object) -> DeliveryRequest: + payload: dict[str, object] = { + "entity": DeliveryEntity.INDIVIDUAL, + "from_city": "Moscow", + "to_city": "Kazan", + "country_code": None, + "weight_kg": 1.2, + "length_cm": 20, + "width_cm": 10, + "height_cm": 5, + } + payload.update(overrides) + return DeliveryRequest(**payload) def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: request = _make_request() city_lookup_moscow = httpx.Response( 200, - json=[{"code": 44, "city": "Moscow"}], - request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + json={"code": 44, "full_name": "Moscow"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) city_lookup_kazan = httpx.Response( 200, - json=[{"code": 424, "city": "Kazan"}], - request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + json={"code": 424, "full_name": "Kazan"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) first_error = httpx.ReadTimeout( "read timeout", @@ -197,9 +200,7 @@ def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: assert len(http_client.calls) == 4 assert http_client.calls[0]["method"] == "GET" assert http_client.calls[0]["params"] == { - "city": "Moscow", - "country_codes": "RU", - "size": 1, + "name": "Moscow", } assert http_client.calls[1]["method"] == "GET" assert http_client.calls[2]["method"] == "POST" @@ -209,17 +210,50 @@ def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: assert sleep_calls == [0.5] +def test_cdek_client_passes_country_code_when_provided() -> None: + request = _make_request(country_code="KZ") + city_lookup_from = httpx.Response( + 200, + json={"code": 88, "full_name": "Moscow"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), + ) + city_lookup_to = httpx.Response( + 200, + json={"code": 99, "full_name": "Kazan"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), + ) + tariff_response = httpx.Response( + 200, + json={"tariff_codes": [{"delivery_sum": 500}]}, + request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), + ) + http_client = SequenceHTTPClient([city_lookup_from, city_lookup_to, tariff_response]) + client = CDEKClient( + http_client=http_client, # type: ignore[arg-type] + auth_client=StubAuthClient(), # type: ignore[arg-type] + base_url="https://api.cdek.test/v2", + retry_attempts=0, + ) + + asyncio.run(client.get_raw_price(request)) + + assert http_client.calls[0]["params"] == { + "name": "Moscow", + "country_code": "KZ", + } + + def test_cdek_client_raises_on_non_retriable_http_error() -> None: request = _make_request() city_lookup_moscow = httpx.Response( 200, - json=[{"code": 44, "city": "Moscow"}], - request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + json={"code": 44, "full_name": "Moscow"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) city_lookup_kazan = httpx.Response( 200, - json=[{"code": 424, "city": "Kazan"}], - request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + json={"code": 424, "full_name": "Kazan"}, + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) bad_response = httpx.Response( 400, @@ -245,7 +279,7 @@ def test_cdek_client_raises_when_city_code_not_found() -> None: city_lookup_empty = httpx.Response( 200, json=[], - request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) http_client = SequenceHTTPClient([city_lookup_empty]) client = CDEKClient( diff --git a/tests/controllers/v1/test_delivery.py b/tests/controllers/v1/test_delivery.py index 47ffa2f..47bcd2d 100644 --- a/tests/controllers/v1/test_delivery.py +++ b/tests/controllers/v1/test_delivery.py @@ -177,6 +177,7 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: entity=DeliveryEntity.INDIVIDUAL, from_city="Moscow", to_city="Kazan", + country_code=None, weight_kg=2.5, length_cm=30.0, width_cm=20.0, @@ -184,6 +185,28 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: ) +def test_post_delivery_price_accepts_optional_country_code() -> None: + service = StubAggregatorService(response=[]) + app = create_app() + _install_service_override(app, service) + payload = _valid_payload() + payload["country_code"] = "kz" + + 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/price", json=payload) + + response = asyncio.run(run_request()) + + assert response.status_code == 200 + assert len(service.calls) == 1 + assert service.calls[0].country_code == "kz" + + def test_post_delivery_price_rejects_invalid_payload() -> None: service = StubAggregatorService(response=[]) app = create_app() diff --git a/tests/domain/test_price.py b/tests/domain/test_price.py index c552ca0..1ed1794 100644 --- a/tests/domain/test_price.py +++ b/tests/domain/test_price.py @@ -28,6 +28,7 @@ def _make_request(**overrides) -> DeliveryRequest: "entity": DeliveryEntity.INDIVIDUAL, "from_city": " Moscow ", "to_city": " Saint Petersburg ", + "country_code": " ru ", "weight_kg": 1.235, "length_cm": 10.04, "width_cm": 20.05, @@ -45,15 +46,18 @@ def test_normalize_delivery_request_applies_rules_without_side_effects() -> None assert normalized is not request assert normalized.from_city == "Moscow" assert normalized.to_city == "Saint Petersburg" + assert normalized.country_code == "RU" assert normalized.weight_kg == Decimal("1.24") assert normalized.length_cm == Decimal("10.0") assert normalized.width_cm == Decimal("20.1") assert normalized.height_cm == Decimal("30.1") assert request.from_city == " Moscow " + assert request.country_code == " ru " def test_normalize_delivery_request_handles_normalization_boundaries() -> None: request = _make_request( + country_code=" ", weight_kg=0.0001, length_cm=0.01, width_cm=0.04, @@ -68,6 +72,7 @@ def test_normalize_delivery_request_handles_normalization_boundaries() -> None: assert low_scale.width_cm == Decimal("0.1") assert low_scale.height_cm == Decimal("0.2") assert high_scale.weight_kg == Decimal("0.01") + assert low_scale.country_code is None def test_filter_valid_prices_handles_empty_input() -> None: diff --git a/tests/services/test_aggregator.py b/tests/services/test_aggregator.py index 9d68b3d..6ea0cf2 100644 --- a/tests/services/test_aggregator.py +++ b/tests/services/test_aggregator.py @@ -58,16 +58,19 @@ class StubCache: self._storage[key] = value -def _make_request() -> DeliveryRequest: - return DeliveryRequest( - entity=DeliveryEntity.INDIVIDUAL, - from_city="Moscow", - to_city="Kazan", - weight_kg=1.234, - length_cm=30.0, - width_cm=20.0, - height_cm=10.0, - ) +def _make_request(**overrides: object) -> DeliveryRequest: + payload: dict[str, object] = { + "entity": DeliveryEntity.INDIVIDUAL, + "from_city": "Moscow", + "to_city": "Kazan", + "country_code": None, + "weight_kg": 1.234, + "length_cm": 30.0, + "width_cm": 20.0, + "height_cm": 10.0, + } + payload.update(overrides) + return DeliveryRequest(**payload) def _make_price(provider: str, price: str) -> DeliveryPrice: @@ -96,6 +99,17 @@ def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None: assert [ttl for _, _, ttl in cache.set_calls] == [111, 222] +def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> None: + provider = StubProvider(name="cdek", response=_make_price("cdek", "150.00")) + service = AggregatorService([provider], cache=StubCache()) + + result = asyncio.run(service.get_all_prices(_make_request(country_code=" kz "))) + + assert [price.provider for price in result] == ["cdek"] + assert len(provider.calls) == 1 + assert provider.calls[0].country_code == "KZ" + + def test_get_all_prices_partial_failure_excludes_failed_provider() -> None: provider_ok = StubProvider(name="ok", response=_make_price("ok", "150.00")) provider_failed = StubProvider(name="failed", error=RuntimeError("provider unavailable"))