Добавлен поллер накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 20:24:23 +03:00
parent c494d50566
commit 5526f90cb3
23 changed files with 1481 additions and 61 deletions
@@ -0,0 +1,173 @@
import asyncio
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
class StubAuthClient:
async def get_access_token(self) -> str:
return "test-token"
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def get(
self,
url: str,
*,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_client(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKClient:
kwargs.setdefault("retry_attempts", 0)
return CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
timeout_seconds=7.5,
**kwargs,
)
def test_get_order_parses_status_and_waybill_uuid() -> None:
response = httpx.Response(
200,
json={
"entity": {
"uuid": "cdek-order-uuid",
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(client.get_order("cdek-order-uuid"))
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code="ACCEPTED",
waybill_uuid="waybill-uuid-1",
)
assert http_client.calls[0]["url"] == (
"https://api.cdek.test/v2/orders/cdek-order-uuid"
)
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
def test_get_order_retries_on_5xx_and_succeeds() -> None:
flaky = httpx.Response(
503,
json={"message": "temporary"},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
)
success = httpx.Response(
200,
json={"entity": {"uuid": "x", "statuses": []}},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
)
http_client = SequenceHTTPClient([flaky, success])
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
client = _make_client(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.1,
sleep=fake_sleep,
)
result = asyncio.run(client.get_order("x"))
assert result.order_uuid == "x"
assert len(http_client.calls) == 2
assert sleep_calls == [0.1]
def test_get_order_raises_request_error_on_4xx() -> None:
response = httpx.Response(
404,
json={"requests": [{"errors": [{"code": "v2_not_found"}]}]},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/missing"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKRequestError, match="status 404"):
asyncio.run(client.get_order("missing"))
def test_get_waybill_returns_url_when_present() -> None:
response = httpx.Response(
200,
json={
"entity": {
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
},
request=httpx.Request(
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(client.get_waybill("waybill-uuid-1"))
assert result == CDEKWaybillInfo(
waybill_uuid="waybill-uuid-1",
url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == (
"https://api.cdek.test/v2/print/orders/waybill-uuid-1"
)
def test_get_waybill_raises_client_error_for_invalid_payload() -> None:
response = httpx.Response(
200,
json={"entity": {}},
request=httpx.Request(
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.get_waybill("waybill-uuid-1"))
@@ -1,10 +1,16 @@
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKOrderMappingError,
CDEKOrderRegistrationResult,
CDEKWaybillInfo,
compose_address_line,
map_cdek_existing_order_response,
map_cdek_order_info_response,
map_cdek_order_request,
map_cdek_order_response,
map_cdek_waybill_info_response,
)
import pytest
from app.schemas.payment import Address
from tests.payment_fixtures import make_init_payment_request
@@ -207,3 +213,72 @@ def test_compose_address_line_handles_empty_apartment() -> None:
)
assert compose_address_line(address) == "Москва, Lenina, 1"
def test_map_cdek_order_info_response_picks_latest_status_by_date_time() -> None:
result = map_cdek_order_info_response(
{
"entity": {
"uuid": "cdek-order-uuid",
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"},
{"code": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
}
)
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code="INVALID",
waybill_uuid="waybill-uuid-1",
)
def test_map_cdek_order_info_response_returns_none_status_when_statuses_empty() -> None:
result = map_cdek_order_info_response(
{"entity": {"uuid": "cdek-order-uuid", "statuses": []}}
)
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code=None,
waybill_uuid=None,
)
def test_map_cdek_order_info_response_raises_for_missing_entity() -> None:
with pytest.raises(CDEKOrderMappingError):
map_cdek_order_info_response({"requests": []})
def test_map_cdek_waybill_info_response_returns_url_when_present() -> None:
result = map_cdek_waybill_info_response(
{
"entity": {
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
}
)
assert result == CDEKWaybillInfo(
waybill_uuid="waybill-uuid-1",
url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_waybill_info_response_returns_none_url_when_missing() -> None:
result = map_cdek_waybill_info_response(
{"entity": {"uuid": "waybill-uuid-1"}}
)
assert result == CDEKWaybillInfo(waybill_uuid="waybill-uuid-1", url=None)
def test_map_cdek_waybill_info_response_raises_for_missing_uuid() -> None:
with pytest.raises(CDEKOrderMappingError):
map_cdek_waybill_info_response({"entity": {}})
+19
View File
@@ -0,0 +1,19 @@
import pytest
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES, is_terminal_order_status
@pytest.mark.parametrize(
"code", sorted(TERMINAL_ORDER_STATUSES)
)
def test_terminal_statuses_are_terminal(code: str) -> None:
assert is_terminal_order_status(code) is True
@pytest.mark.parametrize("code", ["ACCEPTED", "CREATED", "RECEIVED_AT_SHIPMENT_WAREHOUSE", ""])
def test_non_terminal_statuses_are_not_terminal(code: str) -> None:
assert is_terminal_order_status(code) is False
def test_none_status_is_not_terminal() -> None:
assert is_terminal_order_status(None) is False
+221 -31
View File
@@ -1,5 +1,6 @@
import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import Any
import pytest
@@ -173,7 +174,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
@@ -186,8 +187,6 @@ def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
session,
"order-uuid-1",
"cdek-order-uuid-1",
"waybill-uuid-1",
"https://cdek.test/waybill/1.pdf",
)
async with session_factory() as session:
@@ -197,34 +196,6 @@ def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
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 == "waybill-uuid-1"
assert persisted_order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_allows_missing_waybill_fields() -> 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 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.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
@@ -279,3 +250,222 @@ def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
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_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))
+3 -16
View File
@@ -113,20 +113,9 @@ class StubOrderRepository:
session: object,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> StoredOrder | None:
self.calls.append(
(
"mark_cdek_order_registered",
(
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
),
)
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
)
if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0)
@@ -137,8 +126,6 @@ class StubOrderRepository:
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
return order
@@ -211,8 +198,8 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid == "waybill-uuid-1"
assert order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
assert order.cdek_waybill_uuid is None
assert order.cdek_waybill_url is None
assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
+259
View File
@@ -0,0 +1,259 @@
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.services.waybill_poller import WaybillPollerService
@dataclass
class StoredOrder:
order_uuid: str
cdek_order_uuid: str | None = 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
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.cdek_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
order.cdek_waybill_uuid = waybill_uuid
order.cdek_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.cdek_waybill_url is None:
order.cdek_waybill_url = waybill_url
order.cdek_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, cdek_order_uuid: str) -> CDEKOrderInfo:
self.calls.append(cdek_order_uuid)
result = self._results[cdek_order_uuid]
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, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
self.calls.append(cdek_waybill_uuid)
result = self._results[cdek_waybill_uuid]
if isinstance(result, Exception):
raise result
return result
_POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
def _make_service(
*,
repository: StubRepository,
order_info: StubOrderInfoAdapter | None = None,
waybill_info: StubWaybillInfoAdapter | None = None,
) -> WaybillPollerService:
return WaybillPollerService(
order_repository=repository,
order_info_adapter=order_info or StubOrderInfoAdapter({}),
waybill_info_adapter=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", cdek_order_uuid="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.cdek_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
order = StoredOrder(
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="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.cdek_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_records_terminal_status_without_waybill() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="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.cdek_order_status == "INVALID"
assert order.cdek_waybill_uuid is None
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
failing = StoredOrder(order_uuid="bad", cdek_order_uuid="cdek-bad")
good = StoredOrder(
order_uuid="good",
cdek_order_uuid="cdek-good",
cdek_waybill_uuid="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.cdek_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())
View File
+88
View File
@@ -0,0 +1,88 @@
"""Smoke test for the waybill poller worker entrypoint.
The test patches all external dependencies (HTTP client, DB engine, CDEK,
service) and verifies that `_run` wires the components together and exits
cleanly when the stop event is set.
"""
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from app.config import (
AdapterConfig,
ObservabilityConfig,
PostgresConfig,
Settings,
TBankPaymentAuthConfig,
TBankPaymentConfig,
WaybillPollerConfig,
)
from app.workers.waybill_poller import _run
def _make_settings() -> Settings:
return Settings(
adapter=AdapterConfig(
cdek_base_url="https://api.cdek.test/v2",
cdek_client_id="id",
cdek_client_secret="secret",
),
tbank_payment=TBankPaymentConfig(
init_url="https://pay.test/init",
notification_url="https://pay.test/notify",
success_url="https://pay.test/success",
auth=TBankPaymentAuthConfig(terminal_key="t", password="p"),
),
postgres=PostgresConfig(dsn="postgresql+asyncpg://u:p@h/db"),
observability=ObservabilityConfig(
service_name="svc", otlp_endpoint="http://otlp"
),
waybill_poller=WaybillPollerConfig(interval_seconds=0.01, batch_size=5),
business_logic={"provider_price_multiplier": "1.0"},
)
def test_run_exits_when_stop_event_is_set() -> None:
settings = _make_settings()
http_client_instance = MagicMock()
http_client_instance.aclose = AsyncMock()
engine_instance = MagicMock()
engine_instance.dispose = AsyncMock()
run_forever_mock = AsyncMock()
with (
patch(
"app.workers.waybill_poller.httpx.AsyncClient",
return_value=http_client_instance,
),
patch("app.workers.waybill_poller.CDEKAuthClient"),
patch("app.workers.waybill_poller.CDEKClient"),
patch(
"app.workers.waybill_poller.create_postgres_engine",
return_value=engine_instance,
),
patch("app.workers.waybill_poller.create_postgres_session_factory"),
patch("app.workers.waybill_poller.OrderRepository"),
patch(
"app.workers.waybill_poller.WaybillPollerService"
) as service_cls_mock,
):
service_instance = MagicMock()
service_instance.run_forever = run_forever_mock
service_cls_mock.return_value = service_instance
async def runner() -> None:
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(_run(settings, stop_event), timeout=1.0)
asyncio.run(runner())
assert run_forever_mock.await_count == 1
call_kwargs: dict[str, Any] = run_forever_mock.await_args.kwargs
assert call_kwargs["interval_seconds"] == 0.01
assert http_client_instance.aclose.await_count == 1
assert engine_instance.dispose.await_count == 1