Покрытие валидации/сохранения contact_email, простановки Reply-To в письмах регистрации/приглашений/саммари при включённом и выключенном контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный получатель, сбой транспорта без утечки логина/пароля).
220 lines
6.7 KiB
Python
220 lines
6.7 KiB
Python
"""Тесты 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 <no-reply@vidconf.example>",
|
|
to="user@example.com",
|
|
subject="Саммари встречи",
|
|
body="Текстовая версия",
|
|
html_body="<p>HTML-версия</p>",
|
|
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 <no-reply@vidconf.example>",
|
|
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 <no-reply@vidconf.example>",
|
|
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 <no-reply@vidconf.example>",
|
|
)
|
|
|
|
|
|
@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"
|