feat: auto-email sending via SMTP (--send flag)
Closes #45 - New redmine_reporter/mailer.py: SMTP email sending with {author}/{period} template substitution, MIME attachment with correct content-type per file extension - Config.get_email_config(): returns EmailConfig from YAML or None when not configured - CLI --send flag: sends report after generation, works with --output, --commit, or standalone (saves to template path) - 31 new tests (22 mailer + 6 CLI + 3 config) - 249/249 tests passing, ruff clean, mypy clean
This commit is contained in:
198
tests/test_mailer.py
Normal file
198
tests/test_mailer.py
Normal file
@@ -0,0 +1,198 @@
|
||||
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_part = msg.get_payload()[0]
|
||||
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user