feat: add email confirmation prompt before sending report
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled

Require user confirmation (y/N) before sending email via --send.
Shows from/to/cc/bcc/file info. Skips send on anything except y/yes.

Version bump: 1.11.1 → 1.12.0
This commit is contained in:
Кокос Артем Николаевич
2026-07-26 16:15:10 +07:00
parent 3accb1212c
commit 35fa585bd0
4 changed files with 236 additions and 9 deletions

View File

@@ -964,8 +964,12 @@ class TestSendFlag:
@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):
@mock.patch("builtins.input")
def test_send_triggers_mailer(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send с --output вызывает send_report после сохранения."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1003,10 +1007,12 @@ class TestSendFlag:
@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("builtins.input")
def test_send_without_output_saves_to_default_path(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1071,10 +1077,12 @@ class TestSendFlag:
@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("builtins.input")
def test_send_smtp_error_reported_to_stderr(
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
):
"""Ошибка SMTP выводится в stderr, exit code 1."""
mock_input.return_value = "y"
from redmine_reporter.client import RedmineAPIError
issue = _MockIssue()
@@ -1118,10 +1126,12 @@ class TestSendFlag:
@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")
@mock.patch("builtins.input")
def test_send_with_commit_works_together(
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send и --commit работают вместе без конфликтов."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1175,10 +1185,12 @@ class TestSendHtmlBody:
@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("builtins.input")
def test_send_passes_rows_to_send_report(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send передаёт rows в send_report для генерации HTML."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1220,10 +1232,12 @@ class TestSendHtmlBody:
@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("builtins.input")
def test_send_html_false_does_not_require_html_part(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""При email.html: false письмо отправляется без HTML-части."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration:
@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")
def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path):
@mock.patch("builtins.input")
def test_send_with_report_no_time_true(
self, mock_input, mock_send, mock_fetch, tmp_path
):
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
mock_input.return_value = "y"
import yaml
issue = _MockIssue()
@@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput:
assert "raw details" not in captured.err
assert "raw details" not in caplog.text
assert "safe message" in captured.err
# ---------------------------------------------------------------------------
# Email confirmation prompt before --send
# ---------------------------------------------------------------------------
class TestSendConfirmation:
"""Tests for email confirmation prompt before sending."""
@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("builtins.input")
def test_send_confirmation_yes_calls_send_report(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Подтверждение 'y' — send_report вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "y"
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"
" cc:\n"
" - cc@example.com\n"
" bcc:\n"
" - bcc@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")
@mock.patch("builtins.input")
def test_send_confirmation_no_skips_send(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Ответ 'n' — send_report не вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "n"
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_not_called()
@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("builtins.input")
def test_send_confirmation_default_skips_send(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Пустой ввод (Enter) — send_report не вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = ""
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_not_called()
@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("builtins.input")
def test_send_confirmation_shows_recipients(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
):
"""Подтверждение показывает from, to, cc, bcc, file."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "y"
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: sender@example.com\n"
" to:\n"
" - boss@example.com\n"
" cc:\n"
" - cc@example.com\n"
" bcc:\n"
" - bcc@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
captured = capsys.readouterr()
assert "sender@example.com" in captured.out
assert "boss@example.com" in captured.out
assert "cc@example.com" in captured.out
assert "bcc@example.com" in captured.out
assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ")