018 add parcel type

This commit is contained in:
Раис Юсупалиев
2026-03-16 01:14:46 +03:00
parent 54160a0e38
commit c4175121a0
10 changed files with 346 additions and 11 deletions
+119 -1
View File
@@ -7,9 +7,10 @@ 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 DeliveryEntity, DeliveryRequest
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType
from app.schemas.response import DeliveryPrice
from app.services.aggregator import (
AggregatorService,
AggregatorServiceError,
InvalidDeliveryRequestError,
)
@@ -28,6 +29,18 @@ class StubAggregatorService:
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[DeliveryRequest] = []
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
self.calls.append(request)
return self._response
def _install_service_override(app, service: StubAggregatorService) -> None:
async def override_service() -> StubAggregatorService:
return service
@@ -47,6 +60,22 @@ def _valid_payload() -> dict[str, object]:
}
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:
@@ -210,6 +239,7 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
length_cm=30.0,
width_cm=20.0,
height_cm=10.0,
parcel_type=None,
)
@@ -235,6 +265,72 @@ def test_post_delivery_price_accepts_optional_country_code() -> None:
assert service.calls[0].country_code == "kz"
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"),
_make_price(service_name="Срочный документ", price="150.00"),
_make_price(service_name="DOCUMENT EXPRESS", price="200.00"),
]
)
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()
@@ -256,6 +352,28 @@ def test_post_delivery_price_rejects_invalid_payload() -> None:
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=[],
+59 -1
View File
@@ -1,12 +1,13 @@
from decimal import Decimal
from app.domain.price import (
filter_prices_by_parcel_type,
filter_and_sort_prices,
filter_valid_prices,
normalize_delivery_request,
sort_prices_by_price,
)
from app.schemas.request import DeliveryEntity, DeliveryRequest
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType
from app.schemas.response import DeliveryPrice
@@ -80,6 +81,46 @@ def test_filter_valid_prices_handles_empty_input() -> None:
assert filter_and_sort_prices([]) == []
def test_filter_prices_by_parcel_type_matches_document_markers_case_insensitively() -> None:
document_ru = _make_price(service_name="Срочный Документ")
document_en = _make_price(service_name="DOCUMENT EXPRESS")
parcel = _make_price(service_name="Parcel locker")
result = filter_prices_by_parcel_type(
[document_ru, parcel, document_en],
parcel_type=ParcelType.DOC,
)
assert [price.service_name for price in result] == [
"Срочный Документ",
"DOCUMENT EXPRESS",
]
def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel() -> None:
document = _make_price(service_name="document delivery")
parcel = _make_price(service_name="Economy parcel")
standard = _make_price(service_name="Express")
result = filter_prices_by_parcel_type(
[document, parcel, standard],
parcel_type=ParcelType.PARCEL,
)
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
prices = [
_make_price(service_name="Документ"),
_make_price(service_name="Parcel"),
]
result = filter_prices_by_parcel_type(prices, parcel_type=None)
assert result == prices
def test_filter_valid_prices_applies_multiplier_and_rounds_half_up() -> None:
low = _make_price(provider="cdek", price=Decimal("100.40"))
high = _make_price(provider="boxberry", price=Decimal("100.50"))
@@ -173,3 +214,20 @@ def test_filter_and_sort_prices_combines_domain_steps() -> None:
assert [price.provider for price in result] == ["b", "a"]
assert [price.price for price in result] == [Decimal("130"), Decimal("286")]
def test_filter_and_sort_prices_applies_parcel_type_filter_before_sorting() -> None:
document = _make_price(provider="a", price=Decimal("220.00"), service_name="Document")
parcel = _make_price(provider="b", price=Decimal("100.00"), service_name="Parcel")
document_ru = _make_price(
provider="c",
price=Decimal("150.00"),
service_name="Документ курьером",
)
result = filter_and_sort_prices(
[document, parcel, document_ru],
parcel_type=ParcelType.DOC,
)
assert [price.provider for price in result] == ["c", "a"]
+56 -3
View File
@@ -4,7 +4,7 @@ from decimal import Decimal
import pytest
from app.adapters.delivery_providers.base import ProviderRequestError
from app.schemas.request import DeliveryEntity, DeliveryRequest
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType
from app.schemas.response import DeliveryPrice
from app.services.aggregator import AggregatorService, InvalidDeliveryRequestError
@@ -68,6 +68,7 @@ def _make_request(**overrides: object) -> DeliveryRequest:
"length_cm": 30.0,
"width_cm": 20.0,
"height_cm": 10.0,
"parcel_type": None,
}
payload.update(overrides)
return DeliveryRequest(**payload)
@@ -195,6 +196,55 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
assert cache.set_calls == []
@pytest.mark.parametrize(
("cache", "expected_provider_calls"),
[
(StubCache(), 1),
(
StubCache(
forced_get_value=[
_make_price(
"cdek",
"100.40",
service_name="DOCUMENT EXPRESS",
).model_dump(mode="json"),
_make_price(
"cdek",
"200.40",
service_name="Economy parcel",
).model_dump(mode="json"),
]
),
0,
),
],
)
def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_results(
cache: StubCache,
expected_provider_calls: int,
) -> None:
provider = StubProvider(
name="cdek",
response=[
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"),
_make_price("cdek", "200.40", service_name="Economy parcel"),
],
)
service = AggregatorService(
[provider],
cache=cache,
provider_price_multiplier=Decimal("1.1"),
)
result = asyncio.run(
service.get_all_prices(_make_request(parcel_type=ParcelType.DOC))
)
assert [price.service_name for price in result] == ["DOCUMENT EXPRESS"]
assert [price.price for price in result] == [Decimal("110")]
assert len(provider.calls) == expected_provider_calls
def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> None:
provider_a = StubProvider(
name="a",
@@ -206,11 +256,13 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non
provider_b = StubProvider(name="b", response=[_make_price("b", "100.00")])
delegated_inputs: list[list[DeliveryPrice]] = []
delegated_multipliers: list[Decimal] = []
delegated_parcel_types: list[object | None] = []
def fake_filter_and_sort(prices, *, price_multiplier):
def fake_filter_and_sort(prices, *, price_multiplier, parcel_type):
price_list = list(prices)
delegated_inputs.append(price_list)
delegated_multipliers.append(price_multiplier)
delegated_parcel_types.append(parcel_type)
return [price_list[0]]
service = AggregatorService(
@@ -220,7 +272,7 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non
filter_and_sort_prices_fn=fake_filter_and_sort,
)
result = asyncio.run(service.get_all_prices(_make_request()))
result = asyncio.run(service.get_all_prices(_make_request(parcel_type=ParcelType.PARCEL)))
assert [price.provider for price in delegated_inputs[0]] == ["a", "a", "b"]
assert [price.service_name for price in delegated_inputs[0]] == [
@@ -229,4 +281,5 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non
"standard",
]
assert delegated_multipliers == [Decimal("1.23")]
assert delegated_parcel_types == [ParcelType.PARCEL]
assert [price.provider for price in result] == ["a"]