Files
redmine-reporter/tests/test_mailer.py
Кокос Артем Николаевич 1df1194f58 refactor: remove import-time side effects
Импорт модулей больше не мутирует процесс:

- config.py: module-level load_dotenv(override=False) мутировал os.environ
  при любом импорте. Вызов перенесён в Config.load_yaml() — точку явной
  загрузки конфигурации, семантика override=False и путь по умолчанию
  сохранены. --config PATH (load_dotenv(path, override=True)) не затронут.
- mailer.py: глобальная регистрация email.charset.add_charset('utf-8', ...)
  меняла кодировку для всего процесса. Заменена на точечное применение
  8bit UTF-8 к телу письма в _utf8_text_part() — payload в UTF-8
  (surrogateescape) + Content-Transfer-Encoding: 8bit на конкретной части.
  Проверено: сериализация в байты (BytesGenerator, как в send_message)
  побайтово совпадает со старым поведением.

Тест test_env_var_takes_priority_over_dotenv адаптирован к новой точке
вызова load_dotenv; test_body_substitution переведён с as_string() на
декодированный payload (str-сериализация не-ASCII части без глобальной
мутации реестра теперь даёт base64; wire-формат 8bit сохранён и
зафиксирован test_wire_format_is_8bit_utf8).

Closes #64
2026-07-17 13:35:02 +07:00

334 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import smtplib
from unittest import mock
import pytest
from redmine_reporter.client import RedmineAPIError
from redmine_reporter.config import EmailConfig, SmtpConfig
from redmine_reporter.mailer import build_message, send_report
def _make_email_config(**overrides) -> EmailConfig:
"""Создаёт EmailConfig с минимальными валидными настройками."""
smtp = SmtpConfig(
host=overrides.pop("smtp_host", "smtp.example.com"),
port=overrides.pop("smtp_port", 587),
user=overrides.pop("smtp_user", "bot@example.com"),
password=overrides.pop("smtp_password", "secret"),
tls=overrides.pop("smtp_tls", True),
)
return EmailConfig(
smtp=smtp,
from_=overrides.pop("from_", "bot@example.com"),
to=overrides.pop("to", ["boss@example.com"]),
cc=overrides.pop("cc", []),
bcc=overrides.pop("bcc", []),
subject=overrides.pop("subject", "Отчёт {author} за {period}"),
body_text=overrides.pop("body_text", "Во вложении отчёт."),
attach=overrides.pop("attach", True),
**overrides,
)
class TestBuildMessage:
"""Тесты формирования MIME-письма."""
def test_subject_substitution(self):
"""{author} и {period} подставляются в тему."""
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
msg = build_message(
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", []
)
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
def test_body_substitution(self):
"""{author} и {period} подставляются в тело."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
assert "Автор: Иванов, период: Q1" in payload
def test_wire_format_is_8bit_utf8(self):
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
from email.generator import BytesGenerator
from io import BytesIO
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
buf = BytesIO()
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data
def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
assert plain["Content-Transfer-Encoding"] == "8bit"
assert (
plain.get_payload(decode=True).decode("utf-8")
== "Автор: Иванов, период: Q1"
)
def test_from_header(self):
cfg = _make_email_config(from_="sender@example.com", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["From"] == "sender@example.com"
def test_to_header(self):
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["To"] == "a@x.com, b@x.com"
def test_cc_header(self):
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["Cc"] == "cc@x.com"
def test_bcc_not_in_headers(self):
"""BCC не должен появляться в заголовках письма."""
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert "Bcc" not in msg
def test_attachment_present_when_attach_true(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake xlsx content")
cfg = _make_email_config(attach=True)
msg = build_message(cfg, str(report), "A", "P", [])
assert len(msg.get_payload()) == 2
def test_no_attachment_when_attach_false(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake xlsx content")
cfg = _make_email_config(attach=False)
msg = build_message(cfg, str(report), "A", "P", [])
assert len(msg.get_payload()) == 1
@pytest.mark.parametrize(
"ext,expected_mime",
[
(
".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
(".odt", "application/vnd.oasis.opendocument.text"),
(".csv", "text/csv"),
(".html", "text/html"),
(".json", "application/json"),
(".md", "text/markdown"),
],
)
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
report = tmp_path / f"report{ext}"
report.write_text("content")
cfg = _make_email_config()
msg = build_message(cfg, str(report), "A", "P", [])
attachment_part = msg.get_payload()[1]
assert attachment_part.get_content_type() == expected_mime
def test_missing_attachment_file_raises_error(self, tmp_path):
"""Если файл вложения не существует — RedmineAPIError."""
cfg = _make_email_config()
missing = str(tmp_path / "nonexistent.xlsx")
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
build_message(cfg, missing, "A", "P", [])
def test_html_part_present_when_html_true(self, tmp_path):
"""При email.html: true письмо содержит multipart/alternative с HTML."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "Project",
"version": "1.0",
"issue_id": 1,
"subject": "Task",
"status_ru": "В работе",
"time_text": "",
"hours": 2.0,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
alternatives = [
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
]
assert len(alternatives) == 1
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
assert len(html_parts) == 1
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
def test_no_html_part_when_html_false(self, tmp_path):
"""При email.html: false письмо содержит только plain-text."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=False)
msg = build_message(cfg, str(report), "A", "P", [])
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
assert len(html_parts) == 0
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
def test_html_part_contains_report_data(self, tmp_path):
"""HTML-часть содержит данные отчёта."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "Project X",
"version": "2.0",
"issue_id": 42,
"subject": "Important task",
"status_ru": "В работе",
"time_text": "3ч 30м",
"hours": 3.5,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
payload = html_part.get_payload(decode=True).decode("utf-8")
assert "Project X" in payload
assert "Important task" in payload
def test_plain_text_before_html_in_alternative(self, tmp_path):
"""В multipart/alternative plain-text идёт перед HTML."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "P",
"version": "V",
"issue_id": 1,
"subject": "S",
"status_ru": "В работе",
"time_text": "",
"hours": 1.0,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
alternative = [
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
][0]
payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html"
class TestSendReport:
"""Тесты отправки письма."""
def test_send_success(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30", [])
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
mock_smtp.starttls.assert_called_once()
mock_smtp.login.assert_called_once_with("bot@example.com", "secret")
mock_smtp.send_message.assert_called_once()
def test_send_no_tls(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(smtp_tls=False)
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "A", "P", [])
mock_smtp.starttls.assert_not_called()
def test_send_connection_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
535, b"Bad auth"
)
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_timeout_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.send_message.side_effect = smtplib.SMTPException(
"Something went wrong"
)
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
send_report(cfg, str(report), "A", "P", [])
def test_send_includes_cc_and_bcc(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"])
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "A", "P", [])
call_args = mock_smtp.send_message.call_args
msg = call_args.args[0]
assert msg["To"] == "a@x.com"
assert msg["Cc"] == "cc@x.com"