Импорт модулей больше не мутирует процесс:
- 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
334 lines
13 KiB
Python
334 lines
13 KiB
Python
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": "2ч",
|
||
"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": "1ч",
|
||
"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"
|