diff --git a/alembic/versions/20260524_035_add_waybill_email_sent_at.py b/alembic/versions/20260524_035_add_waybill_email_sent_at.py new file mode 100644 index 0000000..4a78c21 --- /dev/null +++ b/alembic/versions/20260524_035_add_waybill_email_sent_at.py @@ -0,0 +1,27 @@ +"""Add waybill_email_sent_at column to orders table.""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260524_035" +down_revision: str | None = "20260524_034" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "orders", + sa.Column( + "waybill_email_sent_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("orders", "waybill_email_sent_at") diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index 0ceeb5b..a9705e9 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -253,6 +253,55 @@ class CDEKClient: "CDEK get waybill response payload is invalid." ) from exc + async def download_waybill_pdf(self, url: str) -> bytes: + failure_message = "CDEK waybill PDF download failed" + for attempt in range(self._retry_attempts + 1): + try: + token = await self._auth_client.get_access_token() + response = await self._http_client.get( + url, + headers={"Authorization": f"Bearer {token}"}, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + f"{failure_message} after retry attempts." + ) from exc + + if self._should_retry(response.status_code): + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + f"{failure_message} with retriable status " + f"{response.status_code}." + ) + + if 400 <= response.status_code < 500: + log.warning( + "cdek_waybill_pdf_download_rejected", + url=url, + status_code=response.status_code, + response_body=_response_text_or_none(response), + ) + raise CDEKRequestError( + f"{failure_message} with status {response.status_code}." + ) + + try: + response.raise_for_status() + except httpx.HTTPError as exc: + raise CDEKClientError( + f"{failure_message}: invalid response." + ) from exc + + return response.content + + raise CDEKClientError(f"{failure_message} unexpectedly.") + async def _get_json(self, url: str, *, failure_message: str) -> dict[str, Any]: for attempt in range(self._retry_attempts + 1): try: diff --git a/app/adapters/email/__init__.py b/app/adapters/email/__init__.py new file mode 100644 index 0000000..0795d2b --- /dev/null +++ b/app/adapters/email/__init__.py @@ -0,0 +1,5 @@ +"""E-mail adapters.""" + +from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError + +__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"] diff --git a/app/adapters/email/smtp_client.py b/app/adapters/email/smtp_client.py new file mode 100644 index 0000000..d4890a9 --- /dev/null +++ b/app/adapters/email/smtp_client.py @@ -0,0 +1,135 @@ +"""SMTP e-mail adapter built on top of aiosmtplib.""" + +from email.message import EmailMessage +from typing import Protocol + +import aiosmtplib +import structlog + +log = structlog.get_logger(__name__) + + +class SMTPEmailSenderError(Exception): + """Raised when the SMTP delivery fails.""" + + +class SMTPSendFn(Protocol): + 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: ... + + +class SMTPEmailSender: + def __init__( + self, + *, + smtp_host: str, + smtp_port: int, + username: str, + password: str, + from_address: str, + use_tls: bool = True, + timeout_seconds: float = 10.0, + send: SMTPSendFn | None = None, + ) -> None: + self._smtp_host = smtp_host + self._smtp_port = smtp_port + self._username = username + self._password = password + self._from_address = from_address + self._use_tls = use_tls + self._timeout_seconds = timeout_seconds + self._send = send or _default_send + + async def send_email( + self, + *, + to: str, + subject: str, + body: str, + attachment_bytes: bytes, + attachment_filename: str, + ) -> None: + message = self._build_message( + to=to, + subject=subject, + body=body, + attachment_bytes=attachment_bytes, + attachment_filename=attachment_filename, + ) + try: + await self._send( + message, + hostname=self._smtp_host, + port=self._smtp_port, + username=self._username or None, + password=self._password or None, + use_tls=self._use_tls and self._smtp_port == 465, + start_tls=self._use_tls and self._smtp_port != 465, + timeout=self._timeout_seconds, + ) + except Exception as exc: + log.warning( + "smtp_send_failed", + smtp_host=self._smtp_host, + smtp_port=self._smtp_port, + to=to, + error=str(exc), + ) + raise SMTPEmailSenderError( + f"SMTP send to {to} failed: {exc}" + ) from exc + + def _build_message( + self, + *, + to: str, + subject: str, + body: str, + attachment_bytes: bytes, + attachment_filename: str, + ) -> EmailMessage: + message = EmailMessage() + message["From"] = self._from_address + message["To"] = to + message["Subject"] = subject + message.set_content(body) + message.add_attachment( + attachment_bytes, + maintype="application", + subtype="pdf", + filename=attachment_filename, + ) + return message + + +async def _default_send( + message: EmailMessage, + *, + hostname: str, + port: int, + username: str | None, + password: str | None, + use_tls: bool, + start_tls: bool, + timeout: float, +) -> object: + return await aiosmtplib.send( + message, + hostname=hostname, + port=port, + username=username, + password=password, + use_tls=use_tls, + start_tls=start_tls, + timeout=timeout, + ) diff --git a/app/config.py b/app/config.py index e8b3c0c..123356a 100644 --- a/app/config.py +++ b/app/config.py @@ -121,6 +121,21 @@ class WaybillPollerConfig(BaseModel): batch_size: int = Field(default=50, gt=0) +class EmailAdapterConfig(BaseModel): + smtp_host: str = Field(..., min_length=1) + smtp_port: int = Field(..., gt=0, le=65535) + username: str = "" + password: str = "" + from_address: str = Field(..., min_length=1) + use_tls: bool = True + timeout_seconds: float = Field(default=10.0, gt=0) + + +class WaybillEmailSenderConfig(BaseModel): + interval_seconds: float = Field(default=30.0, gt=0) + batch_size: int = Field(default=50, gt=0) + + class Settings(BaseSettings): model_config = SettingsConfigDict( extra="ignore", @@ -138,6 +153,10 @@ class Settings(BaseSettings): ) observability: ObservabilityConfig waybill_poller: WaybillPollerConfig = Field(default_factory=WaybillPollerConfig) + email: EmailAdapterConfig + waybill_email_sender: WaybillEmailSenderConfig = Field( + default_factory=WaybillEmailSenderConfig + ) @classmethod def settings_customise_sources( @@ -169,6 +188,7 @@ class _RequiredYamlSections(BaseModel): postgres: dict[str, Any] address_suggestions: dict[str, Any] observability: dict[str, Any] + email: dict[str, Any] def _resolve_runtime_config_file() -> str: diff --git a/app/repositories/order/models.py b/app/repositories/order/models.py index 2ba464d..b3c1333 100644 --- a/app/repositories/order/models.py +++ b/app/repositories/order/models.py @@ -48,6 +48,10 @@ class Order(Base): DateTime(timezone=True), nullable=True, ) + waybill_email_sent_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/app/repositories/order/repository.py b/app/repositories/order/repository.py index 473085c..d58fc2f 100644 --- a/app/repositories/order/repository.py +++ b/app/repositories/order/repository.py @@ -144,3 +144,38 @@ class OrderRepository: order.cdek_polled_at = polled_at await session.flush() return order + + async def list_orders_pending_waybill_email( + self, + session: AsyncSession, + *, + limit: int, + ) -> Sequence[Order]: + statement = ( + select(Order) + .where( + Order.cdek_waybill_url.is_not(None), + Order.waybill_email_sent_at.is_(None), + ) + .order_by(Order.created_at.asc()) + .limit(limit) + .with_for_update(skip_locked=True) + ) + result = await session.execute(statement) + return result.scalars().all() + + async def record_waybill_email_sent( + self, + session: AsyncSession, + *, + order_uuid: str, + sent_at: datetime, + ) -> Order | None: + order = await self.get_order_by_order_uuid(session, order_uuid) + if order is None: + return None + + if order.waybill_email_sent_at is None: + order.waybill_email_sent_at = sent_at + await session.flush() + return order diff --git a/app/schemas/payment.py b/app/schemas/payment.py index 3e91264..7d80df8 100644 --- a/app/schemas/payment.py +++ b/app/schemas/payment.py @@ -4,7 +4,14 @@ from datetime import datetime from decimal import Decimal, InvalidOperation from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + EmailStr, + Field, + field_validator, + model_validator, +) from pydantic.alias_generators import to_camel @@ -115,7 +122,7 @@ class InitPaymentRequest(_CamelModel): content: Content pickup_date: datetime delivery_date: datetime | None = None - account_email: str = Field(min_length=1) + account_email: EmailStr system_data: SystemData diff --git a/app/services/waybill_email_sender.py b/app/services/waybill_email_sender.py new file mode 100644 index 0000000..db9a1a4 --- /dev/null +++ b/app/services/waybill_email_sender.py @@ -0,0 +1,167 @@ +"""Background service that e-mails CDEK waybill PDFs to customers.""" + +import asyncio +from collections.abc import Callable, Sequence +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Protocol + +import structlog + +logger = structlog.get_logger(__name__) + + +_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}" +_EMAIL_BODY_TEMPLATE = ( + "Здравствуйте!\n\n" + "По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n" + "PDF-файл накладной приложен к этому письму.\n" + "Также накладная доступна по ссылке: {waybill_url}\n" +) +_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf" + + +class WaybillPDFDownloaderProtocol(Protocol): + async def download_waybill_pdf(self, url: str) -> bytes: ... + + +class EmailSenderProtocol(Protocol): + async def send_email( + self, + *, + to: str, + subject: str, + body: str, + attachment_bytes: bytes, + attachment_filename: str, + ) -> None: ... + + +class OrderRecord(Protocol): + order_uuid: str + account_email: str + cdek_waybill_url: str | None + + +class WaybillEmailSenderRepositoryProtocol(Protocol): + def session(self) -> AbstractAsyncContextManager[object]: ... + + async def list_orders_pending_waybill_email( + self, session: object, *, limit: int + ) -> Sequence[OrderRecord]: ... + + async def record_waybill_email_sent( + self, + session: object, + *, + order_uuid: str, + sent_at: datetime, + ) -> object | None: ... + + +@dataclass(frozen=True) +class SendBatchSummary: + processed: int + succeeded: int + failed: int + + +class WaybillEmailSenderService: + def __init__( + self, + *, + order_repository: WaybillEmailSenderRepositoryProtocol, + waybill_downloader: WaybillPDFDownloaderProtocol, + email_sender: EmailSenderProtocol, + batch_size: int, + datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), + ) -> None: + self._repository = order_repository + self._waybill_downloader = waybill_downloader + self._email_sender = email_sender + self._batch_size = batch_size + self._datetime_now = datetime_now + + async def poll_once(self) -> SendBatchSummary: + async with self._repository.session() as session: + orders = await self._repository.list_orders_pending_waybill_email( + session, limit=self._batch_size + ) + succeeded = 0 + failed = 0 + for order in orders: + try: + await self._handle_order(session, order) + succeeded += 1 + except Exception: + failed += 1 + logger.exception( + "waybill_email_order_failed", + order_uuid=order.order_uuid, + account_email=order.account_email, + ) + return SendBatchSummary( + processed=len(orders), + succeeded=succeeded, + failed=failed, + ) + + async def run_forever( + self, + *, + interval_seconds: float, + stop_event: asyncio.Event, + ) -> None: + while not stop_event.is_set(): + try: + summary = await self.poll_once() + logger.info( + "waybill_email_tick", + processed=summary.processed, + succeeded=summary.succeeded, + failed=summary.failed, + ) + except Exception: + logger.exception("waybill_email_tick_failed") + + try: + await asyncio.wait_for( + stop_event.wait(), timeout=interval_seconds + ) + except asyncio.TimeoutError: + continue + + async def _handle_order(self, session: object, order: OrderRecord) -> None: + waybill_url = order.cdek_waybill_url + if waybill_url is None: + return + + pdf_bytes = await self._waybill_downloader.download_waybill_pdf(waybill_url) + subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid) + body = _EMAIL_BODY_TEMPLATE.format( + order_uuid=order.order_uuid, + waybill_url=waybill_url, + ) + filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid) + + await self._email_sender.send_email( + to=order.account_email, + subject=subject, + body=body, + attachment_bytes=pdf_bytes, + attachment_filename=filename, + ) + + sent_at = self._datetime_now() + await self._repository.record_waybill_email_sent( + session, + order_uuid=order.order_uuid, + sent_at=sent_at, + ) + logger.info( + "waybill_email_sent", + order_uuid=order.order_uuid, + account_email=order.account_email, + sent_at=sent_at, + ) diff --git a/app/workers/waybill_email_sender.py b/app/workers/waybill_email_sender.py new file mode 100644 index 0000000..bcb65be --- /dev/null +++ b/app/workers/waybill_email_sender.py @@ -0,0 +1,91 @@ +"""Background worker that e-mails CDEK waybill PDFs.""" + +import asyncio +import signal + +import httpx +import structlog + +from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient +from app.adapters.delivery_providers.cdek.client import CDEKClient +from app.adapters.email import SMTPEmailSender +from app.adapters.postgres.engine import ( + create_postgres_engine, + create_postgres_session_factory, +) +from app.config import Settings, get_settings +from app.repositories.order import OrderRepository +from app.runtime.logging import configure_logging +from app.services.waybill_email_sender import WaybillEmailSenderService + +logger = structlog.get_logger(__name__) + + +async def _run(settings: Settings, stop_event: asyncio.Event) -> None: + http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds) + auth_client = CDEKAuthClient( + http_client=http_client, + base_url=settings.adapter.cdek_base_url, + client_id=settings.adapter.cdek_client_id, + client_secret=settings.adapter.cdek_client_secret, + timeout_seconds=settings.adapter.cdek_timeout_seconds, + ) + cdek_client = CDEKClient( + http_client=http_client, + auth_client=auth_client, + base_url=settings.adapter.cdek_base_url, + timeout_seconds=settings.adapter.cdek_timeout_seconds, + retry_attempts=settings.adapter.cdek_retry_attempts, + retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds, + ) + email_sender = SMTPEmailSender( + smtp_host=settings.email.smtp_host, + smtp_port=settings.email.smtp_port, + username=settings.email.username, + password=settings.email.password, + from_address=settings.email.from_address, + use_tls=settings.email.use_tls, + timeout_seconds=settings.email.timeout_seconds, + ) + engine = create_postgres_engine(settings.postgres) + session_factory = create_postgres_session_factory(engine) + repository = OrderRepository(session_factory=session_factory) + service = WaybillEmailSenderService( + order_repository=repository, + waybill_downloader=cdek_client, + email_sender=email_sender, + batch_size=settings.waybill_email_sender.batch_size, + ) + logger.info( + "waybill_email_sender_started", + interval_seconds=settings.waybill_email_sender.interval_seconds, + batch_size=settings.waybill_email_sender.batch_size, + ) + try: + await service.run_forever( + interval_seconds=settings.waybill_email_sender.interval_seconds, + stop_event=stop_event, + ) + finally: + await http_client.aclose() + await engine.dispose() + logger.info("waybill_email_sender_stopped") + + +async def main() -> None: + configure_logging() + settings = get_settings() + stop_event = asyncio.Event() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + try: + loop.add_signal_handler(sig, stop_event.set) + except NotImplementedError: + pass + + await _run(settings, stop_event) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/config.example.yaml b/config.example.yaml index e7572b1..f417a3a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -67,3 +67,16 @@ observability: waybill_poller: interval_seconds: 30 batch_size: 50 + +email: + smtp_host: "smtp.example.com" + smtp_port: 587 + username: "" + password: "" + from_address: "no-reply@example.com" + use_tls: true + timeout_seconds: 10.0 + +waybill_email_sender: + interval_seconds: 30 + batch_size: 50 diff --git a/config.test.yaml b/config.test.yaml index 28b272c..42a7555 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -97,3 +97,8 @@ observability: service_name: "g2s-aggregator-test" otlp_endpoint: "http://localhost:4317" otlp_insecure: true + +email: + smtp_host: "smtp.test" + smtp_port: 587 + from_address: "no-reply@test" diff --git a/docker-compose.yml b/docker-compose.yml index 0fc5113..4c43f69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: yusupal1ev/g2s-aggregator:0.0.2 + image: yusupal1ev/g2s-aggregator:0.0.7 container_name: g2s-aggregator ports: - "8000:8000" @@ -36,7 +36,7 @@ services: timeout: 3s retries: 10 migrations: - image: yusupal1ev/g2s-aggregator:0.0.2 + image: yusupal1ev/g2s-aggregator:0.0.7 container_name: g2s-aggregator-migrations depends_on: postgres: @@ -46,7 +46,7 @@ services: command: ["poetry", "run", "alembic", "upgrade", "head"] restart: "no" waybill-poller: - image: yusupal1ev/g2s-aggregator:0.0.2 + image: yusupal1ev/g2s-aggregator:0.0.7 container_name: g2s-aggregator-waybill-poller depends_on: postgres: @@ -57,5 +57,18 @@ services: - ./config.yaml:/config.yaml command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"] restart: unless-stopped + waybill-email-sender: + image: yusupal1ev/g2s-aggregator:0.0.7 + container_name: g2s-aggregator-waybill-email-sender + depends_on: + postgres: + condition: service_healthy + migrations: + condition: service_completed_successfully + volumes: + - ./config.yaml:/config.yaml + command: + ["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"] + restart: unless-stopped volumes: postgres: diff --git a/poetry.lock b/poetry.lock index 3b0cae1..6419f45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aioredis" @@ -6,6 +6,7 @@ version = "2.0.1" description = "asyncio (PEP 3156) Redis support" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"}, {file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"}, @@ -16,7 +17,23 @@ async-timeout = "*" typing-extensions = "*" [package.extras] -hiredis = ["hiredis (>=1.0)"] +hiredis = ["hiredis (>=1.0) ; implementation_name == \"cpython\""] + +[[package]] +name = "aiosmtplib" +version = "4.0.2" +description = "asyncio SMTP client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742"}, + {file = "aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"] +uvloop = ["uvloop (>=0.18)"] [[package]] name = "aiosqlite" @@ -24,6 +41,7 @@ version = "0.22.1" description = "asyncio bridge to the standard sqlite3 module" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, @@ -39,6 +57,7 @@ version = "1.18.4" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, @@ -58,6 +77,7 @@ version = "0.0.4" description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, @@ -69,6 +89,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -80,6 +101,7 @@ version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, @@ -89,7 +111,7 @@ files = [ idna = ">=2.8" [package.extras] -trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "asgiref" @@ -97,6 +119,7 @@ version = "3.11.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"}, {file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"}, @@ -111,6 +134,7 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -122,6 +146,7 @@ version = "0.31.0" description = "An asyncio PostgreSQL driver" optional = false python-versions = ">=3.9.0" +groups = ["main"] files = [ {file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"}, {file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"}, @@ -183,7 +208,7 @@ files = [ ] [package.extras] -gssauth = ["gssapi", "sspilib"] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] [[package]] name = "certifi" @@ -191,6 +216,7 @@ version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, @@ -202,6 +228,7 @@ version = "3.4.5" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"}, {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"}, @@ -324,6 +351,7 @@ version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -338,17 +366,57 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "fastapi" version = "0.135.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"}, {file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"}, @@ -372,6 +440,7 @@ version = "1.73.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"}, {file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"}, @@ -389,6 +458,8 @@ version = "3.4.0" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" files = [ {file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"}, {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"}, @@ -461,6 +532,7 @@ version = "1.78.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"}, {file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"}, @@ -537,6 +609,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -548,6 +621,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -569,6 +643,7 @@ version = "0.7.1" description = "A collection of framework independent HTTP protocol utils." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, @@ -621,6 +696,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -633,7 +709,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -645,6 +721,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -659,6 +736,7 @@ version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -668,13 +746,13 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "iniconfig" @@ -682,6 +760,7 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -693,6 +772,7 @@ version = "1.3.10" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, @@ -712,6 +792,7 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -810,6 +891,7 @@ version = "1.40.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"}, {file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"}, @@ -825,6 +907,7 @@ version = "1.40.0" description = "OpenTelemetry Collector Exporters" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d"}, {file = "opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a"}, @@ -840,6 +923,7 @@ version = "1.40.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"}, {file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"}, @@ -854,6 +938,7 @@ version = "1.40.0" description = "OpenTelemetry Collector Protobuf over gRPC Exporter" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"}, @@ -877,6 +962,7 @@ version = "1.40.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069"}, {file = "opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"}, @@ -900,6 +986,7 @@ version = "0.61b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"}, {file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"}, @@ -917,6 +1004,7 @@ version = "0.61b0" description = "ASGI instrumentation for OpenTelemetry" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"}, {file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"}, @@ -938,6 +1026,7 @@ version = "0.61b0" description = "OpenTelemetry FastAPI Instrumentation" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"}, {file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"}, @@ -959,6 +1048,7 @@ version = "0.61b0" description = "OpenTelemetry HTTPX Instrumentation" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"}, {file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"}, @@ -980,6 +1070,7 @@ version = "0.61b0" description = "OpenTelemetry Redis instrumentation" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"}, {file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"}, @@ -1000,6 +1091,7 @@ version = "1.40.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"}, {file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"}, @@ -1014,6 +1106,7 @@ version = "1.40.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"}, {file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"}, @@ -1030,6 +1123,7 @@ version = "0.61b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"}, {file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"}, @@ -1045,6 +1139,7 @@ version = "0.61b0" description = "Web util for OpenTelemetry" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"}, {file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"}, @@ -1056,6 +1151,7 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -1067,6 +1163,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1082,6 +1179,7 @@ version = "6.33.5" description = "" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, @@ -1101,6 +1199,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -1114,7 +1213,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -1122,6 +1221,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -1255,6 +1355,7 @@ version = "2.13.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, @@ -1278,6 +1379,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1292,6 +1394,7 @@ version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, @@ -1313,6 +1416,7 @@ version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, @@ -1327,6 +1431,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1409,6 +1514,7 @@ version = "7.3.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"}, {file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"}, @@ -1428,6 +1534,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1449,6 +1556,7 @@ version = "2.0.49" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"}, {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"}, @@ -1550,6 +1658,7 @@ version = "0.52.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, @@ -1567,6 +1676,7 @@ version = "25.5.0" description = "Structured Logging for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, @@ -1578,6 +1688,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -1589,6 +1700,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -1603,16 +1715,17 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" @@ -1620,6 +1733,7 @@ version = "0.41.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"}, {file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"}, @@ -1632,12 +1746,12 @@ h11 = ">=0.8" httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.20)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -1645,6 +1759,8 @@ version = "0.22.1" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -1708,6 +1824,7 @@ version = "1.1.1" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, @@ -1829,6 +1946,7 @@ version = "16.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.10" +groups = ["main"] files = [ {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, @@ -1899,6 +2017,7 @@ version = "1.17.3" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, @@ -1989,13 +2108,14 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -2003,6 +2123,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.14" -content-hash = "d3da307072ea8d39a768a338440f91ceaf72b09eab699bd06875714de74c8c90" +content-hash = "37dfb9818087d72976a203956d4f1ef6df05377e66dc521b7884b718df44138e" diff --git a/pyproject.toml b/pyproject.toml index 92a7b3a..67d5dc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ sqlalchemy = "^2.0.49" asyncpg = "^0.31.0" alembic = "^1.18.4" aiosqlite = "^0.22.1" +aiosmtplib = "^4.0.2" +email-validator = "^2.2.0" [tool.pytest.ini_options] pythonpath = ["."] diff --git a/spec/index.md b/spec/index.md index 600253c..335759f 100644 --- a/spec/index.md +++ b/spec/index.md @@ -40,9 +40,10 @@ | 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` | | 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` | | 033 | DONE | 2026-05-23 | Add CDEK waybill polling worker | `spec/tasks/033_add_waybill_polling_worker.md` | +| 034 | DONE | 2026-05-23 | Add CDEK waybill e-mail sender worker | `spec/tasks/034_add_waybill_email_sender_worker.md` | ## Summary -- Total: **34** +- Total: **35** - TODO: **2** -- DONE: **32** +- DONE: **33** diff --git a/spec/tasks/034_add_waybill_email_sender_worker.md b/spec/tasks/034_add_waybill_email_sender_worker.md new file mode 100644 index 0000000..22cab96 --- /dev/null +++ b/spec/tasks/034_add_waybill_email_sender_worker.md @@ -0,0 +1,87 @@ +--- +id: 034 +title: Add CDEK waybill e-mail sender worker +status: DONE +created: 2026-05-23 +--- + +## Context +После задачи 033 фоновый `WaybillPollerService` дотягивает у CDEK +`cdek_waybill_url` для зарегистрированных заказов и на этом цепочка +обрывается: PDF-накладная остаётся доступной только по URL, клиенту на почту +не отправляется. Адрес получателя уже сохраняется в `Order.account_email` +(приходит из `InitPaymentRequest.account_email`). + +## Goal +Добавить отдельный фоновый сервис и worker, который для заказов с +`cdek_waybill_url IS NOT NULL AND waybill_email_sent_at IS NULL` скачивает PDF +накладной у CDEK и отправляет его вложением на `Order.account_email`. + +## Constraints +- Запуск — отдельный процесс `python -m app.workers.waybill_email_sender`, + отдельный сервис в `docker-compose.yml`, одна реплика, тот же + `config.yaml`, что и у `waybill-poller`. +- Поллер waybill-данных (033) не меняем; новый сервис — единственный writer + колонки `waybill_email_sent_at`. +- Соблюдение слоёв AGENTS.md: SMTP — только в адаптере, скачивание PDF — + в CDEK-адаптере, работа с БД — в репозитории, оркестрация — в сервисе. +- Текст письма (тема, тело, имя вложения) — приватные константы сервиса, + без отдельного domain-модуля и без шаблонов в config. +- На вход (POST /api/v1/delivery/order) `accountEmail` валидируется как + `pydantic.EmailStr` — заведомо некорректный адрес отбрасывается на + контроллере и не доходит до воркера. + +## Acceptance criteria +- При появлении у заказа `cdek_waybill_url` worker в течение + `waybill_email_sender.interval_seconds` скачивает PDF и отправляет письмо + на `Order.account_email`, после чего проставляет `waybill_email_sent_at`. +- Письмо содержит тему `Накладная по заказу {order_uuid}`, тело — краткое + уведомление со ссылкой на `cdek_waybill_url`, и вложение + `waybill_{order_uuid}.pdf` (`application/pdf`). +- Повторный запуск worker-а не приводит к повторной отправке (фильтр + `waybill_email_sent_at IS NULL`). +- Ошибка скачивания PDF или SMTP по одному заказу логируется и не валит + батч; заказ остаётся pending и будет переотправлен в следующем тике. +- `POST /api/v1/delivery/order` с заведомо невалидным `accountEmail` + возвращает 422. +- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP- + клиент и async engine. + +## Definition of Done +- [ ] Миграция `20260524_035_add_waybill_email_sent_at.py` добавляет + `waybill_email_sent_at`; модель синхронизирована. +- [ ] `OrderRepository.list_orders_pending_waybill_email` и + `record_waybill_email_sent` реализованы. +- [ ] `CDEKClient.download_waybill_pdf(url)` реализован. +- [ ] Адаптер `app/adapters/email/smtp_client.py` (`aiosmtplib`) реализован. +- [ ] `WaybillEmailSenderService.poll_once` и `run_forever` реализованы. +- [ ] `app/workers/waybill_email_sender.py` поднимает зависимости и + поддерживает graceful shutdown. +- [ ] `config.example.yaml` содержит секции `email` и + `waybill_email_sender`; `app/config.py` — `EmailAdapterConfig` и + `WaybillEmailSenderConfig`. +- [ ] `docker-compose.yml` содержит сервис `waybill-email-sender`. +- [ ] `pyproject.toml` содержит `aiosmtplib` и `email-validator`. +- [ ] `InitPaymentRequest.account_email` имеет тип `EmailStr`. + +## Tests +- `tests/adapters/email/test_smtp_client.py` — корректное MIME-сообщение, + вложение PDF, проброс SMTP-ошибок. +- `tests/adapters/delivery_providers/cdek/test_waybill_download_client.py` — + `download_waybill_pdf`: 200 → bytes, 4xx/5xx, ретраи, auth header. +- `tests/repositories/order/test_repository.py` — расширить: + `list_orders_pending_waybill_email` (фильтр и порядок по `created_at`), + `record_waybill_email_sent` (защита от перезаписи). +- `tests/services/test_waybill_email_sender.py` — `poll_once` на стабах: + успешная отправка, ошибка скачивания PDF, ошибка SMTP, изоляция ошибок + между заказами, `run_forever` завершается по `stop_event`. +- `tests/workers/test_waybill_email_sender_main.py` — `_run` собирает + зависимости и завершается по `stop_event`. +- `tests/controllers/v1/test_init_payment.py` — 422 на невалидный + `accountEmail`. + +## Commands +- `poetry run pytest -q` +- `poetry run alembic upgrade head` +- `poetry run python -m app.workers.waybill_email_sender` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 1b7ed45..e27ef40 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -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", ) diff --git a/tests/adapters/delivery_providers/cdek/test_waybill_download_client.py b/tests/adapters/delivery_providers/cdek/test_waybill_download_client.py new file mode 100644 index 0000000..e733bb4 --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_waybill_download_client.py @@ -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")) diff --git a/tests/adapters/email/__init__.py b/tests/adapters/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/email/test_smtp_client.py b/tests/adapters/email/test_smtp_client.py new file mode 100644 index 0000000..1c2d468 --- /dev/null +++ b/tests/adapters/email/test_smtp_client.py @@ -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 diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 1bec7ce..3abbe33 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -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" diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml index f59208b..8082562 100644 --- a/tests/config/fixtures/config.invalid_price_multiplier.yaml +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -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" diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index 4ff21ed..0487115 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -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" diff --git a/tests/config/fixtures/config.missing_address_suggestions.yaml b/tests/config/fixtures/config.missing_address_suggestions.yaml index dd5b540..dd809d6 100644 --- a/tests/config/fixtures/config.missing_address_suggestions.yaml +++ b/tests/config/fixtures/config.missing_address_suggestions.yaml @@ -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" diff --git a/tests/config/fixtures/config.missing_observability.yaml b/tests/config/fixtures/config.missing_observability.yaml index 8f2f193..2160432 100644 --- a/tests/config/fixtures/config.missing_observability.yaml +++ b/tests/config/fixtures/config.missing_observability.yaml @@ -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" diff --git a/tests/config/fixtures/config.missing_observability_endpoint.yaml b/tests/config/fixtures/config.missing_observability_endpoint.yaml index 04ab09f..89ef903 100644 --- a/tests/config/fixtures/config.missing_observability_endpoint.yaml +++ b/tests/config/fixtures/config.missing_observability_endpoint.yaml @@ -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" diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml index 4d20895..3ac901e 100644 --- a/tests/config/fixtures/config.missing_price_multiplier.yaml +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -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" diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 7ca8ca4..a7b7682 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -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" diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 91027b8..348f341 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -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", ) diff --git a/tests/controllers/v1/test_init_payment.py b/tests/controllers/v1/test_init_payment.py index 1e60095..097018e 100644 --- a/tests/controllers/v1/test_init_payment.py +++ b/tests/controllers/v1/test_init_payment.py @@ -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() diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index 8c95eb0..26bcfe2 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -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", ) diff --git a/tests/repositories/order/test_repository.py b/tests/repositories/order/test_repository.py index e34676f..828645f 100644 --- a/tests/repositories/order/test_repository.py +++ b/tests/repositories/order/test_repository.py @@ -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) diff --git a/tests/services/test_waybill_email_sender.py b/tests/services/test_waybill_email_sender.py new file mode 100644 index 0000000..311e647 --- /dev/null +++ b/tests/services/test_waybill_email_sender.py @@ -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()) diff --git a/tests/workers/test_waybill_email_sender_main.py b/tests/workers/test_waybill_email_sender_main.py new file mode 100644 index 0000000..aa4c9c3 --- /dev/null +++ b/tests/workers/test_waybill_email_sender_main.py @@ -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 diff --git a/tests/workers/test_waybill_poller_main.py b/tests/workers/test_waybill_poller_main.py index c4ce015..e1c5073 100644 --- a/tests/workers/test_waybill_poller_main.py +++ b/tests/workers/test_waybill_poller_main.py @@ -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"}, )