Добавлен заказ накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 17:29:56 +03:00
parent 39cd5ddc7a
commit c494d50566
21 changed files with 1384 additions and 1126 deletions
View File
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider,
CDEKRequestError,
)
from app.schemas.payment import InitPaymentRequest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient:
@@ -51,179 +54,53 @@ class SequenceHTTPClient:
return result
def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"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_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
return 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,
**kwargs,
)
)
result = asyncio.run(provider.register_order(_make_order_request()))
assert result == "cdek-order-uuid"
assert http_client.calls == [
{
"method": "POST",
"url": "https://api.cdek.test/v2/orders",
"json": {
"number": "order-uuid-1",
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"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": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
"timeout": 7.5,
}
]
def test_provider_register_order_omits_services_when_none_and_converts_package_weights() -> None:
def test_provider_register_order_posts_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
json={
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
],
},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
)
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,
)
)
request = _make_order_request(
services=None,
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
],
)
provider = _make_provider(http_client)
asyncio.run(provider.register_order(request))
result = asyncio.run(provider.register_order(make_init_payment_request()))
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
payload = http_client.calls[0]["json"]
assert "services" not in payload
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [{"number": "+79001234567"}]
assert payload["packages"] == [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2000,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
]
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
assert payload["print"] == "WAYBILL"
assert payload["from_location"]["code"] == 7017
assert payload["to_location"]["code"] == 16454
assert payload["packages"][0]["weight"] == 1000
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
@@ -241,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 1
@@ -272,41 +149,13 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
retry_attempts=2,
)
result = asyncio.run(client.register_order(_make_order_request()))
result = asyncio.run(client.register_order(make_init_payment_request()))
assert result == "existing-cdek-order-uuid"
assert len(http_client.calls) == 1
def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None:
unrelated_response = httpx.Response(
409,
json={
"entity": {"uuid": "unrelated-uuid"},
"requests": [
{
"errors": [
{
"code": "v2_recipient_phone_invalid",
"message": "Recipient phone is invalid",
}
]
}
],
},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
http_client = SequenceHTTPClient([unrelated_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):
asyncio.run(client.register_order(_make_order_request()))
assert len(http_client.calls) == 1
@@ -337,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
)
with pytest.raises(CDEKClientError, match="retriable status 503"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -358,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
@@ -1,53 +1,209 @@
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
compose_address_line,
map_cdek_existing_order_response,
map_cdek_order_request,
map_cdek_order_response,
)
from app.schemas.payment import InitPaymentRequest
from app.schemas.payment import Address
from tests.payment_fixtures import make_init_payment_request
def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"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_cdek_order_payload_uses_order_uuid_as_external_number() -> None:
payload = map_cdek_order_request(_make_order_request())
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
def test_order_payload_requests_waybill_print() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["print"] == "WAYBILL"
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
result = map_cdek_existing_order_response(
{
"entity": {"uuid": "existing-uuid"},
"requests": [
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
],
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-existing",
"url": "https://cdek.test/waybill/existing.pdf",
}
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-uuid",
waybill_uuid="waybill-existing",
waybill_url="https://cdek.test/waybill/existing.pdf",
)
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [
{"number": "+79001234567", "additional": "101"}
]
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["from_location"] == {
"code": 7017,
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
"postal_code": "12345",
}
assert payload["to_location"] == {
"code": 16454,
"address": "Шарджа, Al Wahda, 5",
"postal_code": "54321",
}
def test_order_payload_for_parcel_includes_dimensions() -> None:
payload = map_cdek_order_request(make_init_payment_request())
package = payload["packages"][0]
assert package["weight"] == 1000
assert package["length"] == 20
assert package["width"] == 15
assert package["height"] == 10
def test_order_payload_for_doc_omits_dimensions() -> None:
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
payload = map_cdek_order_request(request)
package = payload["packages"][0]
assert package["weight"] == 500
assert "length" not in package
assert "width" not in package
assert "height" not in package
def test_order_payload_omits_shipment_and_delivery_point() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert "shipment_point" not in payload
assert "delivery_point" not in payload
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
request = make_init_payment_request(
senderContact={
"fullName": "Romashka LLC",
"email": "buh@romashka.test",
"phone": "+74950000000",
"phoneExt": None,
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
sender = map_cdek_order_request(request)["sender"]
assert sender["contragent_type"] == "LEGAL_ENTITY"
assert sender["company"] == "Romashka LLC"
assert sender["inn"] == "7707083893"
assert sender["kpp"] == "770701001"
def test_order_payload_propagates_description_to_comments_without_items() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["comment"] == "Headphones"
assert payload["packages"][0]["comment"] == "Headphones"
assert "items" not in payload["packages"][0]
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request(
make_init_payment_request(content={"description": None})
)
assert payload["packages"][0]["comment"] == "order-uuid-1"
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["sender"]["company"] == "Petr Petrov"
assert payload["recipient"]["company"] == "Ivan Ivanov"
assert "contragent_type" not in payload["sender"]
assert "contragent_type" not in payload["recipient"]
def test_compose_address_line_handles_empty_apartment() -> None:
address = Address(
cityId=1,
city="Москва",
street="Lenina",
house="1",
apartment=None,
zip="12345",
comment=None,
)
assert compose_address_line(address) == "Москва, Lenina, 1"
@@ -11,7 +11,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider,
CDEKRequestError,
)
from app.schemas.payment import InitPaymentRequest
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient:
@@ -52,62 +52,11 @@ class SequenceHTTPClient:
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:
def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> 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",
@@ -131,11 +80,9 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
)
)
result = asyncio.run(provider.get_payment_price(_make_request()))
result = asyncio.run(provider.get_payment_price(make_init_payment_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 == [
{
@@ -143,16 +90,8 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"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",
},
"from_location": {"code": 7017},
"to_location": {"code": 16454},
"packages": [
{
"weight": 1000,
@@ -161,7 +100,6 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"height": 10,
}
],
"services": [{"code": "INSURANCE", "parameter": "1000"}],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
@@ -170,7 +108,7 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
]
def test_provider_get_payment_price_omits_services_when_none() -> None:
def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
response = httpx.Response(
200,
json={"tariff_codes": []},
@@ -185,11 +123,28 @@ def test_provider_get_payment_price_omits_services_when_none() -> None:
retry_attempts=0,
)
)
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
asyncio.run(provider.get_payment_price(request))
assert result is None
assert "services" not in http_client.calls[0]["json"]
package = http_client.calls[0]["json"]["packages"][0]
assert package == {"weight": 500}
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
@@ -207,7 +162,7 @@ def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.get_raw_payment_price(_make_request()))
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 1
@@ -239,7 +194,7 @@ def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> N
)
with pytest.raises(CDEKClientError, match="status 503"):
asyncio.run(client.get_raw_payment_price(_make_request()))
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -262,4 +217,4 @@ def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(provider.get_payment_price(_make_request()))
asyncio.run(provider.get_payment_price(make_init_payment_request()))
+140 -326
View File
@@ -9,6 +9,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload
class StubAggregatorService:
@@ -31,45 +32,16 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]:
return {
"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",
}
],
}
def _post(app, payload: dict[str, object]) -> httpx.Response:
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=payload)
return asyncio.run(run_request())
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
@@ -77,44 +49,64 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
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/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
response = _post(app, payload)
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest(**_valid_payload())]
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_invalid_payload() -> None:
def test_post_init_payment_rejects_invalid_tariff_code() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["tariff_code"] = "not-a-number"
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
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/order",
json=invalid_payload,
)
response = _post(app, invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
sender_address = invalid_payload["senderAddress"]
sender_address["city_id"] = sender_address.pop("cityId")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
tariff = invalid_payload["systemData"]["tariff"]
tariff["tariff_code"] = tariff.pop("tariffCode")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -124,21 +116,10 @@ def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
del invalid_payload["price"]
invalid_payload = make_init_payment_payload()
del invalid_payload["systemData"]["tariff"]["price"]
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -148,21 +129,10 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 0
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -172,155 +142,102 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 125000.5
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 125000.5
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_company_field() -> None:
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
invalid_payload = make_init_payment_payload()
invalid_payload["senderContact"]["isCompany"] = True
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"phones": [{"number": "+79009876543"}],
}
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"phones": [{"number": "+79001234567"}],
}
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/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_request_without_services() -> None:
def test_post_init_payment_accepts_company_with_full_requisites() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = _valid_payload()
del payload["services"]
payload = make_init_payment_payload()
payload["senderContact"].update(
{
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
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/order", json=payload)
response = asyncio.run(run_request())
response = _post(app, payload)
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest(**payload)]
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_non_numeric_weight() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["weight"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"]["length"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"] = None
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
payload["systemData"]["parcelType"] = "doc"
payload["systemData"]["docPackaging"] = "envelope"
payload["systemData"]["dimensions"] = None
response = _post(app, payload)
assert response.status_code == 200
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_maps_invalid_request_to_400() -> None:
@@ -331,18 +248,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
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())
response = _post(app, make_init_payment_payload())
assert response.status_code == 400
assert response.json() == {
@@ -353,37 +259,6 @@ 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,
@@ -392,18 +267,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
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())
response = _post(app, make_init_payment_payload())
assert response.status_code == 503
assert response.json() == {
@@ -412,53 +276,3 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
"message": "Payment initialization is temporarily unavailable.",
}
}
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()
_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/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 404
assert service.calls == []
+81
View File
@@ -0,0 +1,81 @@
"""Shared fixtures for the camelCase init-payment contract."""
from typing import Any
from app.schemas.payment import InitPaymentRequest
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
payload: dict[str, Any] = {
"orderUuid": "order-uuid-1",
"senderAddress": {
"cityId": 1,
"city": "Дубай",
"street": "Sheikh Zayed Road",
"house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Domofon 12",
},
"senderContact": {
"fullName": "Petr Petrov",
"email": "sender@example.com",
"phone": "+79009876543",
"phoneExt": None,
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"receiverAddress": {
"cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": None,
"zip": "54321",
"comment": None,
},
"receiverContact": {
"fullName": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": "+79001234567",
"phoneExt": "101",
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"content": {
"description": "Headphones",
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "parcel",
"docPackaging": None,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10",
},
},
}
payload.update(overrides)
return payload
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
+40 -64
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.repositories.order import OrderData, OrderRepository
from app.repositories.order.models import Base, Order
from tests.payment_fixtures import make_init_payment_payload
def _make_order_data(**overrides: object) -> OrderData:
@@ -17,40 +18,9 @@ def _make_order_data(**overrides: object) -> OrderData:
"order_uuid": "order-uuid-1",
"payment_url": "https://pay.test/payment/1",
"price": 125000,
"delivery_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",
}
],
"account_email": "client@example.com",
"payload": make_init_payment_payload(),
}
payload.update(overrides)
return OrderData(**payload)
@@ -92,46 +62,20 @@ def test_create_order_persists_all_required_fields() -> None:
assert persisted_order.order_uuid == "order-uuid-1"
assert persisted_order.payment_url == "https://pay.test/payment/1"
assert persisted_order.price == 125000
assert persisted_order.delivery_type == 2
assert persisted_order.tariff_code == 535
assert persisted_order.sender == order_data.sender
assert persisted_order.recipient == order_data.recipient
assert persisted_order.from_location == order_data.from_location
assert persisted_order.to_location == order_data.to_location
assert persisted_order.packages == order_data.packages
assert persisted_order.services == order_data.services
assert persisted_order.comment == "Test payment"
assert persisted_order.account_email == "client@example.com"
assert persisted_order.payload == order_data.payload
assert persisted_order.payment_status is None
assert persisted_order.tbank_payment_id is None
assert persisted_order.cdek_order_uuid is None
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
assert persisted_order.created_at is not None
assert persisted_order.updated_at is not None
asyncio.run(_with_repository(run))
def test_create_order_persists_nullable_services_and_comment() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data(services=None, comment=None)
async with repository.session() as session:
await repository.create_order(session, order_data)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.services is None
assert persisted_order.comment is None
asyncio.run(_with_repository(run))
def test_create_order_rejects_duplicate_order_uuid() -> None:
async def run(
repository: OrderRepository,
@@ -229,7 +173,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
@@ -242,6 +186,8 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
session,
"order-uuid-1",
"cdek-order-uuid-1",
"waybill-uuid-1",
"https://cdek.test/waybill/1.pdf",
)
async with session_factory() as session:
@@ -252,6 +198,36 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
assert order is not None
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid == "waybill-uuid-1"
assert persisted_order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_allows_missing_waybill_fields() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
asyncio.run(_with_repository(run))
+25 -135
View File
@@ -16,6 +16,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
class StubPaymentAdapter:
@@ -94,49 +95,6 @@ class StubOrderRepository:
return object()
def _make_init_payment_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 _make_cdek_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
@@ -151,7 +109,7 @@ def _make_cdek_price(**overrides: object) -> DeliveryPrice:
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
events: list[str] = []
validation_adapter = StubPaymentPriceValidationAdapter(
response=_make_cdek_price(),
@@ -172,8 +130,9 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
assert events == ["cdek", "tbank"]
def test_init_payment_persists_order_after_successful_payment_link() -> None:
request = _make_init_payment_request()
def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
payload = make_init_payment_payload()
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
@@ -184,59 +143,21 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
order_repository=order_repository,
)
result = asyncio.run(service.init_payment(request))
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 == [
(
order_repository.session_value,
OrderData(
order_uuid="order-uuid-1",
payment_url="https://pay.test/payment/1",
price=125000,
delivery_type=2,
tariff_code=535,
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",
},
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
services=[{"code": "INSURANCE", "parameter": "1000"}],
comment="Test payment",
),
)
]
assert len(order_repository.calls) == 1
session, order_data = order_repository.calls[0]
assert session is order_repository.session_value
assert order_data.order_uuid == "order-uuid-1"
assert order_data.payment_url == "https://pay.test/payment/1"
assert order_data.price == 125000
assert order_data.tariff_code == 535
assert order_data.account_email == "client@example.com"
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
request = _make_init_payment_request()
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"))
@@ -250,13 +171,12 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
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
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
@@ -268,30 +188,9 @@ def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() ->
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")
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
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
@@ -303,19 +202,18 @@ def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(_make_init_payment_request()))
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)
payload = make_init_payment_payload()
payload["systemData"]["tariff"]["price"] = 124999
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
@@ -329,13 +227,12 @@ def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_ca
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()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
service = AggregatorService(
@@ -347,12 +244,11 @@ def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> No
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()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderRequestError("bad CDEK payload")
@@ -366,12 +262,9 @@ def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None
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()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderClientError("CDEK unavailable")
@@ -384,6 +277,3 @@ def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
+61 -54
View File
@@ -1,9 +1,12 @@
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import pytest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.services.aggregator import (
@@ -11,6 +14,11 @@ from app.services.aggregator import (
InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError,
)
from tests.payment_fixtures import make_init_payment_payload
def _default_payload() -> dict[str, Any]:
return make_init_payment_payload()
@dataclass
@@ -18,57 +26,14 @@ class StoredOrder:
order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1"
price: int = 125000
delivery_type: int = 2
tariff_code: int = 535
comment: str | None = "Test payment"
sender: dict[str, Any] | None = None
recipient: dict[str, Any] | None = None
from_location: dict[str, Any] | None = None
to_location: dict[str, Any] | None = None
packages: list[dict[str, Any]] | None = None
services: list[dict[str, Any]] | None = None
account_email: str = "client@example.com"
payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
def __post_init__(self) -> None:
if self.sender is None:
self.sender = {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
}
if self.recipient is None:
self.recipient = {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"number": "+79001234567"},
}
if self.from_location is None:
self.from_location = {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
}
if self.to_location is None:
self.to_location = {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
}
if self.packages is None:
self.packages = [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
]
if self.services is None:
self.services = [{"code": "INSURANCE", "parameter": "1000"}]
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
class StubPaymentAdapter:
@@ -148,9 +113,20 @@ class StubOrderRepository:
session: object,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> StoredOrder | None:
self.calls.append(
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
(
"mark_cdek_order_registered",
(
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
),
)
)
if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0)
@@ -161,6 +137,8 @@ class StubOrderRepository:
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
return order
@@ -168,14 +146,22 @@ class StubCDEKOrderAdapter:
def __init__(
self,
*,
responses: list[str] | None = None,
responses: list[CDEKOrderRegistrationResult] | None = None,
error: Exception | None = None,
) -> None:
self._responses = responses or ["cdek-order-uuid-1"]
self._responses = responses or [
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid=None,
waybill_url=None,
)
]
self._error = error
self.calls: list[InitPaymentRequest] = []
async def register_order(self, request: InitPaymentRequest) -> str:
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
self.calls.append(request)
if self._error is not None:
raise self._error
@@ -201,7 +187,15 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
order = StoredOrder()
payment_adapter = StubPaymentAdapter()
order_repository = StubOrderRepository(orders=[order])
cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"])
cdek_adapter = StubCDEKOrderAdapter(
responses=[
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
]
)
service = AggregatorService(
providers=[],
payment_adapter=payment_adapter,
@@ -217,6 +211,8 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid == "waybill-uuid-1"
assert order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
@@ -317,7 +313,18 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
mark_cdek_errors=[RuntimeError("db down"), None],
)
cdek_adapter = StubCDEKOrderAdapter(
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"]
responses=[
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
]
)
service = AggregatorService(
providers=[],