298 lines
9.3 KiB
Python
298 lines
9.3 KiB
Python
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())
|