feat: add email confirmation prompt before sending report
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:
@@ -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]: ")
|
||||
|
||||
Reference in New Issue
Block a user