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:
Кокос Артем Николаевич
2026-07-10 12:37:02 +07:00
parent b926dd0d49
commit b0e353c565
7 changed files with 693 additions and 63 deletions

View File

@@ -765,3 +765,199 @@ class TestCommitFlag:
)
assert code == 0
mock_save.assert_not_called()
# ---------------------------------------------------------------------------
# #45: --send tests
# ---------------------------------------------------------------------------
class TestSendFlag:
"""Tests for --send flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
"""--send с --output вызывает send_report после сохранения."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
)
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", output,
"--send",
"--config-path", str(config_path),
]
)
assert code == 0
mock_send.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_without_output_saves_to_default_path(self, mock_get, mock_send, mock_fetch, tmp_path):
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
"output:\n"
" dir: " + str(tmp_path / "reports") + "\n"
" filename: report_{date}.{ext}\n"
" default_format: xlsx\n"
)
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--send",
"--config-path", str(config_path),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
mock_send.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
"""--send без email-конфига — ошибка и выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--send",
"--config-path", str(config_path),
]
)
assert code == 1
captured = capsys.readouterr()
assert "Email не настроен" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_smtp_error_reported_to_stderr(self, mock_get, mock_send, mock_fetch, tmp_path, capsys):
"""Ошибка SMTP выводится в stderr, exit code 1."""
from redmine_reporter.client import RedmineAPIError
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_send.side_effect = RedmineAPIError("Не удалось подключиться к SMTP-серверу bad:587")
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: bad\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
)
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--send",
"--config-path", str(config_path),
]
)
assert code == 1
captured = capsys.readouterr()
assert "SMTP" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_send_with_commit_works_together(
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send и --commit работают вместе без конфликтов."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
"period:\n"
" precision: date\n"
" dynamic: true\n"
)
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--send",
"--commit",
"--config-path", str(config_path),
]
)
assert code == 0
mock_send.assert_called_once()
mock_save.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_send_no_entries_exits_early(self, mock_fetch):
"""--send без time entries просто выходит с 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
assert code == 0