600 lines
20 KiB
Python
600 lines
20 KiB
Python
import asyncio
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import datetime, timezone
|
|
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
|
|
from tests.payment_fixtures import make_init_payment_payload
|
|
|
|
|
|
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,
|
|
"tariff_code": "535",
|
|
"provider": "cdek",
|
|
"account_email": "client@example.com",
|
|
"payload": make_init_payment_payload(),
|
|
}
|
|
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.tariff_code == "535"
|
|
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_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))
|
|
|
|
|
|
def test_get_order_by_order_uuid_returns_persisted_order() -> 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)
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.get_order_by_order_uuid(session, "order-uuid-1")
|
|
|
|
assert order is not None
|
|
assert order.order_uuid == "order-uuid-1"
|
|
assert order.payment_url == "https://pay.test/payment/1"
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
async with repository.session() as session:
|
|
order = await repository.get_order_by_order_uuid(session, "missing-order")
|
|
|
|
assert order is None
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_mark_payment_status_persists_status_and_payment_id() -> 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:
|
|
order = await repository.mark_payment_status(
|
|
session,
|
|
"order-uuid-1",
|
|
"CONFIRMED",
|
|
8347568144,
|
|
)
|
|
|
|
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 is not None
|
|
assert persisted_order.payment_status == "CONFIRMED"
|
|
assert persisted_order.tbank_payment_id == 8347568144
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_mark_payment_status_returns_none_for_missing_order() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
async with repository.session() as session:
|
|
order = await repository.mark_payment_status(
|
|
session,
|
|
"missing-order",
|
|
"CONFIRMED",
|
|
8347568144,
|
|
)
|
|
|
|
assert order is None
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> 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:
|
|
order = 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 order is not None
|
|
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))
|
|
|
|
|
|
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> 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 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))
|
|
orders = result.scalars().all()
|
|
|
|
assert len(orders) == 1
|
|
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1"
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
async with repository.session() as session:
|
|
order = await repository.mark_cdek_order_registered(
|
|
session,
|
|
"missing-order",
|
|
"cdek-order-uuid-1",
|
|
)
|
|
|
|
assert order is None
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
async def _seed_order(
|
|
repository: OrderRepository,
|
|
*,
|
|
order_uuid: str,
|
|
cdek_order_uuid: str | None,
|
|
cdek_order_status: str | None = None,
|
|
cdek_waybill_uuid: str | None = None,
|
|
cdek_waybill_url: str | None = None,
|
|
cdek_polled_at: datetime | None = None,
|
|
) -> None:
|
|
async with repository.session() as session:
|
|
await repository.create_order(
|
|
session, _make_order_data(order_uuid=order_uuid)
|
|
)
|
|
if cdek_order_uuid is not None:
|
|
await repository.mark_cdek_order_registered(
|
|
session, order_uuid, cdek_order_uuid
|
|
)
|
|
if (
|
|
cdek_order_status is not None
|
|
or cdek_waybill_uuid is not None
|
|
or cdek_polled_at is not None
|
|
):
|
|
order = await repository.get_order_by_order_uuid(session, order_uuid)
|
|
assert order is not None
|
|
if cdek_order_status is not None:
|
|
order.cdek_order_status = cdek_order_status
|
|
if cdek_waybill_uuid is not None:
|
|
order.cdek_waybill_uuid = cdek_waybill_uuid
|
|
if cdek_polled_at is not None:
|
|
order.cdek_polled_at = cdek_polled_at
|
|
if cdek_waybill_url is not None:
|
|
order = await repository.get_order_by_order_uuid(session, order_uuid)
|
|
assert order is not None
|
|
order.cdek_waybill_url = cdek_waybill_url
|
|
|
|
|
|
def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(repository, order_uuid="pending", cdek_order_uuid="o1")
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="done",
|
|
cdek_order_uuid="o2",
|
|
cdek_waybill_uuid="w2",
|
|
cdek_waybill_url="https://cdek.test/2.pdf",
|
|
)
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="invalid",
|
|
cdek_order_uuid="o3",
|
|
cdek_order_status="INVALID",
|
|
)
|
|
await _seed_order(repository, order_uuid="no-cdek", cdek_order_uuid=None)
|
|
|
|
async with repository.session() as session:
|
|
orders = await repository.list_orders_pending_waybill(session, limit=10)
|
|
|
|
assert [order.order_uuid for order in orders] == ["pending"]
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_list_orders_pending_waybill_orders_polled_at_nulls_first() -> None:
|
|
earlier = datetime(2026, 5, 24, 10, 0, tzinfo=timezone.utc)
|
|
later = datetime(2026, 5, 24, 11, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="late",
|
|
cdek_order_uuid="o-late",
|
|
cdek_polled_at=later,
|
|
)
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="early",
|
|
cdek_order_uuid="o-early",
|
|
cdek_polled_at=earlier,
|
|
)
|
|
await _seed_order(repository, order_uuid="never", cdek_order_uuid="o-never")
|
|
|
|
async with repository.session() as session:
|
|
orders = await repository.list_orders_pending_waybill(session, limit=10)
|
|
|
|
assert [order.order_uuid for order in orders] == ["never", "early", "late"]
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_order_poll_sets_status_and_waybill_uuid() -> None:
|
|
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(repository, order_uuid="o", cdek_order_uuid="cdek-o")
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_order_poll(
|
|
session,
|
|
order_uuid="o",
|
|
order_status="ACCEPTED",
|
|
waybill_uuid="waybill-1",
|
|
polled_at=polled,
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.cdek_order_status == "ACCEPTED"
|
|
assert order.cdek_waybill_uuid == "waybill-1"
|
|
assert order.cdek_polled_at == polled
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_order_poll_does_not_overwrite_existing_waybill_uuid() -> None:
|
|
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="o",
|
|
cdek_order_uuid="cdek-o",
|
|
cdek_waybill_uuid="existing-waybill",
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_order_poll(
|
|
session,
|
|
order_uuid="o",
|
|
order_status="ACCEPTED",
|
|
waybill_uuid="new-waybill",
|
|
polled_at=polled,
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.cdek_waybill_uuid == "existing-waybill"
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
|
|
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="o",
|
|
cdek_order_uuid="cdek-o",
|
|
cdek_waybill_uuid="waybill-1",
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_poll(
|
|
session,
|
|
order_uuid="o",
|
|
waybill_url="https://cdek.test/1.pdf",
|
|
polled_at=polled,
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
|
|
assert order.cdek_polled_at == polled
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_poll(
|
|
session,
|
|
order_uuid="o",
|
|
waybill_url="https://cdek.test/REPLACED.pdf",
|
|
polled_at=polled,
|
|
)
|
|
assert order is not None
|
|
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_at() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="ready",
|
|
cdek_order_uuid="o1",
|
|
cdek_waybill_uuid="w1",
|
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
|
)
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="no-url",
|
|
cdek_order_uuid="o2",
|
|
cdek_waybill_uuid="w2",
|
|
)
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="already-sent",
|
|
cdek_order_uuid="o3",
|
|
cdek_waybill_uuid="w3",
|
|
cdek_waybill_url="https://cdek.test/3.pdf",
|
|
)
|
|
async with repository.session() as session:
|
|
sent = await repository.get_order_by_order_uuid(session, "already-sent")
|
|
assert sent is not None
|
|
sent.waybill_email_sent_at = datetime(
|
|
2026, 5, 24, 12, 0, tzinfo=timezone.utc
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
orders = await repository.list_orders_pending_waybill_email(
|
|
session, limit=10
|
|
)
|
|
|
|
assert [order.order_uuid for order in orders] == ["ready"]
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_list_orders_pending_waybill_email_orders_by_created_at_asc() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="first",
|
|
cdek_order_uuid="o1",
|
|
cdek_waybill_uuid="w1",
|
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
|
)
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="second",
|
|
cdek_order_uuid="o2",
|
|
cdek_waybill_uuid="w2",
|
|
cdek_waybill_url="https://cdek.test/2.pdf",
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
orders = await repository.list_orders_pending_waybill_email(
|
|
session, limit=10
|
|
)
|
|
|
|
assert [order.order_uuid for order in orders] == ["first", "second"]
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_waybill_email_sent_sets_timestamp_once() -> None:
|
|
first_sent = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
|
second_sent = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="o",
|
|
cdek_order_uuid="cdek-o",
|
|
cdek_waybill_uuid="w",
|
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_email_sent(
|
|
session, order_uuid="o", sent_at=first_sent
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.waybill_email_sent_at == first_sent
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_email_sent(
|
|
session, order_uuid="o", sent_at=second_sent
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.waybill_email_sent_at is not None
|
|
assert order.waybill_email_sent_at.replace(
|
|
tzinfo=None
|
|
) == first_sent.replace(tzinfo=None)
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_waybill_email_sent_returns_none_for_missing_order() -> None:
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_email_sent(
|
|
session,
|
|
order_uuid="missing",
|
|
sent_at=datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc),
|
|
)
|
|
|
|
assert order is None
|
|
|
|
asyncio.run(_with_repository(run))
|
|
|
|
|
|
def test_record_waybill_poll_updates_polled_at_when_url_is_none() -> None:
|
|
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
|
|
|
async def run(
|
|
repository: OrderRepository,
|
|
_session_factory: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
await _seed_order(
|
|
repository,
|
|
order_uuid="o",
|
|
cdek_order_uuid="cdek-o",
|
|
cdek_waybill_uuid="waybill-1",
|
|
)
|
|
|
|
async with repository.session() as session:
|
|
order = await repository.record_waybill_poll(
|
|
session,
|
|
order_uuid="o",
|
|
waybill_url=None,
|
|
polled_at=polled,
|
|
)
|
|
|
|
assert order is not None
|
|
assert order.cdek_waybill_url is None
|
|
assert order.cdek_polled_at == polled
|
|
|
|
asyncio.run(_with_repository(run))
|