diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 6635b7f..f69c4ef 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -1,5 +1,66 @@ """Delivery API controller skeleton.""" -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from app.adapters.delivery_providers.cdek import CDEKProvider +from app.config import Settings +from app.controllers.http_client import build_controller_http_client +from app.repositories.cache.redis_cache import PriceCache +from app.schemas.request import DeliveryRequest +from app.schemas.response import DeliveryPrice +from app.services.aggregator import AggregatorService, AggregatorServiceError router = APIRouter(prefix="/delivery", tags=["delivery"]) + + +_AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service" + + +def _build_aggregator_service(settings: Settings) -> AggregatorService: + http_client = build_controller_http_client( + timeout_seconds=settings.adapter.cdek_timeout_seconds + ) + providers = ( + CDEKProvider.from_adapter_config( + http_client=http_client, + adapter_config=settings.adapter, + ), + ) + cache = PriceCache.from_repository_config(settings.repository) + service = AggregatorService( + providers=providers, + cache=cache, + weight_round_scale=settings.business_logic.weight_round_scale, + ) + return service + + +async def get_aggregator_service(request: Request) -> AggregatorService: + cached_service = getattr(request.app.state, _AGGREGATOR_SERVICE_STATE_KEY, None) + if cached_service is not None: + return cached_service + + settings = request.app.state.settings + service = _build_aggregator_service(settings) + setattr(request.app.state, _AGGREGATOR_SERVICE_STATE_KEY, service) + return service + + +@router.post( + "/price", + response_model=list[DeliveryPrice], +) +async def get_delivery_price( + delivery_request: DeliveryRequest, + service: AggregatorService = Depends(get_aggregator_service), +) -> list[DeliveryPrice]: + try: + return await service.get_all_prices(delivery_request) + except AggregatorServiceError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "aggregator_service_error", + "message": "Delivery price aggregation is temporarily unavailable.", + }, + ) from exc diff --git a/app/services/aggregator.py b/app/services/aggregator.py index e3fd596..90e836c 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -17,6 +17,10 @@ from app.schemas.request import DeliveryRequest from app.schemas.response import DeliveryPrice +class AggregatorServiceError(RuntimeError): + """Base exception for AggregatorService failures.""" + + class PriceCacheProtocol(Protocol): async def get(self, key: str) -> object | None: ... diff --git a/spec/index.md b/spec/index.md index b3391a3..067fa97 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-07T12:38:07+00:00` +> Generated at (UTC): `2026-03-07T20:54:20+00:00` ## Tasks @@ -13,7 +13,7 @@ | 003 | DONE | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | | 004 | DONE | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | | 005 | DONE | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | -| 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | +| 006 | DONE | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | | 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | | 008 | TODO | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | | 009 | TODO | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | @@ -21,5 +21,5 @@ ## Summary - Total: **10** -- TODO: **4** -- DONE: **6** +- TODO: **3** +- DONE: **7** diff --git a/spec/tasks/006_add_delivery_price_controller.md b/spec/tasks/006_add_delivery_price_controller.md index 212c71d..ab64ce5 100644 --- a/spec/tasks/006_add_delivery_price_controller.md +++ b/spec/tasks/006_add_delivery_price_controller.md @@ -1,7 +1,7 @@ --- id: 006 title: Add delivery price controller endpoint -status: TODO +status: DONE created: 2026-03-07 --- diff --git a/tests/controllers/v1/test_delivery.py b/tests/controllers/v1/test_delivery.py new file mode 100644 index 0000000..18862b5 --- /dev/null +++ b/tests/controllers/v1/test_delivery.py @@ -0,0 +1,276 @@ +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 DeliveryEntity, DeliveryRequest +from app.schemas.response import DeliveryPrice +from app.services.aggregator import AggregatorServiceError + + +class StubAggregatorService: + def __init__(self, *, response: object, error: Exception | None = None) -> None: + self._response = response + self._error = error + self.calls: list[DeliveryRequest] = [] + + async def get_all_prices(self, request: DeliveryRequest) -> object: + self.calls.append(request) + if self._error is not None: + raise self._error + 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": "Moscow", + "to_city": "Kazan", + "weight_kg": 2.5, + "length_cm": 30.0, + "width_cm": 20.0, + "height_cm": 10.0, + } + + +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[DeliveryRequest] = [] + + async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + self.calls.append(request) + return DeliveryPrice( + provider=self.name, + service_name="stub-service", + price=Decimal("123.45"), + currency="RUB", + delivery_days_min=2, + delivery_days_max=3, + ) + + class StubCDEKProvider: + @classmethod + def from_adapter_config(cls, *, http_client, adapter_config) -> StubProvider: + assert http_client is stub_http_client + _ = adapter_config + return stub_provider + + 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) -> StubCache: + _ = repository_config + 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 + + monkeypatch.setattr( + delivery_controller, + "build_controller_http_client", + fake_build_controller_http_client, + ) + monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider) + 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": "stub-service", + "price": "123.45", + "currency": "RUB", + "delivery_days_min": 2, + "delivery_days_max": 3, + } + ] + assert second_response.json() == first_response.json() + assert len(http_client_timeouts) == 1 + assert len(stub_provider.calls) == 1 + + +def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: + 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_price]) + 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() == [expected_price.model_dump(mode="json")] + assert len(service.calls) == 1 + assert service.calls[0] == DeliveryRequest( + entity=DeliveryEntity.INDIVIDUAL, + from_city="Moscow", + to_city="Kazan", + weight_kg=2.5, + length_cm=30.0, + width_cm=20.0, + height_cm=10.0, + ) + + +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_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_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