Добавлена валидация цены
This commit is contained in:
@@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
||||
"currency": "usd",
|
||||
"tariff_codes": [
|
||||
{
|
||||
"tariff_code": 7,
|
||||
"tariff_name": "Express",
|
||||
"delivery_sum": "1234.50",
|
||||
"currency": "rub",
|
||||
@@ -33,6 +34,25 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
||||
assert [price.currency for price in result] == ["RUB", "USD"]
|
||||
assert [price.delivery_days_min for price in result] == [2, 5]
|
||||
assert [price.delivery_days_max for price in result] == [4, 7]
|
||||
assert [price.tariff_code for price in result] == [7, 136]
|
||||
|
||||
|
||||
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
|
||||
payload = {
|
||||
"tariff_codes": [
|
||||
{
|
||||
"tariff_name": "Express",
|
||||
"delivery_sum": "100.00",
|
||||
"currency": "RUB",
|
||||
"period_min": 1,
|
||||
"period_max": 2,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = map_cdek_response(payload)
|
||||
|
||||
assert result[0].tariff_code is None
|
||||
|
||||
|
||||
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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.schemas.payment import InitPaymentRequest
|
||||
|
||||
|
||||
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,
|
||||
"headers": headers,
|
||||
"timeout": timeout,
|
||||
}
|
||||
)
|
||||
result = self._next_result()
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
def _make_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test payment",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return InitPaymentRequest(**payload)
|
||||
|
||||
|
||||
def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_tariff() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"tariff_codes": [
|
||||
{
|
||||
"tariff_code": 234,
|
||||
"tariff_name": "Other tariff",
|
||||
"delivery_sum": "999.00",
|
||||
"currency": "RUB",
|
||||
"period_min": 3,
|
||||
"period_max": 5,
|
||||
},
|
||||
{
|
||||
"tariff_code": 535,
|
||||
"tariff_name": "CDEK tariff",
|
||||
"delivery_sum": "1250.00",
|
||||
"currency": "RUB",
|
||||
"period_min": 1,
|
||||
"period_max": 2,
|
||||
},
|
||||
]
|
||||
},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
provider = CDEKProvider(
|
||||
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=7.5,
|
||||
retry_attempts=0,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_payment_price(_make_request()))
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == "cdek"
|
||||
assert result.service_name == "CDEK tariff"
|
||||
assert result.price == Decimal("1250.00")
|
||||
assert http_client.calls == [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.cdek.test/v2/calculator/tarifflist",
|
||||
"json": {
|
||||
"type": 2,
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"weight": 1000,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
}
|
||||
],
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
},
|
||||
"data": None,
|
||||
"headers": {"Authorization": "Bearer test-token"},
|
||||
"timeout": 7.5,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_get_payment_price_omits_services_when_none() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"tariff_codes": []},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
provider = CDEKProvider(
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
|
||||
|
||||
assert result is None
|
||||
assert "services" not in http_client.calls[0]["json"]
|
||||
|
||||
|
||||
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
|
||||
rejected_response = httpx.Response(
|
||||
422,
|
||||
json={"errors": [{"message": "bad request"}]},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([rejected_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=2,
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> None:
|
||||
first_response = httpx.Response(
|
||||
503,
|
||||
json={"message": "temporary failure"},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
second_response = httpx.Response(
|
||||
503,
|
||||
json={"message": "temporary failure"},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([first_response, 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",
|
||||
retry_attempts=1,
|
||||
retry_backoff_seconds=0.25,
|
||||
sleep=fake_sleep,
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKClientError, match="status 503"):
|
||||
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||
|
||||
assert len(http_client.calls) == 2
|
||||
assert sleep_calls == [0.25]
|
||||
|
||||
|
||||
def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error() -> None:
|
||||
invalid_response = httpx.Response(
|
||||
200,
|
||||
json={"tariff_codes": [{"tariff_code": 535}]},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([invalid_response])
|
||||
provider = CDEKProvider(
|
||||
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(CDEKClientError, match="response payload is invalid"):
|
||||
asyncio.run(provider.get_payment_price(_make_request()))
|
||||
@@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
"currency": "RUB",
|
||||
"delivery_days_min": 2,
|
||||
"delivery_days_max": 3,
|
||||
"tariff_code": None,
|
||||
},
|
||||
{
|
||||
"provider": "stub-provider",
|
||||
@@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
"currency": "RUB",
|
||||
"delivery_days_min": 4,
|
||||
"delivery_days_max": 5,
|
||||
"tariff_code": None,
|
||||
}
|
||||
]
|
||||
assert second_response.json() == first_response.json()
|
||||
@@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type(
|
||||
) -> 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"),
|
||||
_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])
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["tariff_code"] = 136
|
||||
invalid_payload["tariff_code"] = "not-a-number"
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
@@ -110,7 +110,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_post_init_payment_rejects_missing_price() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -209,7 +209,7 @@ def test_post_init_payment_rejects_sender_company_field() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -236,7 +236,7 @@ def test_post_init_payment_rejects_sender_phones_field() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -263,7 +263,7 @@ def test_post_init_payment_rejects_recipient_company_field() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -290,7 +290,7 @@ def test_post_init_payment_rejects_recipient_phones_field() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
@@ -314,7 +314,7 @@ def test_post_init_payment_accepts_request_without_services() -> None:
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/init-payment", json=payload)
|
||||
return await client.post("/api/v1/delivery/order", json=payload)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
@@ -338,7 +338,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
@@ -353,6 +353,37 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_post_init_payment_maps_price_mismatch_to_400() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=InvalidInitPaymentRequestError("price mismatch"),
|
||||
)
|
||||
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/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "invalid_init_payment_request",
|
||||
"message": "Payment request contains invalid or unsupported TBank data.",
|
||||
}
|
||||
}
|
||||
assert service.calls == [InitPaymentRequest(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
@@ -368,7 +399,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/init-payment",
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
@@ -383,6 +414,37 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_post_init_payment_maps_temporary_cdek_validation_error_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=InitPaymentUnavailableError("CDEK validation 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/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "init_payment_unavailable",
|
||||
"message": "Payment initialization is temporarily unavailable.",
|
||||
}
|
||||
}
|
||||
assert service.calls == [InitPaymentRequest(**_valid_payload())]
|
||||
|
||||
|
||||
def test_delivery_order_endpoint_is_removed() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domain.price import (
|
||||
calculate_expected_payment_amount_kopecks,
|
||||
is_init_payment_price_valid,
|
||||
)
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
|
||||
def _make_price(**overrides: object) -> DeliveryPrice:
|
||||
payload = {
|
||||
"provider": "cdek",
|
||||
"service_name": "CDEK tariff",
|
||||
"price": Decimal("1250.00"),
|
||||
"currency": "RUB",
|
||||
"delivery_days_min": 1,
|
||||
"delivery_days_max": 2,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return DeliveryPrice.model_construct(**payload)
|
||||
|
||||
|
||||
def test_init_payment_price_validation_accepts_exact_match() -> None:
|
||||
provider_price = _make_price(price=Decimal("1250.00"))
|
||||
|
||||
assert is_init_payment_price_valid(125000, provider_price)
|
||||
assert calculate_expected_payment_amount_kopecks(provider_price) == 125000
|
||||
|
||||
|
||||
def test_init_payment_price_validation_rejects_mismatch() -> None:
|
||||
provider_price = _make_price(price=Decimal("1250.00"))
|
||||
|
||||
assert not is_init_payment_price_valid(124999, provider_price)
|
||||
|
||||
|
||||
def test_init_payment_price_validation_applies_multiplier_and_rounds_half_up() -> None:
|
||||
provider_price = _make_price(price=Decimal("100.50"))
|
||||
|
||||
expected_amount = calculate_expected_payment_amount_kopecks(
|
||||
provider_price,
|
||||
price_multiplier=Decimal("1.1"),
|
||||
)
|
||||
|
||||
assert expected_amount == 11100
|
||||
assert is_init_payment_price_valid(
|
||||
11100,
|
||||
provider_price,
|
||||
price_multiplier=Decimal("1.1"),
|
||||
)
|
||||
|
||||
|
||||
def test_init_payment_price_validation_converts_rub_to_kopecks() -> None:
|
||||
provider_price = _make_price(price=Decimal("899.00"))
|
||||
|
||||
assert calculate_expected_payment_amount_kopecks(provider_price) == 89900
|
||||
|
||||
|
||||
def test_init_payment_price_validation_rejects_non_rub_currency() -> None:
|
||||
provider_price = _make_price(price=Decimal("899.00"), currency="USD")
|
||||
|
||||
assert calculate_expected_payment_amount_kopecks(provider_price) is None
|
||||
assert not is_init_payment_price_valid(89900, provider_price)
|
||||
@@ -106,6 +106,41 @@ def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel()
|
||||
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
|
||||
|
||||
|
||||
def test_filter_prices_by_parcel_type_keeps_prices_with_bypass_flag_regardless_of_type() -> None:
|
||||
bypass_document = _make_price(
|
||||
provider="cdek",
|
||||
service_name="Документ курьером",
|
||||
bypass_parcel_type_filter=True,
|
||||
)
|
||||
bypass_parcel = _make_price(
|
||||
provider="cdek",
|
||||
service_name="Посылка склад-склад",
|
||||
bypass_parcel_type_filter=True,
|
||||
)
|
||||
other_document = _make_price(provider="other", service_name="Document")
|
||||
other_parcel = _make_price(provider="other", service_name="Parcel")
|
||||
|
||||
doc_result = filter_prices_by_parcel_type(
|
||||
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||
parcel_type=ParcelType.DOC,
|
||||
)
|
||||
parcel_result = filter_prices_by_parcel_type(
|
||||
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||
parcel_type=ParcelType.PARCEL,
|
||||
)
|
||||
|
||||
assert [price.service_name for price in doc_result] == [
|
||||
"Документ курьером",
|
||||
"Посылка склад-склад",
|
||||
"Document",
|
||||
]
|
||||
assert [price.service_name for price in parcel_result] == [
|
||||
"Документ курьером",
|
||||
"Посылка склад-склад",
|
||||
"Parcel",
|
||||
]
|
||||
|
||||
|
||||
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
|
||||
prices = [
|
||||
_make_price(service_name="Документ"),
|
||||
|
||||
@@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
||||
StubCache(
|
||||
forced_get_value=[
|
||||
_make_price(
|
||||
"cdek",
|
||||
"other",
|
||||
"100.40",
|
||||
service_name="DOCUMENT EXPRESS",
|
||||
).model_dump(mode="json"),
|
||||
_make_price(
|
||||
"cdek",
|
||||
"other",
|
||||
"200.40",
|
||||
service_name="Economy parcel",
|
||||
).model_dump(mode="json"),
|
||||
@@ -238,10 +238,10 @@ def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_res
|
||||
expected_provider_calls: int,
|
||||
) -> None:
|
||||
provider = StubProvider(
|
||||
name="cdek",
|
||||
name="other",
|
||||
response=[
|
||||
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"),
|
||||
_make_price("cdek", "200.40", service_name="Economy parcel"),
|
||||
_make_price("other", "100.40", service_name="DOCUMENT EXPRESS"),
|
||||
_make_price("other", "200.40", service_name="Economy parcel"),
|
||||
],
|
||||
)
|
||||
service = AggregatorService(
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.repositories.order import OrderData
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.schemas.response import DeliveryPrice
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
InitPaymentUnavailableError,
|
||||
@@ -21,13 +24,17 @@ class StubPaymentAdapter:
|
||||
*,
|
||||
response: str | None = None,
|
||||
error: Exception | None = None,
|
||||
events: list[str] | None = None,
|
||||
) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self._events = events
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||
self.calls.append((order_uuid, amount_kopecks))
|
||||
if self._events is not None:
|
||||
self._events.append("tbank")
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
if self._response is None:
|
||||
@@ -35,6 +42,31 @@ class StubPaymentAdapter:
|
||||
return self._response
|
||||
|
||||
|
||||
class StubPaymentPriceValidationAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
response: DeliveryPrice | None = None,
|
||||
error: Exception | None = None,
|
||||
events: list[str] | None = None,
|
||||
) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self._events = events
|
||||
self.calls: list[InitPaymentRequest] = []
|
||||
|
||||
async def get_payment_price(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
) -> DeliveryPrice | None:
|
||||
self.calls.append(request)
|
||||
if self._events is not None:
|
||||
self._events.append("cdek")
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
|
||||
class StubOrderSessionContext:
|
||||
def __init__(self, session: object) -> None:
|
||||
self._session = session
|
||||
@@ -105,30 +137,57 @@ def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
|
||||
return InitPaymentRequest(**payload)
|
||||
|
||||
|
||||
def test_init_payment_delegates_to_adapter_and_returns_payment_url() -> None:
|
||||
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
|
||||
payload = {
|
||||
"provider": "cdek",
|
||||
"service_name": "CDEK tariff",
|
||||
"price": Decimal("1250.00"),
|
||||
"currency": "RUB",
|
||||
"delivery_days_min": 1,
|
||||
"delivery_days_max": 2,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return DeliveryPrice.model_construct(**payload)
|
||||
|
||||
|
||||
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
||||
events: list[str] = []
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
response=_make_cdek_price(),
|
||||
events=events,
|
||||
)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert events == ["cdek", "tbank"]
|
||||
|
||||
|
||||
def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert order_repository.calls == [
|
||||
(
|
||||
@@ -179,16 +238,19 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert len(order_repository.calls) == 1
|
||||
|
||||
@@ -196,22 +258,34 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
|
||||
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
|
||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
|
||||
|
||||
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
|
||||
request = _make_init_payment_request(services=None)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
|
||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert request.services is None
|
||||
|
||||
@@ -219,11 +293,17 @@ def test_init_payment_delegates_request_with_services_none_without_new_logic() -
|
||||
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
|
||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
|
||||
|
||||
@@ -232,3 +312,78 @@ def test_init_payment_without_configured_adapter_raises_unavailable_error() -> N
|
||||
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(_make_init_payment_request()))
|
||||
|
||||
|
||||
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
|
||||
request = _make_init_payment_request(price=124999)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
assert order_repository.calls == []
|
||||
|
||||
|
||||
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
|
||||
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
error=ProviderRequestError("bad CDEK payload")
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
|
||||
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
error=ProviderClientError("CDEK unavailable")
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
Reference in New Issue
Block a user