Добавлено сохранение заказов в postgres

This commit is contained in:
Раис Юсупалиев
2026-04-18 00:33:45 +03:00
parent 2b201a08be
commit bddac60965
38 changed files with 971 additions and 17 deletions
@@ -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"
+32 -4
View File
@@ -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"
+9
View File
@@ -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",
+3
View File
@@ -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"
+145
View File
@@ -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))
+105
View File
@@ -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"))
+2 -1
View File
@@ -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: