@@ -964,8 +964,12 @@ class TestSendFlag:
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@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 после сохранения. """
""" --send с --output вызывает send_report после сохранения. """
mock_input . return_value = " y "
issue = _MockIssue ( )
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_formatter = mock . MagicMock ( )
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.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " builtins.input " )
def test_send_without_output_saves_to_default_path (
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 сохраняет файл по шаблону, затем отправляет. """
""" --send без --output сохраняет файл по шаблону, затем отправляет. """
mock_input . return_value = " y "
issue = _MockIssue ( )
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_formatter = mock . MagicMock ( )
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.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " builtins.input " )
def test_send_smtp_error_reported_to_stderr (
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. """
""" Ошибка SMTP выводится в stderr, exit code 1. """
mock_input . return_value = " y "
from redmine_reporter . client import RedmineAPIError
from redmine_reporter . client import RedmineAPIError
issue = _MockIssue ( )
issue = _MockIssue ( )
@@ -1118,10 +1126,12 @@ class TestSendFlag:
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.save_period_to_config " )
@mock.patch ( " redmine_reporter.cli.save_period_to_config " )
@mock.patch ( " builtins.input " )
def test_send_with_commit_works_together (
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 работают вместе без конфликтов. """
""" --send и --commit работают вместе без конфликтов. """
mock_input . return_value = " y "
issue = _MockIssue ( )
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_formatter = mock . MagicMock ( )
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.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " builtins.input " )
def test_send_passes_rows_to_send_report (
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. """
""" --send передаёт rows в send_report для генерации HTML. """
mock_input . return_value = " y "
issue = _MockIssue ( )
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_formatter = mock . MagicMock ( )
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.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " redmine_reporter.cli.get_formatter_by_extension " )
@mock.patch ( " builtins.input " )
def test_send_html_false_does_not_require_html_part (
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-части. """
""" При email.html: false письмо отправляется без HTML-части. """
mock_input . return_value = " y "
issue = _MockIssue ( )
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
mock_formatter = mock . MagicMock ( )
mock_formatter = mock . MagicMock ( )
@@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration:
@mock.patch.dict ( os . environ , VALID_ENV , clear = True )
@mock.patch.dict ( os . environ , VALID_ENV , clear = True )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.send_report " )
@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 в форматтер. """
""" --send с report.no_time: true передаёт no_time=True в форматтер. """
mock_input . return_value = " y "
import yaml
import yaml
issue = _MockIssue ( )
issue = _MockIssue ( )
@@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput:
assert " raw details " not in captured . err
assert " raw details " not in captured . err
assert " raw details " not in caplog . text
assert " raw details " not in caplog . text
assert " safe message " in captured . err
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]: " )