import asyncio from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from app.adapters.delivery_providers.cdek.order_mapper import ( CDEKOrderInfo, CDEKWaybillInfo, ) from app.adapters.delivery_providers.cse.order_mapper import CSEOrderInfo from app.services.waybill_poller import WaybillPollerService @dataclass class StoredOrder: order_uuid: str provider: str = "cdek" provider_order_id: str | None = None provider_order_status: str | None = None provider_waybill_id: str | None = None provider_waybill_url: str | None = None provider_polled_at: datetime | None = None class StubSessionContext: 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 StubRepository: def __init__(self, orders: list[StoredOrder]) -> None: self._orders = {order.order_uuid: order for order in orders} self.session_value = object() self.calls: list[tuple[str, dict[str, Any]]] = [] def session(self) -> StubSessionContext: return StubSessionContext(self.session_value) async def list_orders_pending_waybill( self, session: object, *, limit: int ) -> list[StoredOrder]: self.calls.append(("list", {"session": session, "limit": limit})) return list(self._orders.values()) async def record_order_poll( self, session: object, *, order_uuid: str, order_status: str | None, waybill_uuid: str | None, polled_at: datetime, ) -> StoredOrder | None: self.calls.append( ( "record_order_poll", { "order_uuid": order_uuid, "order_status": order_status, "waybill_uuid": waybill_uuid, "polled_at": polled_at, }, ) ) order = self._orders.get(order_uuid) if order is None: return None order.provider_order_status = order_status if waybill_uuid is not None and order.provider_waybill_id is None: order.provider_waybill_id = waybill_uuid order.provider_polled_at = polled_at return order async def record_waybill_poll( self, session: object, *, order_uuid: str, waybill_url: str | None, polled_at: datetime, ) -> StoredOrder | None: self.calls.append( ( "record_waybill_poll", { "order_uuid": order_uuid, "waybill_url": waybill_url, "polled_at": polled_at, }, ) ) order = self._orders.get(order_uuid) if order is None: return None if waybill_url is not None and order.provider_waybill_url is None: order.provider_waybill_url = waybill_url order.provider_polled_at = polled_at return order class StubOrderInfoAdapter: def __init__(self, results: dict[str, CDEKOrderInfo | Exception]) -> None: self._results = results self.calls: list[str] = [] async def get_order(self, provider_order_id: str) -> CDEKOrderInfo: self.calls.append(provider_order_id) result = self._results[provider_order_id] if isinstance(result, Exception): raise result return result class StubWaybillInfoAdapter: def __init__(self, results: dict[str, CDEKWaybillInfo | Exception]) -> None: self._results = results self.calls: list[str] = [] async def get_waybill(self, provider_waybill_id: str) -> CDEKWaybillInfo: self.calls.append(provider_waybill_id) result = self._results[provider_waybill_id] if isinstance(result, Exception): raise result return result _POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc) _CSE_WAYBILL_CREATED_STATUS = ( "На основании заказа оформлена накладная." ) def _make_service( *, repository: StubRepository, order_info: StubOrderInfoAdapter | None = None, waybill_info: StubWaybillInfoAdapter | None = None, ) -> WaybillPollerService: return WaybillPollerService( order_repository=repository, order_info_adapters={"cdek": order_info or StubOrderInfoAdapter({})}, waybill_info_adapters={"cdek": waybill_info or StubWaybillInfoAdapter({})}, batch_size=10, datetime_now=lambda: _POLLED_AT, ) def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None: order = StoredOrder(order_uuid="o", provider_order_id="cdek-o") repo = StubRepository([order]) order_info = StubOrderInfoAdapter( { "cdek-o": CDEKOrderInfo( order_uuid="cdek-o", status_code="ACCEPTED", waybill_uuid="waybill-1", ) } ) service = _make_service(repository=repo, order_info=order_info) summary = asyncio.run(service.poll_once()) assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0 assert order_info.calls == ["cdek-o"] assert order.provider_order_status == "ACCEPTED" assert order.provider_waybill_id == "waybill-1" assert order.provider_polled_at == _POLLED_AT def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None: order = StoredOrder( order_uuid="o", provider_order_id="cdek-o", provider_waybill_id="waybill-1", ) repo = StubRepository([order]) waybill_info = StubWaybillInfoAdapter( { "waybill-1": CDEKWaybillInfo( waybill_uuid="waybill-1", url="https://cdek.test/1.pdf", ) } ) service = _make_service(repository=repo, waybill_info=waybill_info) summary = asyncio.run(service.poll_once()) assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0 assert waybill_info.calls == ["waybill-1"] assert order.provider_waybill_url == "https://cdek.test/1.pdf" assert order.provider_polled_at == _POLLED_AT def test_poll_once_fetches_cse_waybill_number_from_order_tracking() -> None: order = StoredOrder( order_uuid="o", provider="cse", provider_order_id="CSE-000123", ) repo = StubRepository([order]) order_info = StubOrderInfoAdapter( { "CSE-000123": CSEOrderInfo( order_number="CSE-000123", status_code=_CSE_WAYBILL_CREATED_STATUS, waybill_number="496-AA-1676378", ) } ) service = WaybillPollerService( order_repository=repo, order_info_adapters={"cse": order_info}, waybill_info_adapters={}, batch_size=10, datetime_now=lambda: _POLLED_AT, ) summary = asyncio.run(service.poll_once()) assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0 assert order_info.calls == ["CSE-000123"] assert order.provider_order_status == _CSE_WAYBILL_CREATED_STATUS assert order.provider_waybill_id == "496-AA-1676378" assert order.provider_polled_at == _POLLED_AT def test_poll_once_records_terminal_status_without_waybill() -> None: order = StoredOrder(order_uuid="o", provider_order_id="cdek-o") repo = StubRepository([order]) order_info = StubOrderInfoAdapter( { "cdek-o": CDEKOrderInfo( order_uuid="cdek-o", status_code="INVALID", waybill_uuid=None, ) } ) service = _make_service(repository=repo, order_info=order_info) summary = asyncio.run(service.poll_once()) assert summary.succeeded == 1 assert order.provider_order_status == "INVALID" assert order.provider_waybill_id is None def test_poll_once_failure_on_one_order_does_not_break_batch() -> None: failing = StoredOrder(order_uuid="bad", provider_order_id="cdek-bad") good = StoredOrder( order_uuid="good", provider_order_id="cdek-good", provider_waybill_id="waybill-good", ) repo = StubRepository([failing, good]) order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")}) waybill_info = StubWaybillInfoAdapter( { "waybill-good": CDEKWaybillInfo( waybill_uuid="waybill-good", url="https://cdek.test/good.pdf", ) } ) service = _make_service( repository=repo, order_info=order_info, waybill_info=waybill_info ) summary = asyncio.run(service.poll_once()) assert summary.processed == 2 assert summary.succeeded == 1 assert summary.failed == 1 assert good.provider_waybill_url == "https://cdek.test/good.pdf" def test_run_forever_exits_when_stop_event_is_set() -> None: repo = StubRepository([]) service = _make_service(repository=repo) async def run() -> None: stop_event = asyncio.Event() stop_event.set() await asyncio.wait_for( service.run_forever(interval_seconds=0.01, stop_event=stop_event), timeout=1.0, ) asyncio.run(run())