@@ -715,7 +715,9 @@ class TestCommitFlag:
def test_commit_with_precision_datetime_passes_datetime_strings (
self , mock_save , mock_fetch , tmp_path
) :
""" При precision=datetime --commit сохраняет timestamps . """
""" При precision=datetime --commit сохраняет from окна и to в naive UTC . """
from datetime import datetime
import yaml
issue = _MockIssue ( )
@@ -732,7 +734,7 @@ class TestCommitFlag:
code = main (
[
" --date " ,
" 2026-06-30--2026-06-3 0 " ,
" 2026-09-10T09:00:00--2026-09-12T18:00:0 0 " ,
" --commit " ,
" --output " ,
str ( tmp_path / " report.xlsx " ) ,
@@ -745,17 +747,19 @@ class TestCommitFlag:
call_args = mock_save . call_args
assert call_args is not None
saved_from , saved_to = call_args . args [ 1 ] , call_args . args [ 2 ]
assert " T " in saved_from
assert " T " in saved_to
assert saved_from == " 2026-09-10T09:00:00 "
assert datetime . strptime ( saved_to , " % Y- % m- %d T % H: % M: % S " )
assert call_args . args [ 3 ] == " datetime "
assert call_args . args [ 4 ] is 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.save_period_to_config " )
def test_commit_with_precision_datetime_saves_awar e_utc (
def test_commit_with_precision_datetime_saves_naiv e_utc_to (
self , mock_save , mock_fetch , tmp_path
) :
""" При precision=datetime --commit сохраняет last_used как awar e UTC (#58 ). """
from datetime import datetime , timezone
""" При precision=datetime --commit сохраняет to как naiv e UTC (#70 ). """
from datetime import datetime
import yaml
@@ -773,7 +777,7 @@ class TestCommitFlag:
code = main (
[
" --date " ,
" 2026-06-30--2026-06-3 0 " ,
" 2026-09-10T09:00:00--2026-09-12T18:00:0 0 " ,
" --commit " ,
" --output " ,
str ( tmp_path / " report.xlsx " ) ,
@@ -786,10 +790,60 @@ class TestCommitFlag:
call_args = mock_save . call_args
assert call_args is not None
saved_from , saved_to = call_args . args [ 1 ] , call_args . args [ 2 ]
for saved in ( saved_from , saved_to ) :
parsed = datetime . fromisoformat ( saved )
assert parsed . tzinfo is not None , f " { saved } must be timezone-aware "
assert parsed . utcoffset ( ) == timezone . utc . utcoffset ( None )
assert saved_from == " 2026-09-10T09:00:00 "
parsed = datetime . fromisoformat ( saved_to )
assert parsed . tzinfo is None , f " { saved_to } must be naive UTC "
@mock.patch.dict ( os . environ , VALID_ENV , clear = True )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@mock.patch ( " redmine_reporter.cli.save_period_to_config " )
def test_commit_datetime_persists_window_start_and_now (
self , mock_save , mock_fetch , tmp_path
) :
""" --commit при datetime сохраняет начало окна и текущий naive UTC (#70). """
from datetime import datetime as real_datetime
import yaml
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
config_path = tmp_path / " config.yml "
config_path . write_text (
yaml . dump ( { " period " : { " precision " : " datetime " , " dynamic " : True } } )
)
fake_datetime = mock . MagicMock ( )
fake_datetime . fromisoformat = real_datetime . fromisoformat
fake_datetime . strptime = real_datetime . strptime
fake_datetime . now . return_value = real_datetime ( 2026 , 9 , 15 , 12 , 0 , 0 )
with mock . patch ( " redmine_reporter.cli.datetime " , fake_datetime ) :
with mock . patch (
" redmine_reporter.cli.get_formatter_by_extension "
) as mock_get :
mock_formatter = mock . MagicMock ( )
mock_get . return_value = mock_formatter
code = main (
[
" --commit " ,
" --date " ,
" 2026-09-10T09:00:00--2026-09-12T18:00:00 " ,
" --output " ,
str ( tmp_path / " report.xlsx " ) ,
" --config-path " ,
str ( config_path ) ,
]
)
assert code == 0
mock_save . assert_called_once_with (
str ( config_path ) ,
" 2026-09-10T09:00:00 " ,
" 2026-09-15T12:00:00 " ,
" datetime " ,
True ,
)
@mock.patch.dict ( os . environ , VALID_ENV , clear = True )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
@@ -964,8 +1018,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 +1061,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 +1131,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 +1180,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 +1239,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 +1286,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 +1530,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 ( )
@@ -1517,11 +1589,10 @@ class TestReportNoTimeIntegration:
@mock.patch.dict ( os . environ , VALID_ENV , clear = True )
@mock.patch ( " redmine_reporter.cli.fetch_issues_with_spent_time " )
def test_cli_dynamic_datetime_period_without_date ( mock_fetch , tmp_path ) :
""" dynamic+datetime без --date: datetime-диапазон из last_used принимается .
""" dynamic+datetime без --date: скользящее окно от last_used.to до now (#70) .
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
отчёт строится без ошибки.
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
"""
issue = _MockIssue ( )
mock_fetch . return_value = [ ( issue , 1.0 , None ) ]
@@ -1536,14 +1607,17 @@ def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
" to: ' 2026-06-30T23:59:59 ' \n "
)
code = main ( [ " --config-path " , str ( config_path ) ] )
real_dt = __import__ ( " datetime " ) . datetime
with mock . patch ( " redmine_reporter.config.datetime " ) as dt :
dt . fromisoformat = real_dt . fromisoformat
dt . now . return_value = real_dt ( 2026 , 7 , 15 , 10 , 0 , 0 )
code = main ( [ " --config-path " , str ( config_path ) ] )
assert code == 0
args , _ = mock_fetch . call_args
from_date , to_date = args [ 0 ] , args [ 1 ]
# Следующий период после 2026-06-01T00:00:00-- 2026-06-30T23:59:59
assert from _date == " 2026-07-01T0 0:00:00 "
assert to_date == " 2026-07-30T23:59:59 "
assert from_date == " 2026-06-30T23:59:59"
assert to _date == " 2026-07-15T1 0:00:00 "
# ---------------------------------------------------------------------------
@@ -1660,3 +1734,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]: " )