Files
g2s-aggregator/tests/controllers/v1/test_delivery.py
T
Раис Юсупалиев 3f7c6dc631
Deploy / deploy (push) Failing after 14m35s
Рефактор
2026-06-26 19:29:11 +03:00

508 lines
16 KiB
Python

import asyncio
from decimal import Decimal
import httpx
import pytest
from app.controllers.v1 import delivery as delivery_controller
from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType
from app.schemas.response import DeliveryPrice
from app.adapters.delivery_providers.registry import DeliveryProviderRegistry
from app.services.aggregator import (
AggregatorService,
AggregatorServiceError,
InvalidDeliveryRequestError,
)
class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response
self._error = error
self.calls: list[DeliveryCalculationRequest] = []
async def get_all_prices(self, request: DeliveryCalculationRequest) -> object:
self.calls.append(request)
if self._error is not None:
raise self._error
return self._response
class StubPriceProvider:
def __init__(self, response: list[DeliveryPrice]) -> None:
self.name = "stub-provider"
self.cache_ttl_seconds = 900
self._response = response
self.calls: list[DeliveryCalculationRequest] = []
async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
self.calls.append(request)
return self._response
def _install_service_override(app, service: StubAggregatorService) -> None:
async def override_service() -> StubAggregatorService:
return service
app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]:
return {
"entity": "individual",
"from_city": 1,
"to_city": 2,
"weight_kg": 2.5,
"length_cm": 30.0,
"width_cm": 20.0,
"height_cm": 10.0,
}
def _make_price(
*,
service_name: str,
price: str,
provider: str = "cdek",
) -> DeliveryPrice:
return DeliveryPrice(
provider=provider,
service_name=service_name,
price=Decimal(price),
currency="RUB",
delivery_days_min=1,
delivery_days_max=3,
)
def test_post_delivery_price_uses_registered_provider_in_default_dependency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class StubHttpClient:
def __init__(self) -> None:
self.closed = False
async def aclose(self) -> None:
self.closed = True
class StubProvider:
name = "stub-provider"
cache_ttl_seconds = 900
def __init__(self) -> None:
self.calls: list[DeliveryCalculationRequest] = []
async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
self.calls.append(request)
return [
DeliveryPrice(
provider=self.name,
service_name="slow",
price=Decimal("223.50"),
currency="RUB",
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 StubCache:
def __init__(self) -> None:
self.storage: dict[str, object] = {}
async def get(self, key: str) -> object | None:
return self.storage.get(key)
async def set(self, key: str, value: object, ttl: int | None = None) -> None:
_ = ttl
self.storage[key] = value
class StubPriceCache:
@classmethod
def from_repository_config(
cls, repository_config, *, metrics
) -> StubCache:
_ = repository_config
assert metrics is delivery_controller.get_cache_metrics()
return stub_cache
stub_http_client = StubHttpClient()
stub_provider = StubProvider()
stub_cache = StubCache()
http_client_timeouts: list[float] = []
def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient:
http_client_timeouts.append(timeout_seconds)
return stub_http_client
def fake_resolve_delivery_provider_timeout_seconds(config: object) -> float:
_ = config
return 12.5
def fake_build_delivery_provider_registry(
*,
http_client: object,
config: object,
) -> DeliveryProviderRegistry:
assert http_client is stub_http_client
_ = config
return DeliveryProviderRegistry(
providers=(stub_provider,),
payment_price_validation_adapters={},
order_registration_adapters={},
)
monkeypatch.setattr(
delivery_controller,
"build_controller_http_client",
fake_build_controller_http_client,
)
monkeypatch.setattr(
delivery_controller,
"resolve_delivery_provider_timeout_seconds",
fake_resolve_delivery_provider_timeout_seconds,
)
monkeypatch.setattr(
delivery_controller,
"build_delivery_provider_registry",
fake_build_delivery_provider_registry,
)
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
app = create_app()
async def run_requests() -> tuple[httpx.Response, httpx.Response]:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
first = await client.post("/api/v1/delivery/price", json=_valid_payload())
second = await client.post("/api/v1/delivery/price", json=_valid_payload())
return first, second
first_response, second_response = asyncio.run(run_requests())
assert first_response.status_code == 200
assert second_response.status_code == 200
assert first_response.json() == [
{
"provider": "stub-provider",
"service_name": "fast",
"price": "124",
"currency": "RUB",
"delivery_days_min": 2,
"delivery_days_max": 3,
"tariff_code": None,
},
{
"provider": "stub-provider",
"service_name": "slow",
"price": "224",
"currency": "RUB",
"delivery_days_min": 4,
"delivery_days_max": 5,
"tariff_code": None,
}
]
assert second_response.json() == first_response.json()
assert http_client_timeouts == [12.5]
assert len(stub_provider.calls) == 1
def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
expected_prices = [
DeliveryPrice(
provider="cdek",
service_name="economy",
price=Decimal("150.00"),
currency="RUB",
delivery_days_min=3,
delivery_days_max=5,
),
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()
_install_service_override(app, service)
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=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 200
assert response.json() == [price.model_dump(mode="json") for price in expected_prices]
assert len(service.calls) == 1
assert service.calls[0] == DeliveryCalculationRequest(
entity=DeliveryEntity.INDIVIDUAL,
from_city=1,
to_city=2,
weight_kg=2.5,
length_cm=30.0,
width_cm=20.0,
height_cm=10.0,
parcel_type=None,
)
@pytest.mark.parametrize(("field_name", "field_value"), [("from_city", "1"), ("to_city", "2")])
def test_post_delivery_price_rejects_non_integer_city_identifier(
field_name: str,
field_value: str,
) -> None:
service = StubAggregatorService(response=[])
app = create_app()
_install_service_override(app, service)
payload = _valid_payload()
payload[field_name] = field_value
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 == 422
assert response.json()["detail"][0]["loc"] == ["body", field_name]
assert service.calls == []
def test_post_delivery_price_accepts_optional_parcel_type() -> None:
service = StubAggregatorService(response=[])
app = create_app()
_install_service_override(app, service)
payload = _valid_payload()
payload["parcel_type"] = "doc"
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].parcel_type == ParcelType.DOC
@pytest.mark.parametrize(
("parcel_type", "expected_service_names"),
[
("doc", ["Срочный документ", "DOCUMENT EXPRESS"]),
("parcel", ["Parcel locker"]),
(None, ["Parcel locker", "Срочный документ", "DOCUMENT EXPRESS"]),
],
)
def test_post_delivery_price_filters_response_by_optional_parcel_type(
parcel_type: str | None,
expected_service_names: list[str],
) -> None:
provider = StubPriceProvider(
response=[
_make_price(service_name="Parcel locker", price="90.00", provider="other"),
_make_price(service_name="Срочный документ", price="150.00", provider="other"),
_make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"),
]
)
service = AggregatorService(providers=[provider])
app = create_app()
async def override_service() -> AggregatorService:
return service
app.dependency_overrides[get_aggregator_service] = override_service
payload = _valid_payload()
if parcel_type is not None:
payload["parcel_type"] = parcel_type
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 [item["service_name"] for item in response.json()] == expected_service_names
def test_post_delivery_price_rejects_invalid_payload() -> None:
service = StubAggregatorService(response=[])
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["weight_kg"] = 0
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=invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_delivery_price_rejects_invalid_parcel_type() -> None:
service = StubAggregatorService(response=[])
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["parcel_type"] = "letters"
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=invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "parcel_type"]
assert service.calls == []
def test_post_delivery_price_maps_service_exception_to_503() -> None:
service = StubAggregatorService(
response=[],
error=AggregatorServiceError("service unavailable"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/price", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "aggregator_service_error",
"message": "Delivery price aggregation is temporarily unavailable.",
}
}
def test_post_delivery_price_maps_invalid_request_to_400() -> None:
service = StubAggregatorService(
response=[],
error=InvalidDeliveryRequestError("city not found"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/price", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_delivery_request",
"message": "Delivery request contains unknown or unsupported location.",
}
}
def test_delivery_price_endpoint_accepts_only_post_method() -> None:
service = StubAggregatorService(response=[])
app = create_app()
_install_service_override(app, service)
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.get("/api/v1/delivery/price")
response = asyncio.run(run_request())
assert response.status_code == 405
def test_post_delivery_price_enforces_response_schema() -> None:
service = StubAggregatorService(
response=[
{
"provider": "cdek",
"service_name": "express",
"price": "100.50",
"currency": "R",
"delivery_days_min": 1,
"delivery_days_max": 2,
}
]
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/price", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 500