352 lines
11 KiB
Python
352 lines
11 KiB
Python
import asyncio
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.adapters.delivery_providers.cdek.client import (
|
|
CDEKClient,
|
|
CDEKClientError,
|
|
CDEKProvider,
|
|
CDEKRequestError,
|
|
)
|
|
from app.cities import cities_map
|
|
from app import config as config_module
|
|
from app.config import AdapterConfig, Settings
|
|
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
|
|
|
|
|
class StubAuthClient:
|
|
async def get_access_token(self) -> str:
|
|
return "test-token"
|
|
|
|
|
|
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,
|
|
data: 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,
|
|
"data": data,
|
|
"params": None,
|
|
"headers": headers,
|
|
"timeout": timeout,
|
|
}
|
|
)
|
|
result = self._next_result()
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
return result
|
|
|
|
async def get(
|
|
self,
|
|
url: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
raise AssertionError("CDEK city lookup API must not be called.")
|
|
|
|
|
|
class RecordingHTTPClient:
|
|
def __init__(self) -> None:
|
|
self.auth_timeouts: list[float | None] = []
|
|
self.tariff_timeouts: list[float | None] = []
|
|
self.tariff_payloads: list[dict[str, Any]] = []
|
|
|
|
async def get(
|
|
self,
|
|
url: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
raise AssertionError("CDEK city lookup API must not be called.")
|
|
|
|
async def post(
|
|
self,
|
|
url: str,
|
|
*,
|
|
json: dict[str, Any] | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
request = httpx.Request("POST", url)
|
|
if url.endswith("/oauth/token"):
|
|
self.auth_timeouts.append(timeout)
|
|
return httpx.Response(
|
|
200,
|
|
json={"access_token": "provider-token", "expires_in": 3600},
|
|
request=request,
|
|
)
|
|
self.tariff_timeouts.append(timeout)
|
|
assert headers == {"Authorization": "Bearer provider-token"}
|
|
assert data is None
|
|
assert json is not None
|
|
self.tariff_payloads.append(json)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"tariff_codes": [
|
|
{
|
|
"tariff_name": "Express",
|
|
"delivery_sum": "899.00",
|
|
"currency": "RUB",
|
|
"period_min": 1,
|
|
"period_max": 2,
|
|
},
|
|
{
|
|
"tariff_name": "Economy",
|
|
"delivery_sum": "499.00",
|
|
"currency": "RUB",
|
|
"period_min": 3,
|
|
"period_max": 5,
|
|
}
|
|
]
|
|
},
|
|
request=request,
|
|
)
|
|
|
|
|
|
def _make_request(**overrides: object) -> DeliveryCalculationRequest:
|
|
payload: dict[str, object] = {
|
|
"entity": DeliveryEntity.INDIVIDUAL,
|
|
"from_city": 1,
|
|
"to_city": 2,
|
|
"weight_kg": 1.2,
|
|
"length_cm": 20,
|
|
"width_cm": 10,
|
|
"height_cm": 5,
|
|
}
|
|
payload.update(overrides)
|
|
return DeliveryCalculationRequest(**payload)
|
|
|
|
|
|
def _cdek_code(city_id: str) -> int:
|
|
city_entry = cities_map[city_id]
|
|
cdek_data = city_entry["cdek"]
|
|
assert isinstance(cdek_data, dict)
|
|
return int(cdek_data["code"])
|
|
|
|
|
|
def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None:
|
|
request = _make_request()
|
|
first_error = httpx.ReadTimeout(
|
|
"read timeout",
|
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
|
)
|
|
second_response = httpx.Response(
|
|
200,
|
|
json={"tariff_codes": [{"delivery_sum": 500}]},
|
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
|
)
|
|
http_client = SequenceHTTPClient([first_error, second_response])
|
|
sleep_calls: list[float] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
sleep_calls.append(seconds)
|
|
|
|
client = CDEKClient(
|
|
http_client=http_client, # type: ignore[arg-type]
|
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
|
base_url="https://api.cdek.test/v2",
|
|
timeout_seconds=10.0,
|
|
retry_attempts=1,
|
|
retry_backoff_seconds=0.5,
|
|
sleep=fake_sleep,
|
|
)
|
|
|
|
result = asyncio.run(client.get_raw_price(request))
|
|
|
|
assert result == {"tariff_codes": [{"delivery_sum": 500}]}
|
|
assert len(http_client.calls) == 2
|
|
assert http_client.calls[0]["method"] == "POST"
|
|
assert http_client.calls[0]["json"]["from_location"] == {"code": _cdek_code("1")}
|
|
assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")}
|
|
assert http_client.calls[0]["timeout"] == 10.0
|
|
assert sleep_calls == [0.5]
|
|
|
|
|
|
def test_cdek_client_builds_payload_from_cities_map_codes() -> None:
|
|
request = _make_request()
|
|
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([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]["json"]["from_location"] == {"code": _cdek_code("1")}
|
|
assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")}
|
|
assert http_client.calls[0]["json"]["packages"] == [
|
|
{"weight": 1200, "length": 20, "width": 10, "height": 5}
|
|
]
|
|
assert http_client.calls[0]["params"] is None
|
|
|
|
|
|
def test_cdek_client_raises_when_city_mapping_is_missing() -> None:
|
|
request = _make_request(from_city=999999)
|
|
http_client = SequenceHTTPClient([])
|
|
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,
|
|
)
|
|
|
|
with pytest.raises(CDEKRequestError, match="city id 999999"):
|
|
asyncio.run(client.get_raw_price(request))
|
|
|
|
assert http_client.calls == []
|
|
|
|
|
|
def test_cdek_client_raises_when_cdek_code_is_not_configured() -> None:
|
|
request = _make_request(to_city=3)
|
|
http_client = SequenceHTTPClient([])
|
|
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,
|
|
)
|
|
|
|
with pytest.raises(CDEKRequestError, match="city id 3"):
|
|
asyncio.run(client.get_raw_price(request))
|
|
|
|
assert http_client.calls == []
|
|
|
|
|
|
def test_cdek_client_raises_on_non_retriable_http_error() -> None:
|
|
request = _make_request()
|
|
bad_response = httpx.Response(
|
|
400,
|
|
json={"error": "bad request"},
|
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
|
)
|
|
http_client = SequenceHTTPClient([bad_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=3,
|
|
)
|
|
|
|
with pytest.raises(CDEKClientError):
|
|
asyncio.run(client.get_raw_price(request))
|
|
|
|
assert len(http_client.calls) == 1
|
|
|
|
|
|
def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text(
|
|
"""
|
|
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.test/v2"
|
|
cdek_client_id: "yaml-id"
|
|
cdek_client_secret: "yaml-secret"
|
|
cdek_retry_attempts: 0
|
|
cdek_retry_backoff_seconds: 0.1
|
|
cdek_timeout_seconds: 7.5
|
|
cdek_cache_ttl_seconds: 777
|
|
|
|
observability:
|
|
enabled: false
|
|
service_name: "cdek-adapter-test-service"
|
|
otlp_endpoint: "http://collector:4317"
|
|
otlp_insecure: true
|
|
""".strip(),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
|
|
settings = Settings()
|
|
http_client = RecordingHTTPClient()
|
|
provider = CDEKProvider.from_adapter_config(
|
|
http_client=http_client, # type: ignore[arg-type]
|
|
adapter_config=settings.adapter,
|
|
)
|
|
|
|
result = asyncio.run(provider.get_prices(_make_request()))
|
|
|
|
assert provider.cache_ttl_seconds == 777
|
|
assert http_client.auth_timeouts == [7.5]
|
|
assert http_client.tariff_timeouts == [7.5]
|
|
assert http_client.tariff_payloads == [
|
|
{
|
|
"type": 2,
|
|
"from_location": {"code": _cdek_code("1")},
|
|
"to_location": {"code": _cdek_code("2")},
|
|
"packages": [
|
|
{"weight": 1200, "length": 20, "width": 10, "height": 5}
|
|
],
|
|
}
|
|
]
|
|
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:
|
|
adapter_config = AdapterConfig(
|
|
cdek_client_id="default-id",
|
|
cdek_client_secret="default-secret",
|
|
)
|
|
http_client = RecordingHTTPClient()
|
|
provider = CDEKProvider.from_adapter_config(
|
|
http_client=http_client, # type: ignore[arg-type]
|
|
adapter_config=adapter_config,
|
|
)
|
|
|
|
result = asyncio.run(provider.get_prices(_make_request()))
|
|
|
|
assert provider.cache_ttl_seconds == 900
|
|
assert http_client.auth_timeouts == [10.0]
|
|
assert http_client.tariff_timeouts == [10.0]
|
|
assert len(result) == 2
|