Добавлена отправка накладных на почту

This commit is contained in:
Раис Юсупалиев
2026-05-24 00:55:09 +03:00
parent 5526f90cb3
commit 50124fb2c9
36 changed files with 1615 additions and 22 deletions
@@ -312,6 +312,11 @@ observability:
service_name: "cdek-adapter-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
@@ -0,0 +1,115 @@
import asyncio
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKRequestError,
)
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]] = []
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._results[len(self.calls) - 1]
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_download_waybill_pdf_returns_response_bytes() -> None:
pdf_bytes = b"%PDF-1.4 mock content"
response = httpx.Response(
200,
content=pdf_bytes,
request=httpx.Request("GET", "https://cdek.test/waybill/1.pdf"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(
client.download_waybill_pdf("https://cdek.test/waybill/1.pdf")
)
assert result == pdf_bytes
assert http_client.calls[0]["url"] == "https://cdek.test/waybill/1.pdf"
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
assert http_client.calls[0]["timeout"] == 7.5
def test_download_waybill_pdf_retries_on_5xx_and_succeeds() -> None:
request = httpx.Request("GET", "https://cdek.test/x.pdf")
flaky = httpx.Response(503, content=b"oops", request=request)
success = httpx.Response(200, content=b"%PDF", request=request)
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.download_waybill_pdf("https://cdek.test/x.pdf"))
assert result == b"%PDF"
assert len(http_client.calls) == 2
assert sleep_calls == [0.1]
def test_download_waybill_pdf_raises_request_error_on_4xx() -> None:
response = httpx.Response(
404,
content=b"not found",
request=httpx.Request("GET", "https://cdek.test/missing.pdf"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKRequestError, match="status 404"):
asyncio.run(client.download_waybill_pdf("https://cdek.test/missing.pdf"))
def test_download_waybill_pdf_raises_client_error_after_retries_exhausted() -> None:
request = httpx.Request("GET", "https://cdek.test/x.pdf")
response = httpx.Response(500, content=b"oops", request=request)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client, retry_attempts=0)
with pytest.raises(CDEKClientError, match="retriable status 500"):
asyncio.run(client.download_waybill_pdf("https://cdek.test/x.pdf"))
View File
+166
View File
@@ -0,0 +1,166 @@
import asyncio
from email.message import EmailMessage
from typing import Any
import pytest
from app.adapters.email import SMTPEmailSender, SMTPEmailSenderError
class StubSend:
def __init__(self, raise_exc: Exception | None = None) -> None:
self.raise_exc = raise_exc
self.calls: list[dict[str, Any]] = []
async def __call__(
self,
message: EmailMessage,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
start_tls: bool,
timeout: float,
) -> object:
self.calls.append(
{
"message": message,
"hostname": hostname,
"port": port,
"username": username,
"password": password,
"use_tls": use_tls,
"start_tls": start_tls,
"timeout": timeout,
}
)
if self.raise_exc is not None:
raise self.raise_exc
return None
def _make_sender(send: StubSend, **overrides: Any) -> SMTPEmailSender:
kwargs: dict[str, Any] = {
"smtp_host": "smtp.test",
"smtp_port": 587,
"username": "user",
"password": "pass",
"from_address": "no-reply@test",
"use_tls": True,
"timeout_seconds": 5.0,
"send": send,
}
kwargs.update(overrides)
return SMTPEmailSender(**kwargs)
def test_send_email_builds_multipart_message_with_pdf_attachment() -> None:
send = StubSend()
sender = _make_sender(send)
asyncio.run(
sender.send_email(
to="client@example.com",
subject="Накладная по заказу o-1",
body="hello",
attachment_bytes=b"%PDF",
attachment_filename="waybill_o-1.pdf",
)
)
assert len(send.calls) == 1
call = send.calls[0]
assert call["hostname"] == "smtp.test"
assert call["port"] == 587
assert call["username"] == "user"
assert call["password"] == "pass"
assert call["use_tls"] is False
assert call["start_tls"] is True
assert call["timeout"] == 5.0
message: EmailMessage = call["message"]
assert message["From"] == "no-reply@test"
assert message["To"] == "client@example.com"
assert message["Subject"] == "Накладная по заказу o-1"
parts = list(message.iter_attachments())
assert len(parts) == 1
attachment = parts[0]
assert attachment.get_content_type() == "application/pdf"
assert attachment.get_filename() == "waybill_o-1.pdf"
assert attachment.get_payload(decode=True) == b"%PDF"
def test_send_email_uses_tls_for_port_465() -> None:
send = StubSend()
sender = _make_sender(send, smtp_port=465)
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["use_tls"] is True
assert call["start_tls"] is False
def test_send_email_disables_tls_when_use_tls_false() -> None:
send = StubSend()
sender = _make_sender(send, use_tls=False)
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["use_tls"] is False
assert call["start_tls"] is False
def test_send_email_wraps_send_errors_in_sender_error() -> None:
send = StubSend(raise_exc=RuntimeError("connection refused"))
sender = _make_sender(send)
with pytest.raises(SMTPEmailSenderError, match="connection refused"):
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
def test_send_email_passes_none_when_credentials_blank() -> None:
send = StubSend()
sender = _make_sender(send, username="", password="")
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["username"] is None
assert call["password"] is None
@@ -48,3 +48,8 @@ observability:
service_name: "default-config-service"
otlp_endpoint: "http://default-collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -46,3 +46,8 @@ observability:
service_name: "invalid-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -35,3 +35,8 @@ observability:
service_name: "missing-adapter-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -39,3 +39,8 @@ observability:
service_name: "missing-address-suggestions-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -40,3 +40,8 @@ address_suggestions:
dadata:
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: "missing-observability-dadata-key"
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -45,3 +45,8 @@ observability:
enabled: true
service_name: "g2s-aggregator"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -45,3 +45,8 @@ observability:
service_name: "missing-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -52,3 +52,8 @@ observability:
service_name: "override-config-service"
otlp_endpoint: "http://override-collector:4317"
otlp_insecure: false
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
+5
View File
@@ -148,6 +148,11 @@ address_suggestions: {{}}
observability:
service_name: "config-test"
otlp_endpoint: "http://collector:4317"
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
+13
View File
@@ -84,6 +84,19 @@ def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
assert service.calls == []
def test_post_init_payment_rejects_invalid_account_email() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["accountEmail"] = "not-an-email"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
+5
View File
@@ -195,6 +195,11 @@ observability:
service_name: "repository-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
+127
View File
@@ -442,6 +442,133 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
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)
+240
View File
@@ -0,0 +1,240 @@
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
cdek_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.cdek_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",
cdek_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",
cdek_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",
cdek_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",
cdek_waybill_url="https://cdek.test/bad.pdf",
)
good = StoredOrder(
order_uuid="good",
account_email="good@example.com",
cdek_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())
@@ -0,0 +1,90 @@
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from app.config import (
AdapterConfig,
EmailAdapterConfig,
ObservabilityConfig,
PostgresConfig,
Settings,
TBankPaymentAuthConfig,
TBankPaymentConfig,
WaybillEmailSenderConfig,
)
from app.workers.waybill_email_sender 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"
),
email=EmailAdapterConfig(
smtp_host="smtp.test",
smtp_port=587,
from_address="no-reply@test",
),
waybill_email_sender=WaybillEmailSenderConfig(
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_email_sender.httpx.AsyncClient",
return_value=http_client_instance,
),
patch("app.workers.waybill_email_sender.CDEKAuthClient"),
patch("app.workers.waybill_email_sender.CDEKClient"),
patch("app.workers.waybill_email_sender.SMTPEmailSender"),
patch(
"app.workers.waybill_email_sender.create_postgres_engine",
return_value=engine_instance,
),
patch("app.workers.waybill_email_sender.create_postgres_session_factory"),
patch("app.workers.waybill_email_sender.OrderRepository"),
patch(
"app.workers.waybill_email_sender.WaybillEmailSenderService"
) 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
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.config import (
AdapterConfig,
EmailAdapterConfig,
ObservabilityConfig,
PostgresConfig,
Settings,
@@ -39,6 +40,11 @@ def _make_settings() -> Settings:
service_name="svc", otlp_endpoint="http://otlp"
),
waybill_poller=WaybillPollerConfig(interval_seconds=0.01, batch_size=5),
email=EmailAdapterConfig(
smtp_host="smtp.test",
smtp_port=587,
from_address="no-reply@test",
),
business_logic={"provider_price_multiplier": "1.0"},
)