import asyncio from dataclasses import dataclass from datetime import datetime, timezone from typing import Any from app.services.waybill_email_sender import WaybillEmailSenderService @dataclass class StoredOrder: order_uuid: str account_email: str provider_waybill_url: str | None = None waybill_email_sent_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_email( self, session: object, *, limit: int ) -> list[StoredOrder]: self.calls.append(("list", {"limit": limit})) return [ order for order in self._orders.values() if order.provider_waybill_url is not None and order.waybill_email_sent_at is None ] async def record_waybill_email_sent( self, session: object, *, order_uuid: str, sent_at: datetime, ) -> StoredOrder | None: self.calls.append( ("record", {"order_uuid": order_uuid, "sent_at": sent_at}) ) order = self._orders.get(order_uuid) if order is None: return None if order.waybill_email_sent_at is None: order.waybill_email_sent_at = sent_at return order class StubDownloader: def __init__(self, results: dict[str, bytes | Exception]) -> None: self._results = results self.calls: list[str] = [] async def download_waybill_pdf(self, url: str) -> bytes: self.calls.append(url) result = self._results[url] if isinstance(result, Exception): raise result return result class StubEmailSender: def __init__(self, raise_for: set[str] | None = None) -> None: self.raise_for = raise_for or set() self.calls: list[dict[str, Any]] = [] async def send_email( self, *, to: str, subject: str, body: str, attachment_bytes: bytes, attachment_filename: str, ) -> None: self.calls.append( { "to": to, "subject": subject, "body": body, "attachment_bytes": attachment_bytes, "attachment_filename": attachment_filename, } ) if to in self.raise_for: raise RuntimeError(f"smtp blew up for {to}") _SENT_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc) def _make_service( *, repository: StubRepository, downloader: StubDownloader | None = None, email_sender: StubEmailSender | None = None, ) -> WaybillEmailSenderService: return WaybillEmailSenderService( order_repository=repository, waybill_downloader=downloader or StubDownloader({}), email_sender=email_sender or StubEmailSender(), batch_size=10, datetime_now=lambda: _SENT_AT, ) def test_poll_once_downloads_pdf_sends_email_and_marks_sent() -> None: order = StoredOrder( order_uuid="o-1", account_email="client@example.com", provider_waybill_url="https://cdek.test/1.pdf", ) repo = StubRepository([order]) downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"}) email_sender = StubEmailSender() service = _make_service( repository=repo, downloader=downloader, email_sender=email_sender ) summary = asyncio.run(service.poll_once()) assert summary.processed == 1 assert summary.succeeded == 1 assert summary.failed == 0 assert downloader.calls == ["https://cdek.test/1.pdf"] assert len(email_sender.calls) == 1 call = email_sender.calls[0] assert call["to"] == "client@example.com" assert call["subject"] == "Накладная по заказу o-1" assert "o-1" in call["body"] assert "https://cdek.test/1.pdf" in call["body"] assert call["attachment_bytes"] == b"%PDF" assert call["attachment_filename"] == "waybill_o-1.pdf" assert order.waybill_email_sent_at == _SENT_AT def test_poll_once_download_error_keeps_order_pending_and_skips_send() -> None: order = StoredOrder( order_uuid="o-1", account_email="client@example.com", provider_waybill_url="https://cdek.test/1.pdf", ) repo = StubRepository([order]) downloader = StubDownloader({"https://cdek.test/1.pdf": RuntimeError("cdek 500")}) email_sender = StubEmailSender() service = _make_service( repository=repo, downloader=downloader, email_sender=email_sender ) summary = asyncio.run(service.poll_once()) assert summary.processed == 1 assert summary.succeeded == 0 assert summary.failed == 1 assert email_sender.calls == [] assert order.waybill_email_sent_at is None def test_poll_once_smtp_error_keeps_order_pending() -> None: order = StoredOrder( order_uuid="o-1", account_email="bad@example.com", provider_waybill_url="https://cdek.test/1.pdf", ) repo = StubRepository([order]) downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"}) email_sender = StubEmailSender(raise_for={"bad@example.com"}) service = _make_service( repository=repo, downloader=downloader, email_sender=email_sender ) summary = asyncio.run(service.poll_once()) assert summary.failed == 1 assert order.waybill_email_sent_at is None assert len(email_sender.calls) == 1 def test_poll_once_failure_on_one_order_does_not_break_batch() -> None: bad = StoredOrder( order_uuid="bad", account_email="bad@example.com", provider_waybill_url="https://cdek.test/bad.pdf", ) good = StoredOrder( order_uuid="good", account_email="good@example.com", provider_waybill_url="https://cdek.test/good.pdf", ) repo = StubRepository([bad, good]) downloader = StubDownloader( { "https://cdek.test/bad.pdf": RuntimeError("cdek 500"), "https://cdek.test/good.pdf": b"%PDF", } ) email_sender = StubEmailSender() service = _make_service( repository=repo, downloader=downloader, email_sender=email_sender ) summary = asyncio.run(service.poll_once()) assert summary.processed == 2 assert summary.succeeded == 1 assert summary.failed == 1 assert good.waybill_email_sent_at == _SENT_AT assert bad.waybill_email_sent_at is None 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())