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, "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))