Добавлена отправка накладных на почту
This commit is contained in:
@@ -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"))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user