Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 58s
Deploy / deploy (push) Successful in 58s
This commit is contained in:
@@ -85,7 +85,7 @@ def test_provider_register_order_posts_payload_and_maps_response() -> None:
|
||||
http_client = SequenceHTTPClient([response])
|
||||
provider = _make_provider(http_client)
|
||||
|
||||
result = asyncio.run(provider.register_order(make_init_payment_request()))
|
||||
result = asyncio.run(provider.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid",
|
||||
@@ -118,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_init_payment_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
@@ -149,7 +149,7 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
|
||||
retry_attempts=2,
|
||||
)
|
||||
|
||||
result = asyncio.run(client.register_order(make_init_payment_request()))
|
||||
result = asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="existing-cdek-order-uuid",
|
||||
@@ -186,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_init_payment_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||
|
||||
assert len(http_client.calls) == 2
|
||||
assert sleep_calls == [0.25]
|
||||
@@ -207,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_init_payment_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||
|
||||
@@ -16,7 +16,7 @@ from tests.payment_fixtures import make_init_payment_request
|
||||
|
||||
|
||||
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
assert payload["type"] == 2
|
||||
@@ -24,7 +24,7 @@ def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> Non
|
||||
|
||||
|
||||
def test_order_payload_requests_waybill_print() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["print"] == "WAYBILL"
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> Non
|
||||
|
||||
|
||||
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
||||
assert payload["recipient"]["phones"] == [
|
||||
@@ -95,7 +95,7 @@ def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
||||
|
||||
|
||||
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["from_location"] == {
|
||||
"code": 7017,
|
||||
@@ -110,7 +110,7 @@ def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
||||
|
||||
|
||||
def test_order_payload_for_parcel_includes_dimensions() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
package = payload["packages"][0]
|
||||
assert package["weight"] == 1000
|
||||
@@ -138,7 +138,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
payload = map_cdek_order_request(request)
|
||||
payload = map_cdek_order_request(request, "order-uuid-1")
|
||||
package = payload["packages"][0]
|
||||
|
||||
assert package["weight"] == 500
|
||||
@@ -148,7 +148,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||
|
||||
|
||||
def test_order_payload_omits_shipment_and_delivery_point() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert "shipment_point" not in payload
|
||||
assert "delivery_point" not in payload
|
||||
@@ -168,7 +168,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
||||
}
|
||||
)
|
||||
|
||||
sender = map_cdek_order_request(request)["sender"]
|
||||
sender = map_cdek_order_request(request, "order-uuid-1")["sender"]
|
||||
|
||||
assert sender["contragent_type"] == "LEGAL_ENTITY"
|
||||
assert sender["company"] == "Romashka LLC"
|
||||
@@ -177,7 +177,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
||||
|
||||
|
||||
def test_order_payload_propagates_description_to_comments_without_items() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["comment"] == "Headphones"
|
||||
assert payload["packages"][0]["comment"] == "Headphones"
|
||||
@@ -186,14 +186,14 @@ def test_order_payload_propagates_description_to_comments_without_items() -> Non
|
||||
|
||||
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
||||
payload = map_cdek_order_request(
|
||||
make_init_payment_request(content={"description": None})
|
||||
make_init_payment_request(content={"description": None}), "order-uuid-1"
|
||||
)
|
||||
|
||||
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())
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["sender"]["company"] == "Petr Petrov"
|
||||
assert payload["recipient"]["company"] == "Ivan Ivanov"
|
||||
|
||||
@@ -164,3 +164,24 @@ def test_send_email_passes_none_when_credentials_blank() -> None:
|
||||
call = send.calls[0]
|
||||
assert call["username"] is None
|
||||
assert call["password"] is None
|
||||
|
||||
|
||||
def test_send_email_without_attachment_builds_plain_text_message() -> None:
|
||||
send = StubSend()
|
||||
sender = _make_sender(send)
|
||||
|
||||
asyncio.run(
|
||||
sender.send_email(
|
||||
to="client@example.com",
|
||||
subject="Оплата принята",
|
||||
body="Ваша оплата принята.",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(send.calls) == 1
|
||||
message: EmailMessage = send.calls[0]["message"]
|
||||
assert message["From"] == "no-reply@test"
|
||||
assert message["To"] == "client@example.com"
|
||||
assert message["Subject"] == "Оплата принята"
|
||||
assert "Ваша оплата принята." in message.get_content()
|
||||
assert list(message.iter_attachments()) == []
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
"https://example.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-1"
|
||||
"test-password"
|
||||
"https://example.test/payment/success"
|
||||
"https://example.test/payment/success/order-uuid-1"
|
||||
"TBankTest"
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
@@ -109,7 +109,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
"Amount": 125000,
|
||||
"OrderId": "order-uuid-1",
|
||||
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://example.test/payment/success",
|
||||
"SuccessURL": "https://example.test/payment/success/order-uuid-1",
|
||||
"Token": expected_token,
|
||||
},
|
||||
"data": None,
|
||||
@@ -159,7 +159,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-2"
|
||||
"config-password"
|
||||
"https://merchant.test/payment/success"
|
||||
"https://merchant.test/payment/success/order-uuid-2"
|
||||
"ConfigTerminal"
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
@@ -169,7 +169,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
"Amount": 9900,
|
||||
"OrderId": "order-uuid-2",
|
||||
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://merchant.test/payment/success",
|
||||
"SuccessURL": "https://merchant.test/payment/success/order-uuid-2",
|
||||
"Token": expected_token,
|
||||
}
|
||||
assert http_client.calls[0]["timeout"] == 6.25
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid")
|
||||
invalid_payload["account_email"] = invalid_payload.pop("accountEmail")
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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": "Дубай",
|
||||
@@ -54,6 +53,8 @@ def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
|
||||
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||
"accountEmail": "client@example.com",
|
||||
"agreePrivacy": True,
|
||||
"agreeTerms": True,
|
||||
"systemData": {
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
|
||||
@@ -120,6 +120,7 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_uuid_factory=lambda: "order-uuid-1",
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
@@ -141,6 +142,7 @@ def test_init_payment_persists_order_payload_after_successful_payment_link() ->
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_repository=order_repository,
|
||||
order_uuid_factory=lambda: "order-uuid-1",
|
||||
)
|
||||
|
||||
asyncio.run(service.init_payment(request))
|
||||
@@ -166,6 +168,7 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
order_repository=order_repository,
|
||||
order_uuid_factory=lambda: "order-uuid-1",
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
@@ -34,6 +34,7 @@ class StoredOrder:
|
||||
cdek_order_uuid: str | None = None
|
||||
cdek_waybill_uuid: str | None = None
|
||||
cdek_waybill_url: str | None = None
|
||||
payment_email_sent_at: object | None = None
|
||||
|
||||
|
||||
class StubPaymentAdapter:
|
||||
@@ -128,6 +129,34 @@ class StubOrderRepository:
|
||||
order.cdek_order_uuid = cdek_order_uuid
|
||||
return order
|
||||
|
||||
async def record_payment_email_sent(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
order_uuid: str,
|
||||
sent_at: object,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(
|
||||
("record_payment_email_sent", (session, order_uuid, sent_at))
|
||||
)
|
||||
order = self._orders.get(order_uuid)
|
||||
if order is not None and order.payment_email_sent_at is None:
|
||||
order.payment_email_sent_at = sent_at
|
||||
return order
|
||||
|
||||
|
||||
class StubEmailSender:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self._error = error
|
||||
self.calls: list[dict[str, str]] = []
|
||||
|
||||
async def send_email(
|
||||
self, *, to: str, subject: str, body: str
|
||||
) -> None:
|
||||
self.calls.append({"to": to, "subject": subject, "body": body})
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
|
||||
class StubCDEKOrderAdapter:
|
||||
def __init__(
|
||||
@@ -144,12 +173,12 @@ class StubCDEKOrderAdapter:
|
||||
)
|
||||
]
|
||||
self._error = error
|
||||
self.calls: list[InitPaymentRequest] = []
|
||||
self.calls: list[tuple[InitPaymentRequest, str]] = []
|
||||
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
self, request: InitPaymentRequest, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
self.calls.append(request)
|
||||
self.calls.append((request, order_uuid))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._responses.pop(0)
|
||||
@@ -201,7 +230,7 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
|
||||
assert order.cdek_waybill_uuid is None
|
||||
assert order.cdek_waybill_url is None
|
||||
assert len(cdek_adapter.calls) == 1
|
||||
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
|
||||
assert cdek_adapter.calls[0][1] == "order-uuid-1"
|
||||
|
||||
|
||||
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
|
||||
@@ -330,7 +359,95 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
|
||||
|
||||
assert result == "OK"
|
||||
assert order.cdek_order_uuid == "same-cdek-order-uuid"
|
||||
assert [request.order_uuid for request in cdek_adapter.calls] == [
|
||||
assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [
|
||||
"order-uuid-1",
|
||||
"order-uuid-1",
|
||||
]
|
||||
|
||||
|
||||
def test_confirmed_notification_sends_payment_confirmation_email() -> None:
|
||||
order = StoredOrder()
|
||||
email_sender = StubEmailSender()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||
email_sender=email_sender,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(_make_notification())
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert len(email_sender.calls) == 1
|
||||
assert email_sender.calls[0]["to"] == "client@example.com"
|
||||
assert "order-uuid-1" in email_sender.calls[0]["subject"]
|
||||
assert order.payment_email_sent_at is not None
|
||||
|
||||
|
||||
def test_non_confirmed_notification_does_not_send_email() -> None:
|
||||
order = StoredOrder()
|
||||
email_sender = StubEmailSender()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||
email_sender=email_sender,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(
|
||||
_make_notification(Status="AUTHORIZED")
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert email_sender.calls == []
|
||||
assert order.payment_email_sent_at is None
|
||||
|
||||
|
||||
def test_duplicate_notification_does_not_resend_payment_email() -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
order = StoredOrder(
|
||||
cdek_order_uuid="existing-cdek-order-uuid",
|
||||
payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
email_sender = StubEmailSender()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||
email_sender=email_sender,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(_make_notification())
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert email_sender.calls == []
|
||||
|
||||
|
||||
def test_email_failure_does_not_break_notification_handling() -> None:
|
||||
order = StoredOrder()
|
||||
email_sender = StubEmailSender(error=RuntimeError("smtp down"))
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||
email_sender=email_sender,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(_make_notification())
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert len(email_sender.calls) == 1
|
||||
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
|
||||
Reference in New Issue
Block a user