"""Тесты SMTP-бэкенда email (`services.email`). Маппинг ошибок aiosmtplib в `EmailSendError(retryable=...)`, вложение `.ics` в MIME, console-бэкенд по умолчанию. `_env_file=None` в конструкторе `Settings` — тесты не должны зависеть от содержимого реального `backend/.env` (изолированный конфиг). """ import logging from email.message import EmailMessage from typing import cast import aiosmtplib import pytest from core.config import Settings from services.email import ( ConsoleEmailBackend, EmailAttachment, EmailSendError, SmtpEmailBackend, _build_message, create_email_backend, ) def test_create_email_backend_defaults_to_console() -> None: backend = create_email_backend(Settings(_env_file=None)) assert isinstance(backend, ConsoleEmailBackend) def test_create_email_backend_smtp() -> None: backend = create_email_backend( Settings( _env_file=None, email_backend="smtp", smtp_host="smtp.example.com", smtp_port=2525, ) ) assert isinstance(backend, SmtpEmailBackend) async def test_console_backend_logs_message(caplog: pytest.LogCaptureFixture) -> None: backend = ConsoleEmailBackend() with caplog.at_level(logging.INFO): await backend.send( to="user@example.com", subject="Тестовая тема", body="Тестовое тело письма", attachments=[ EmailAttachment(filename="invite.ics", content=b"BEGIN", mime_type="text/calendar") ], ) assert "user@example.com" in caplog.text assert "invite.ics" in caplog.text def test_build_message_includes_html_alternative_and_ics_attachment() -> None: message = _build_message( sender="VidConf ", to="user@example.com", subject="Саммари встречи", body="Текстовая версия", html_body="

HTML-версия

", attachments=[ EmailAttachment( filename="invite.ics", content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", mime_type="text/calendar; method=REQUEST", ) ], ) assert message["Subject"] == "Саммари встречи" plain_part = message.get_body(preferencelist=("plain",)) assert plain_part is not None assert "Текстовая версия" in plain_part.get_content() html_part = message.get_body(preferencelist=("html",)) assert html_part is not None assert "HTML-версия" in html_part.get_content() attachments = list(message.iter_attachments()) assert len(attachments) == 1 assert attachments[0].get_filename() == "invite.ics" assert attachments[0].get_content_type() == "text/calendar" assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" def test_build_message_sets_reply_to_when_given() -> None: message = _build_message( sender="VidConf ", to="user@example.com", subject="Тема", body="Тело", html_body=None, attachments=(), reply_to="contact@vidconf.example", ) assert message["Reply-To"] == "contact@vidconf.example" def test_build_message_omits_reply_to_when_not_given() -> None: message = _build_message( sender="VidConf ", to="user@example.com", subject="Тема", body="Тело", html_body=None, attachments=(), ) assert message["Reply-To"] is None def _backend() -> SmtpEmailBackend: return SmtpEmailBackend( hostname="smtp.example.com", port=587, username=None, password=None, start_tls=True, use_tls=False, timeout=30, sender="VidConf ", ) @pytest.mark.parametrize( "error", [ aiosmtplib.SMTPConnectError("не удалось подключиться"), aiosmtplib.SMTPServerDisconnected("сервер оборвал соединение"), aiosmtplib.SMTPTimeoutError("таймаут"), aiosmtplib.SMTPAuthenticationError(535, "неверные учётные данные"), ], ) async def test_smtp_backend_maps_transport_errors_to_retryable( monkeypatch: pytest.MonkeyPatch, error: Exception ) -> None: async def _raise(*args: object, **kwargs: object) -> None: raise error monkeypatch.setattr(aiosmtplib, "send", _raise) backend = _backend() with pytest.raises(EmailSendError) as excinfo: await backend.send(to="user@example.com", subject="Тема", body="Тело") assert excinfo.value.retryable is True async def test_smtp_backend_maps_recipients_refused_to_non_retryable( monkeypatch: pytest.MonkeyPatch, ) -> None: refused = aiosmtplib.SMTPRecipientsRefused( [aiosmtplib.SMTPRecipientRefused(550, "мусор", "user@example.com")] ) async def _raise(*args: object, **kwargs: object) -> None: raise refused monkeypatch.setattr(aiosmtplib, "send", _raise) backend = _backend() with pytest.raises(EmailSendError) as excinfo: await backend.send(to="user@example.com", subject="Тема", body="Тело") assert excinfo.value.retryable is False async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]: captured["message"] = message captured["kwargs"] = kwargs return {}, "OK" monkeypatch.setattr(aiosmtplib, "send", _fake_send) backend = _backend() await backend.send(to="user@example.com", subject="Тема", body="Тело") assert captured["kwargs"] == { "hostname": "smtp.example.com", "port": 587, "username": None, "password": None, "start_tls": True, "use_tls": False, "timeout": 30, } async def test_smtp_backend_send_passes_reply_to_into_message( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]: captured["message"] = message return {}, "OK" monkeypatch.setattr(aiosmtplib, "send", _fake_send) backend = _backend() await backend.send( to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example" ) message = cast(EmailMessage, captured["message"]) assert message["Reply-To"] == "contact@vidconf.example"