Добавлено сохранение заказов в postgres
This commit is contained in:
@@ -302,6 +302,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/cdek_adapter_test"
|
||||
|
||||
observability:
|
||||
enabled: false
|
||||
service_name: "cdek-adapter-test-service"
|
||||
|
||||
@@ -111,28 +111,56 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
def test_create_payment_link_maps_4xx_to_request_error() -> None:
|
||||
response = httpx.Response(
|
||||
400,
|
||||
json={"Success": False, "Message": "bad request"},
|
||||
json={
|
||||
"Success": False,
|
||||
"ErrorCode": "101",
|
||||
"Message": "bad request",
|
||||
"Details": "invalid token",
|
||||
},
|
||||
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
adapter = _make_adapter(http_client)
|
||||
|
||||
with pytest.raises(TBankPaymentRequestError, match="status 400"):
|
||||
with pytest.raises(TBankPaymentRequestError) as exc_info:
|
||||
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.error_code == "101"
|
||||
assert exc_info.value.provider_message == "bad request"
|
||||
assert exc_info.value.details == "invalid token"
|
||||
assert str(exc_info.value) == (
|
||||
"TBank payment init request was rejected. "
|
||||
"status_code=400 error_code=101 message=bad request details=invalid token"
|
||||
)
|
||||
|
||||
|
||||
def test_create_payment_link_maps_unsuccessful_payload_to_request_error() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"Success": False, "Message": "bad request"},
|
||||
json={
|
||||
"Success": False,
|
||||
"ErrorCode": "102",
|
||||
"Message": "duplicate order id",
|
||||
"Details": "OrderId must be unique",
|
||||
},
|
||||
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
adapter = _make_adapter(http_client)
|
||||
|
||||
with pytest.raises(TBankPaymentRequestError):
|
||||
with pytest.raises(TBankPaymentRequestError) as exc_info:
|
||||
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||
|
||||
assert exc_info.value.status_code is None
|
||||
assert exc_info.value.error_code == "102"
|
||||
assert exc_info.value.provider_message == "duplicate order id"
|
||||
assert exc_info.value.details == "OrderId must be unique"
|
||||
assert str(exc_info.value) == (
|
||||
"TBank payment init request was rejected. "
|
||||
"error_code=102 message=duplicate order id details=OrderId must be unique"
|
||||
)
|
||||
|
||||
|
||||
def test_create_payment_link_maps_transport_errors_to_client_error() -> None:
|
||||
request = httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init")
|
||||
|
||||
@@ -21,6 +21,9 @@ tbank_payment:
|
||||
terminal_key: "yaml-terminal-key"
|
||||
password: "yaml-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/default"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -29,6 +29,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/invalid_price"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -18,6 +18,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_adapter"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -29,6 +29,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_address"
|
||||
|
||||
observability:
|
||||
enabled: false
|
||||
service_name: "missing-address-suggestions-service"
|
||||
|
||||
@@ -29,6 +29,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -29,6 +29,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability_endpoint"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -28,6 +28,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_price"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -24,6 +24,9 @@ tbank_payment:
|
||||
retry_attempts: 1
|
||||
retry_backoff_seconds: 0.05
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
|
||||
@@ -132,6 +132,10 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
||||
assert settings.tbank_payment.timeout_seconds == 10.0
|
||||
assert settings.tbank_payment.retry_attempts == 2
|
||||
assert settings.tbank_payment.retry_backoff_seconds == 0.2
|
||||
assert (
|
||||
settings.postgres.dsn
|
||||
== "postgresql+asyncpg://postgres:postgres@localhost:5432/g2s_aggregator_test"
|
||||
)
|
||||
assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
|
||||
assert (
|
||||
settings.address_suggestions.dadata.url
|
||||
@@ -199,6 +203,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert first.service.provider_timeout_seconds == 10.0
|
||||
assert first.business_logic.provider_price_multiplier == Decimal("1.0")
|
||||
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||
assert first.postgres.dsn.endswith("/g2s_aggregator_test")
|
||||
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
|
||||
assert first.observability.service_name == "g2s-aggregator-test"
|
||||
get_settings.cache_clear()
|
||||
@@ -224,6 +229,10 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
|
||||
assert settings.tbank_payment.timeout_seconds == 4.25
|
||||
assert settings.tbank_payment.retry_attempts == 1
|
||||
assert settings.tbank_payment.retry_backoff_seconds == 0.05
|
||||
assert (
|
||||
settings.postgres.dsn
|
||||
== "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
|
||||
)
|
||||
assert settings.address_suggestions.country_to_provider == {
|
||||
"RU": "dadata",
|
||||
"AM": "yandex_geosuggest",
|
||||
|
||||
@@ -185,6 +185,9 @@ tbank_payment:
|
||||
terminal_key: "test-terminal-key"
|
||||
password: "test-password"
|
||||
|
||||
postgres:
|
||||
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/repository_test"
|
||||
|
||||
observability:
|
||||
enabled: false
|
||||
service_name: "repository-test-service"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.repositories.order import OrderData, OrderRepository
|
||||
from app.repositories.order.models import Base, Order
|
||||
|
||||
|
||||
def _make_order_data(**overrides: object) -> OrderData:
|
||||
payload: dict[str, Any] = {
|
||||
"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",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return OrderData(**payload)
|
||||
|
||||
|
||||
async def _with_repository(
|
||||
test_fn: Callable[
|
||||
[OrderRepository, async_sessionmaker[AsyncSession]],
|
||||
Awaitable[None],
|
||||
],
|
||||
) -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
await test_fn(OrderRepository(session_factory=session_factory), session_factory)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_create_order_persists_all_required_fields() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
order_data = _make_order_data()
|
||||
|
||||
async with repository.session() as session:
|
||||
order = 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 order.id == persisted_order.id
|
||||
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.created_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,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
order_data = _make_order_data()
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
@@ -6,6 +6,7 @@ from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.repositories.order import OrderData
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
@@ -34,6 +35,33 @@ class StubPaymentAdapter:
|
||||
return self._response
|
||||
|
||||
|
||||
class StubOrderSessionContext:
|
||||
def __init__(self, session: object) -> None:
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class StubOrderRepository:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self._error = error
|
||||
self.session_value = object()
|
||||
self.calls: list[tuple[object, OrderData]] = []
|
||||
|
||||
def session(self) -> StubOrderSessionContext:
|
||||
return StubOrderSessionContext(self.session_value)
|
||||
|
||||
async def create_order(self, session: object, order_data: OrderData) -> object:
|
||||
self.calls.append((session, order_data))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return object()
|
||||
|
||||
|
||||
def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
@@ -88,6 +116,83 @@ def test_init_payment_delegates_to_adapter_and_returns_payment_url() -> None:
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
|
||||
|
||||
def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||
request = _make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
order_repository = StubOrderRepository()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
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",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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")
|
||||
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
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()
|
||||
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
|
||||
|
||||
@@ -18,7 +18,8 @@ def test_compose_defines_required_services() -> None:
|
||||
compose = _load_compose()
|
||||
services = compose.get("services")
|
||||
assert isinstance(services, dict)
|
||||
assert {"app", "redis"}.issubset(services.keys())
|
||||
assert {"app", "redis", "postgres"}.issubset(services.keys())
|
||||
assert "postgres" in services["app"]["depends_on"]
|
||||
|
||||
|
||||
def test_smoke_command_sequence_is_documented() -> None:
|
||||
|
||||
Reference in New Issue
Block a user