017 fix returning all tariffs
This commit is contained in:
@@ -10,8 +10,8 @@ class DeliveryProvider(ABC):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
|
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||||
"""Fetch one quote from an external provider."""
|
"""Fetch provider tariffs for a delivery request."""
|
||||||
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class CDEKClient:
|
|||||||
|
|
||||||
if not isinstance(raw_payload, dict):
|
if not isinstance(raw_payload, dict):
|
||||||
raise CDEKClientError("CDEK tariff payload must be a JSON object.")
|
raise CDEKClientError("CDEK tariff payload must be a JSON object.")
|
||||||
|
log.info(f"Founded {len(response.json())} tarrifs")
|
||||||
return raw_payload
|
return raw_payload
|
||||||
|
|
||||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||||
@@ -201,7 +202,7 @@ class CDEKClient:
|
|||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=self._timeout_seconds,
|
timeout=self._timeout_seconds,
|
||||||
)
|
)
|
||||||
log.info(response.json())
|
log.info(f"Founded cities: {response.json()}")
|
||||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||||
if attempt < self._retry_attempts:
|
if attempt < self._retry_attempts:
|
||||||
await self._sleep(self._retry_delay(attempt))
|
await self._sleep(self._retry_delay(attempt))
|
||||||
@@ -282,7 +283,7 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
)
|
)
|
||||||
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
|
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
|
||||||
|
|
||||||
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
|
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||||
raw_payload = await self._client.get_raw_price(request)
|
raw_payload = await self._client.get_raw_price(request)
|
||||||
return map_cdek_response(raw_payload)
|
return map_cdek_response(raw_payload)
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ class CDEKMappingError(ValueError):
|
|||||||
"""Raised when CDEK response cannot be mapped."""
|
"""Raised when CDEK response cannot be mapped."""
|
||||||
|
|
||||||
|
|
||||||
def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice:
|
def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
|
||||||
tariff = _get_first_tariff(payload)
|
tariffs = _get_tariffs(payload)
|
||||||
|
payload_currency = payload.get("currency")
|
||||||
|
return [
|
||||||
|
_map_tariff(tariff, payload_currency=payload_currency)
|
||||||
|
for tariff in tariffs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_tariff(
|
||||||
|
tariff: dict[str, Any],
|
||||||
|
*,
|
||||||
|
payload_currency: object,
|
||||||
|
) -> DeliveryPrice:
|
||||||
service_name = tariff.get("tariff_name") or tariff.get("tariff_code")
|
service_name = tariff.get("tariff_name") or tariff.get("tariff_code")
|
||||||
if service_name is None:
|
if service_name is None:
|
||||||
raise CDEKMappingError("CDEK tariff_name is missing.")
|
raise CDEKMappingError("CDEK tariff_name is missing.")
|
||||||
@@ -26,7 +37,7 @@ def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice:
|
|||||||
raise CDEKMappingError("CDEK period_min is missing.")
|
raise CDEKMappingError("CDEK period_min is missing.")
|
||||||
period_max = tariff.get("period_max", period_min)
|
period_max = tariff.get("period_max", period_min)
|
||||||
|
|
||||||
raw_currency = tariff.get("currency") or payload.get("currency") or "RUB"
|
raw_currency = tariff.get("currency") or payload_currency or "RUB"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return DeliveryPrice(
|
return DeliveryPrice(
|
||||||
@@ -41,11 +52,13 @@ def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice:
|
|||||||
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
||||||
|
|
||||||
|
|
||||||
def _get_first_tariff(payload: dict[str, Any]) -> dict[str, Any]:
|
def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
tariff_codes = payload.get("tariff_codes")
|
tariff_codes = payload.get("tariff_codes")
|
||||||
if not isinstance(tariff_codes, list) or not tariff_codes:
|
if not isinstance(tariff_codes, list) or not tariff_codes:
|
||||||
raise CDEKMappingError("CDEK response must include non-empty tariff_codes.")
|
raise CDEKMappingError("CDEK response must include non-empty tariff_codes.")
|
||||||
tariff = tariff_codes[0]
|
tariffs: list[dict[str, Any]] = []
|
||||||
if not isinstance(tariff, dict):
|
for tariff in tariff_codes:
|
||||||
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
if not isinstance(tariff, dict):
|
||||||
return tariff
|
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
||||||
|
tariffs.append(tariff)
|
||||||
|
return tariffs
|
||||||
|
|||||||
+23
-16
@@ -83,7 +83,7 @@ class AggregatorService:
|
|||||||
|
|
||||||
provider_results = await asyncio.gather(
|
provider_results = await asyncio.gather(
|
||||||
*(
|
*(
|
||||||
self._get_provider_price(
|
self._get_provider_prices(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
request=provider_request,
|
request=provider_request,
|
||||||
cache_key=self._build_cache_key(
|
cache_key=self._build_cache_key(
|
||||||
@@ -98,8 +98,9 @@ class AggregatorService:
|
|||||||
|
|
||||||
successful_results = [
|
successful_results = [
|
||||||
price
|
price
|
||||||
for price in provider_results
|
for provider_prices in provider_results
|
||||||
if isinstance(price, DeliveryPrice)
|
if isinstance(provider_prices, list)
|
||||||
|
for price in provider_prices
|
||||||
]
|
]
|
||||||
provider_errors = [
|
provider_errors = [
|
||||||
error
|
error
|
||||||
@@ -142,26 +143,26 @@ class AggregatorService:
|
|||||||
from_attributes=True,
|
from_attributes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _get_provider_price(
|
async def _get_provider_prices(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
provider: DeliveryProvider,
|
provider: DeliveryProvider,
|
||||||
request: DeliveryRequest,
|
request: DeliveryRequest,
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
) -> DeliveryPrice:
|
) -> list[DeliveryPrice]:
|
||||||
cached_price = await self._get_cached_price(cache_key)
|
cached_prices = await self._get_cached_prices(cache_key)
|
||||||
if cached_price is not None:
|
if cached_prices is not None:
|
||||||
return cached_price
|
return cached_prices
|
||||||
|
|
||||||
fresh_price = await provider.get_price(request)
|
fresh_prices = await provider.get_prices(request)
|
||||||
await self._set_cached_price(
|
await self._set_cached_prices(
|
||||||
cache_key,
|
cache_key,
|
||||||
fresh_price,
|
fresh_prices,
|
||||||
ttl=getattr(provider, "cache_ttl_seconds", None),
|
ttl=getattr(provider, "cache_ttl_seconds", None),
|
||||||
)
|
)
|
||||||
return fresh_price
|
return fresh_prices
|
||||||
|
|
||||||
async def _get_cached_price(self, cache_key: str) -> DeliveryPrice | None:
|
async def _get_cached_prices(self, cache_key: str) -> list[DeliveryPrice] | None:
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -171,14 +172,14 @@ class AggregatorService:
|
|||||||
if payload is None:
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return self._coerce_delivery_price(payload)
|
return self._coerce_delivery_prices(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _set_cached_price(
|
async def _set_cached_prices(
|
||||||
self,
|
self,
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
payload: DeliveryPrice,
|
payload: list[DeliveryPrice],
|
||||||
*,
|
*,
|
||||||
ttl: int | None,
|
ttl: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -227,3 +228,9 @@ class AggregatorService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_delivery_price(value: object) -> DeliveryPrice:
|
def _coerce_delivery_price(value: object) -> DeliveryPrice:
|
||||||
return DeliveryPrice.model_validate(value, from_attributes=True)
|
return DeliveryPrice.model_validate(value, from_attributes=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _coerce_delivery_prices(cls, value: object) -> list[DeliveryPrice]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise TypeError("Cached delivery prices must be a list.")
|
||||||
|
return [cls._coerce_delivery_price(item) for item in value]
|
||||||
|
|||||||
@@ -71,3 +71,8 @@ Content-Type: application/json
|
|||||||
> {%
|
> {%
|
||||||
client.global.set("order_uuid", response.body.entity.uuid);
|
client.global.set("order_uuid", response.body.entity.uuid);
|
||||||
%}
|
%}
|
||||||
|
|
||||||
|
### 5. Список доступных тарифов
|
||||||
|
GET {{base_url}}/v2/calculator/alltariffs
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
X-User-Lang: rus
|
||||||
|
|||||||
+4
-4
@@ -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-15T20:06:00+00:00`
|
> Generated at (UTC): `2026-03-15T21:04:41+00:00`
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
|
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
|
||||||
| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` |
|
| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` |
|
||||||
| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` |
|
| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` |
|
||||||
| 017 | TODO | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` |
|
| 017 | DONE | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` |
|
||||||
| 018 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/018_add_cdek_order_creation_endpoint.md` |
|
| 018 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/018_add_cdek_order_creation_endpoint.md` |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Total: **19**
|
- Total: **19**
|
||||||
- TODO: **2**
|
- TODO: **1**
|
||||||
- DONE: **17**
|
- DONE: **18**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: 017
|
id: 017
|
||||||
title: Return all tariffs from CDEK price calculation
|
title: Return all tariffs from CDEK price calculation
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-03-14
|
created: 2026-03-14
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -134,6 +135,13 @@ class RecordingHTTPClient:
|
|||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"period_min": 1,
|
"period_min": 1,
|
||||||
"period_max": 2,
|
"period_max": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tariff_name": "Economy",
|
||||||
|
"delivery_sum": "499.00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"period_min": 3,
|
||||||
|
"period_max": 5,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -341,13 +349,15 @@ observability:
|
|||||||
adapter_config=settings.adapter,
|
adapter_config=settings.adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(provider.get_price(_make_request()))
|
result = asyncio.run(provider.get_prices(_make_request()))
|
||||||
|
|
||||||
assert provider.cache_ttl_seconds == 777
|
assert provider.cache_ttl_seconds == 777
|
||||||
assert http_client.auth_timeouts == [7.5]
|
assert http_client.auth_timeouts == [7.5]
|
||||||
assert http_client.city_lookup_timeouts == [7.5, 7.5]
|
assert http_client.city_lookup_timeouts == [7.5, 7.5]
|
||||||
assert http_client.tariff_timeouts == [7.5]
|
assert http_client.tariff_timeouts == [7.5]
|
||||||
assert result.provider == "cdek"
|
assert [price.provider for price in result] == ["cdek", "cdek"]
|
||||||
|
assert [price.service_name for price in result] == ["Express", "Economy"]
|
||||||
|
assert [price.price for price in result] == [Decimal("899.00"), Decimal("499.00")]
|
||||||
|
|
||||||
|
|
||||||
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
||||||
@@ -361,9 +371,10 @@ def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
|||||||
adapter_config=adapter_config,
|
adapter_config=adapter_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(provider.get_price(_make_request()))
|
result = asyncio.run(provider.get_prices(_make_request()))
|
||||||
|
|
||||||
assert provider.cache_ttl_seconds == 900
|
assert provider.cache_ttl_seconds == 900
|
||||||
assert http_client.auth_timeouts == [10.0]
|
assert http_client.auth_timeouts == [10.0]
|
||||||
assert http_client.city_lookup_timeouts == [10.0, 10.0]
|
assert http_client.city_lookup_timeouts == [10.0, 10.0]
|
||||||
assert http_client.tariff_timeouts == [10.0]
|
assert http_client.tariff_timeouts == [10.0]
|
||||||
|
assert len(result) == 2
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import pytest
|
|||||||
from app.adapters.delivery_providers.cdek.mapper import CDEKMappingError, map_cdek_response
|
from app.adapters.delivery_providers.cdek.mapper import CDEKMappingError, map_cdek_response
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_response_maps_first_tariff_to_unified_model() -> None:
|
def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
||||||
payload = {
|
payload = {
|
||||||
|
"currency": "usd",
|
||||||
"tariff_codes": [
|
"tariff_codes": [
|
||||||
{
|
{
|
||||||
"tariff_name": "Express",
|
"tariff_name": "Express",
|
||||||
@@ -14,18 +15,24 @@ def test_map_cdek_response_maps_first_tariff_to_unified_model() -> None:
|
|||||||
"currency": "rub",
|
"currency": "rub",
|
||||||
"period_min": 2,
|
"period_min": 2,
|
||||||
"period_max": 4,
|
"period_max": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tariff_code": 136,
|
||||||
|
"delivery_sum": "999.00",
|
||||||
|
"period_min": 5,
|
||||||
|
"period_max": 7,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = map_cdek_response(payload)
|
result = map_cdek_response(payload)
|
||||||
|
|
||||||
assert result.provider == "cdek"
|
assert [price.provider for price in result] == ["cdek", "cdek"]
|
||||||
assert result.service_name == "Express"
|
assert [price.service_name for price in result] == ["Express", "136"]
|
||||||
assert result.price == Decimal("1234.50")
|
assert [price.price for price in result] == [Decimal("1234.50"), Decimal("999.00")]
|
||||||
assert result.currency == "RUB"
|
assert [price.currency for price in result] == ["RUB", "USD"]
|
||||||
assert result.delivery_days_min == 2
|
assert [price.delivery_days_min for price in result] == [2, 5]
|
||||||
assert result.delivery_days_max == 4
|
assert [price.delivery_days_max for price in result] == [4, 7]
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
||||||
|
|||||||
@@ -64,16 +64,26 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[DeliveryRequest] = []
|
self.calls: list[DeliveryRequest] = []
|
||||||
|
|
||||||
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
|
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||||
self.calls.append(request)
|
self.calls.append(request)
|
||||||
return DeliveryPrice(
|
return [
|
||||||
provider=self.name,
|
DeliveryPrice(
|
||||||
service_name="stub-service",
|
provider=self.name,
|
||||||
price=Decimal("123.50"),
|
service_name="slow",
|
||||||
currency="RUB",
|
price=Decimal("223.50"),
|
||||||
delivery_days_min=2,
|
currency="RUB",
|
||||||
delivery_days_max=3,
|
delivery_days_min=4,
|
||||||
)
|
delivery_days_max=5,
|
||||||
|
),
|
||||||
|
DeliveryPrice(
|
||||||
|
provider=self.name,
|
||||||
|
service_name="fast",
|
||||||
|
price=Decimal("123.50"),
|
||||||
|
currency="RUB",
|
||||||
|
delivery_days_min=2,
|
||||||
|
delivery_days_max=3,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
class StubCDEKProvider:
|
class StubCDEKProvider:
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -135,11 +145,19 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
assert first_response.json() == [
|
assert first_response.json() == [
|
||||||
{
|
{
|
||||||
"provider": "stub-provider",
|
"provider": "stub-provider",
|
||||||
"service_name": "stub-service",
|
"service_name": "fast",
|
||||||
"price": "124",
|
"price": "124",
|
||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"delivery_days_min": 2,
|
"delivery_days_min": 2,
|
||||||
"delivery_days_max": 3,
|
"delivery_days_max": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "stub-provider",
|
||||||
|
"service_name": "slow",
|
||||||
|
"price": "224",
|
||||||
|
"currency": "RUB",
|
||||||
|
"delivery_days_min": 4,
|
||||||
|
"delivery_days_max": 5,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert second_response.json() == first_response.json()
|
assert second_response.json() == first_response.json()
|
||||||
@@ -148,15 +166,25 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
|
|
||||||
|
|
||||||
def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
|
def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
|
||||||
expected_price = DeliveryPrice(
|
expected_prices = [
|
||||||
provider="cdek",
|
DeliveryPrice(
|
||||||
service_name="express",
|
provider="cdek",
|
||||||
price=Decimal("100.50"),
|
service_name="economy",
|
||||||
currency="RUB",
|
price=Decimal("150.00"),
|
||||||
delivery_days_min=1,
|
currency="RUB",
|
||||||
delivery_days_max=2,
|
delivery_days_min=3,
|
||||||
)
|
delivery_days_max=5,
|
||||||
service = StubAggregatorService(response=[expected_price])
|
),
|
||||||
|
DeliveryPrice(
|
||||||
|
provider="cdek",
|
||||||
|
service_name="express",
|
||||||
|
price=Decimal("100.50"),
|
||||||
|
currency="RUB",
|
||||||
|
delivery_days_min=1,
|
||||||
|
delivery_days_max=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = StubAggregatorService(response=expected_prices)
|
||||||
app = create_app()
|
app = create_app()
|
||||||
_install_service_override(app, service)
|
_install_service_override(app, service)
|
||||||
|
|
||||||
@@ -171,7 +199,7 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
|
|||||||
response = asyncio.run(run_request())
|
response = asyncio.run(run_request())
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == [expected_price.model_dump(mode="json")]
|
assert response.json() == [price.model_dump(mode="json") for price in expected_prices]
|
||||||
assert len(service.calls) == 1
|
assert len(service.calls) == 1
|
||||||
assert service.calls[0] == DeliveryRequest(
|
assert service.calls[0] == DeliveryRequest(
|
||||||
entity=DeliveryEntity.INDIVIDUAL,
|
entity=DeliveryEntity.INDIVIDUAL,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class StubProvider:
|
|||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
response: DeliveryPrice | None = None,
|
response: list[DeliveryPrice] | None = None,
|
||||||
error: Exception | None = None,
|
error: Exception | None = None,
|
||||||
cache_ttl_seconds: int = 900,
|
cache_ttl_seconds: int = 900,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -27,7 +27,7 @@ class StubProvider:
|
|||||||
self.cache_ttl_seconds = cache_ttl_seconds
|
self.cache_ttl_seconds = cache_ttl_seconds
|
||||||
self.calls: list[DeliveryRequest] = []
|
self.calls: list[DeliveryRequest] = []
|
||||||
|
|
||||||
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
|
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||||
self.calls.append(request)
|
self.calls.append(request)
|
||||||
if self._error is not None:
|
if self._error is not None:
|
||||||
raise self._error
|
raise self._error
|
||||||
@@ -73,10 +73,10 @@ def _make_request(**overrides: object) -> DeliveryRequest:
|
|||||||
return DeliveryRequest(**payload)
|
return DeliveryRequest(**payload)
|
||||||
|
|
||||||
|
|
||||||
def _make_price(provider: str, price: str) -> DeliveryPrice:
|
def _make_price(provider: str, price: str, *, service_name: str = "standard") -> DeliveryPrice:
|
||||||
return DeliveryPrice(
|
return DeliveryPrice(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
service_name="standard",
|
service_name=service_name,
|
||||||
price=Decimal(price),
|
price=Decimal(price),
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
delivery_days_min=2,
|
delivery_days_min=2,
|
||||||
@@ -85,8 +85,19 @@ def _make_price(provider: str, price: str) -> DeliveryPrice:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None:
|
def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None:
|
||||||
provider_a = StubProvider(name="a", response=_make_price("a", "300.49"), cache_ttl_seconds=111)
|
provider_a = StubProvider(
|
||||||
provider_b = StubProvider(name="b", response=_make_price("b", "100.40"), cache_ttl_seconds=222)
|
name="a",
|
||||||
|
response=[
|
||||||
|
_make_price("a", "300.49", service_name="economy"),
|
||||||
|
_make_price("a", "200.49", service_name="express"),
|
||||||
|
],
|
||||||
|
cache_ttl_seconds=111,
|
||||||
|
)
|
||||||
|
provider_b = StubProvider(
|
||||||
|
name="b",
|
||||||
|
response=[_make_price("b", "100.40")],
|
||||||
|
cache_ttl_seconds=222,
|
||||||
|
)
|
||||||
cache = StubCache()
|
cache = StubCache()
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
[provider_a, provider_b],
|
[provider_a, provider_b],
|
||||||
@@ -96,16 +107,18 @@ def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None:
|
|||||||
|
|
||||||
result = asyncio.run(service.get_all_prices(_make_request()))
|
result = asyncio.run(service.get_all_prices(_make_request()))
|
||||||
|
|
||||||
assert [price.provider for price in result] == ["b", "a"]
|
assert [price.provider for price in result] == ["b", "a", "a"]
|
||||||
assert [price.price for price in result] == [Decimal("110"), Decimal("331")]
|
assert [price.service_name for price in result] == ["standard", "express", "economy"]
|
||||||
|
assert [price.price for price in result] == [Decimal("110"), Decimal("221"), Decimal("331")]
|
||||||
assert len(provider_a.calls) == 1
|
assert len(provider_a.calls) == 1
|
||||||
assert len(provider_b.calls) == 1
|
assert len(provider_b.calls) == 1
|
||||||
assert len(cache.get_calls) == 2
|
assert len(cache.get_calls) == 2
|
||||||
|
assert [len(payload) for _, payload, _ in cache.set_calls] == [2, 1]
|
||||||
assert [ttl for _, _, ttl in cache.set_calls] == [111, 222]
|
assert [ttl for _, _, ttl in cache.set_calls] == [111, 222]
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> None:
|
def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> None:
|
||||||
provider = StubProvider(name="cdek", response=_make_price("cdek", "150.00"))
|
provider = StubProvider(name="cdek", response=[_make_price("cdek", "150.00")])
|
||||||
service = AggregatorService([provider], cache=StubCache())
|
service = AggregatorService([provider], cache=StubCache())
|
||||||
|
|
||||||
result = asyncio.run(service.get_all_prices(_make_request(country_code=" kz ")))
|
result = asyncio.run(service.get_all_prices(_make_request(country_code=" kz ")))
|
||||||
@@ -116,13 +129,20 @@ def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_get_all_prices_partial_failure_excludes_failed_provider() -> None:
|
def test_get_all_prices_partial_failure_excludes_failed_provider() -> None:
|
||||||
provider_ok = StubProvider(name="ok", response=_make_price("ok", "150.00"))
|
provider_ok = StubProvider(
|
||||||
|
name="ok",
|
||||||
|
response=[
|
||||||
|
_make_price("ok", "150.00", service_name="economy"),
|
||||||
|
_make_price("ok", "120.00", service_name="express"),
|
||||||
|
],
|
||||||
|
)
|
||||||
provider_failed = StubProvider(name="failed", error=RuntimeError("provider unavailable"))
|
provider_failed = StubProvider(name="failed", error=RuntimeError("provider unavailable"))
|
||||||
service = AggregatorService([provider_ok, provider_failed], cache=StubCache())
|
service = AggregatorService([provider_ok, provider_failed], cache=StubCache())
|
||||||
|
|
||||||
result = asyncio.run(service.get_all_prices(_make_request()))
|
result = asyncio.run(service.get_all_prices(_make_request()))
|
||||||
|
|
||||||
assert [price.provider for price in result] == ["ok"]
|
assert [price.provider for price in result] == ["ok", "ok"]
|
||||||
|
assert [price.service_name for price in result] == ["express", "economy"]
|
||||||
assert len(provider_ok.calls) == 1
|
assert len(provider_ok.calls) == 1
|
||||||
assert len(provider_failed.calls) == 1
|
assert len(provider_failed.calls) == 1
|
||||||
|
|
||||||
@@ -153,9 +173,12 @@ def test_get_all_prices_raises_invalid_request_for_provider_request_errors() ->
|
|||||||
|
|
||||||
|
|
||||||
def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
||||||
cached_payload = _make_price("cdek", "100.40").model_dump(mode="json")
|
cached_payload = [
|
||||||
|
_make_price("cdek", "100.40", service_name="express").model_dump(mode="json"),
|
||||||
|
_make_price("cdek", "200.40", service_name="economy").model_dump(mode="json"),
|
||||||
|
]
|
||||||
cache = StubCache(forced_get_value=cached_payload)
|
cache = StubCache(forced_get_value=cached_payload)
|
||||||
provider = StubProvider(name="cdek", response=_make_price("cdek", "150.00"))
|
provider = StubProvider(name="cdek", response=[_make_price("cdek", "150.00")])
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
[provider],
|
[provider],
|
||||||
cache=cache,
|
cache=cache,
|
||||||
@@ -164,16 +187,23 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
|||||||
|
|
||||||
result = asyncio.run(service.get_all_prices(_make_request()))
|
result = asyncio.run(service.get_all_prices(_make_request()))
|
||||||
|
|
||||||
assert [price.provider for price in result] == ["cdek"]
|
assert [price.provider for price in result] == ["cdek", "cdek"]
|
||||||
assert result[0].price == Decimal("110")
|
assert [price.service_name for price in result] == ["express", "economy"]
|
||||||
|
assert [price.price for price in result] == [Decimal("110"), Decimal("220")]
|
||||||
assert provider.calls == []
|
assert provider.calls == []
|
||||||
assert len(cache.get_calls) == 1
|
assert len(cache.get_calls) == 1
|
||||||
assert cache.set_calls == []
|
assert cache.set_calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> None:
|
def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> None:
|
||||||
provider_a = StubProvider(name="a", response=_make_price("a", "300.00"))
|
provider_a = StubProvider(
|
||||||
provider_b = StubProvider(name="b", response=_make_price("b", "100.00"))
|
name="a",
|
||||||
|
response=[
|
||||||
|
_make_price("a", "300.00", service_name="economy"),
|
||||||
|
_make_price("a", "200.00", service_name="express"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
provider_b = StubProvider(name="b", response=[_make_price("b", "100.00")])
|
||||||
delegated_inputs: list[list[DeliveryPrice]] = []
|
delegated_inputs: list[list[DeliveryPrice]] = []
|
||||||
delegated_multipliers: list[Decimal] = []
|
delegated_multipliers: list[Decimal] = []
|
||||||
|
|
||||||
@@ -192,6 +222,11 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non
|
|||||||
|
|
||||||
result = asyncio.run(service.get_all_prices(_make_request()))
|
result = asyncio.run(service.get_all_prices(_make_request()))
|
||||||
|
|
||||||
assert [price.provider for price in delegated_inputs[0]] == ["a", "b"]
|
assert [price.provider for price in delegated_inputs[0]] == ["a", "a", "b"]
|
||||||
|
assert [price.service_name for price in delegated_inputs[0]] == [
|
||||||
|
"economy",
|
||||||
|
"express",
|
||||||
|
"standard",
|
||||||
|
]
|
||||||
assert delegated_multipliers == [Decimal("1.23")]
|
assert delegated_multipliers == [Decimal("1.23")]
|
||||||
assert [price.provider for price in result] == ["a"]
|
assert [price.provider for price in result] == ["a"]
|
||||||
|
|||||||
Reference in New Issue
Block a user