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 assert "Автор: Иванов, период: Q1" in plain_parts[0].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", []) 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"