refactor: период через period.from вместо last_used; дедуп удалён
Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации.
This commit is contained in:
@@ -30,10 +30,14 @@ def _reset_config_overrides():
|
||||
[
|
||||
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
|
||||
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
|
||||
# datetime-границы флорятся до даты -- окно состоит из целых дней.
|
||||
(
|
||||
"2026-06-01T00:00:00--2026-06-30T23:59:59",
|
||||
("2026-06-01T00:00:00", "2026-06-30T23:59:59"),
|
||||
("2026-06-01", "2026-06-30"),
|
||||
),
|
||||
# Смешение форматов между границами допустимо.
|
||||
("2026-06-01--2026-06-30T23:59:59", ("2026-06-01", "2026-06-30")),
|
||||
("2026-06-01T09:30:00--2026-06-30", ("2026-06-01", "2026-06-30")),
|
||||
],
|
||||
)
|
||||
def test_parse_date_range_valid(date_arg, expected):
|
||||
@@ -48,7 +52,8 @@ def test_parse_date_range_valid(date_arg, expected):
|
||||
"2026-02-30--2026-03-01",
|
||||
"2026-02-01--2026-01-31",
|
||||
"2026-06-01T25:00:00--2026-06-30T23:59:59",
|
||||
"2026-06-01--2026-06-30T23:59:59",
|
||||
"2026-06-01 00:00:00--2026-06-30",
|
||||
"2026-06-01T00:00:00+03:00--2026-06-30T23:59:59",
|
||||
"2026-06-30T23:59:59--2026-06-01T00:00:00",
|
||||
],
|
||||
)
|
||||
@@ -438,8 +443,7 @@ class TestInitConfig:
|
||||
"REDMINE_URL=https://redmine.example.com\n"
|
||||
"REDMINE_API_KEY=secret-from-dotenv\n"
|
||||
"REDMINE_AUTHOR=ivan.ivanov\n"
|
||||
"DEFAULT_FROM_DATE=2026-01-01\n"
|
||||
"DEFAULT_TO_DATE=2026-01-31\n"
|
||||
"FROM_DATE=2026-01-01\n"
|
||||
"SMTP_PASSWORD=smtp-secret\n"
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -448,8 +452,7 @@ class TestInitConfig:
|
||||
"REDMINE_URL",
|
||||
"REDMINE_API_KEY",
|
||||
"REDMINE_AUTHOR",
|
||||
"DEFAULT_FROM_DATE",
|
||||
"DEFAULT_TO_DATE",
|
||||
"FROM_DATE",
|
||||
"SMTP_PASSWORD",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
@@ -464,8 +467,7 @@ class TestInitConfig:
|
||||
assert data["redmine"]["url"] == "https://redmine.example.com"
|
||||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||
assert data["redmine"]["author"] == "ivan.ivanov"
|
||||
assert data["period"]["default_from"] == "2026-01-01"
|
||||
assert data["period"]["default_to"] == "2026-01-31"
|
||||
assert data["period"]["from"] == "2026-01-01"
|
||||
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@@ -678,16 +680,14 @@ class TestCommitFlag:
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
|
||||
"""--commit сохраняет период в YAML-конфиг."""
|
||||
"""--commit пишет в period.from конец окна отчёта (день коммита)."""
|
||||
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": "date", "dynamic": True}})
|
||||
)
|
||||
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
|
||||
|
||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -705,36 +705,29 @@ class TestCommitFlag:
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
mock_save.assert_called_once_with(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||||
|
||||
@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_passes_datetime_strings(
|
||||
def test_commit_period_from_equals_window_end(
|
||||
self, mock_save, mock_fetch, tmp_path
|
||||
):
|
||||
"""При precision=datetime --commit сохраняет from окна и to в naive UTC."""
|
||||
from datetime import datetime
|
||||
|
||||
"""Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
|
||||
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}})
|
||||
)
|
||||
config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}}))
|
||||
|
||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-09-10T09:00:00--2026-09-12T18:00:00",
|
||||
"2026-09-01--2026-09-30",
|
||||
"--commit",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
@@ -744,40 +737,29 @@ class TestCommitFlag:
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
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 saved_from == "2026-09-10T09:00:00"
|
||||
assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S")
|
||||
assert call_args.args[3] == "datetime"
|
||||
assert call_args.args[4] is True
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-09-30")
|
||||
|
||||
@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_naive_utc_to(
|
||||
def test_commit_period_from_equals_window_end_year_boundary(
|
||||
self, mock_save, mock_fetch, tmp_path
|
||||
):
|
||||
"""При precision=datetime --commit сохраняет to как naive UTC (#70)."""
|
||||
from datetime import datetime
|
||||
|
||||
"""Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
|
||||
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}})
|
||||
)
|
||||
config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}}))
|
||||
|
||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_get.return_value = mock_formatter
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-09-10T09:00:00--2026-09-12T18:00:00",
|
||||
"2026-12-01--2026-12-31",
|
||||
"--commit",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
@@ -787,120 +769,7 @@ class TestCommitFlag:
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
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 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")
|
||||
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
||||
"""Без записей --commit не пишет конфиг."""
|
||||
mock_fetch.return_value = None
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text("period:\n precision: date\n dynamic: true\n")
|
||||
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-01-01--2026-01-31",
|
||||
"--commit",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
@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_dynamic_false_overwrites_defaults(
|
||||
self, mock_save, mock_fetch, tmp_path
|
||||
):
|
||||
"""При dynamic=false --commit перезаписывает default_from/to."""
|
||||
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": "date", "dynamic": False}})
|
||||
)
|
||||
|
||||
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(
|
||||
[
|
||||
"--date",
|
||||
"2026-06-01--2026-06-30",
|
||||
"--commit",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
mock_save.assert_called_once_with(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||||
)
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-12-31")
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
@@ -918,7 +787,7 @@ class TestCommitFlag:
|
||||
config_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"period": {"precision": "date", "dynamic": True},
|
||||
"period": {"from": "2026-06-01"},
|
||||
"output": {
|
||||
"dir": str(tmp_path / "reports"),
|
||||
"filename": "report_{date}.{ext}",
|
||||
@@ -956,9 +825,7 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||||
)
|
||||
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
|
||||
|
||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -982,16 +849,12 @@ class TestCommitFlag:
|
||||
@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_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
|
||||
"""Пустой список задач — --commit не сохраняет конфиг."""
|
||||
import yaml
|
||||
|
||||
mock_fetch.return_value = []
|
||||
def test_commit_no_entries_does_not_save(self, mock_save, mock_fetch, tmp_path):
|
||||
"""Без записей --commit не пишет конфиг."""
|
||||
mock_fetch.return_value = None
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||||
)
|
||||
config_path.write_text("period:\n from: '2026-01-01'\n")
|
||||
|
||||
code = main(
|
||||
[
|
||||
@@ -1005,6 +868,64 @@ class TestCommitFlag:
|
||||
assert code == 0
|
||||
mock_save.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.save_period_to_config")
|
||||
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
|
||||
"""Пустой список задач — --commit не сохраняет конфиг."""
|
||||
import yaml
|
||||
|
||||
mock_fetch.return_value = []
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(yaml.dump({"period": {"from": "2026-01-01"}}))
|
||||
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-01-01--2026-01-31",
|
||||
"--commit",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
mock_save.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.save_period_to_config")
|
||||
def test_commit_save_error_does_not_advance_period(
|
||||
self, mock_save, mock_fetch, tmp_path
|
||||
):
|
||||
"""Сбой записи отчёта не двигает окно: --commit фиксирует только успешно сохранённый отчёт."""
|
||||
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": {"from": "2026-06-01"}}))
|
||||
|
||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||
mock_formatter = mock.MagicMock()
|
||||
mock_formatter.save.side_effect = OSError("disk full")
|
||||
mock_get.return_value = mock_formatter
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-06-01--2026-06-30",
|
||||
"--commit",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
|
||||
assert code == 1
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #45: --send tests
|
||||
@@ -1203,8 +1124,7 @@ class TestSendFlag:
|
||||
" to:\n"
|
||||
" - boss@example.com\n"
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: true\n"
|
||||
" from: '2026-06-01'\n"
|
||||
)
|
||||
|
||||
code = main(
|
||||
@@ -1221,7 +1141,7 @@ class TestSendFlag:
|
||||
)
|
||||
assert code == 0
|
||||
mock_send.assert_called_once()
|
||||
mock_save.assert_called_once()
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
@@ -1231,6 +1151,192 @@ class TestSendFlag:
|
||||
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
|
||||
assert code == 0
|
||||
|
||||
@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")
|
||||
@mock.patch("builtins.input")
|
||||
def test_send_success_advances_period(
|
||||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||
):
|
||||
"""Успешная отправка сдвигает period.from на конец окна отчёта."""
|
||||
mock_input.return_value = "y"
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-06-01--2026-06-30",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
"--send",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||||
|
||||
@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")
|
||||
@mock.patch("builtins.input")
|
||||
def test_send_declined_does_not_advance_period(
|
||||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||
):
|
||||
"""Отказ от подтверждения не сдвигает окно."""
|
||||
mock_input.return_value = "n"
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-06-01--2026-06-30",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
"--send",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
mock_send.assert_not_called()
|
||||
mock_save.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("redmine_reporter.cli.save_period_to_config")
|
||||
@mock.patch("builtins.input")
|
||||
def test_send_smtp_error_does_not_advance_period(
|
||||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||
):
|
||||
"""Ошибка SMTP не сдвигает окно."""
|
||||
from redmine_reporter.client import RedmineAPIError
|
||||
|
||||
mock_input.return_value = "y"
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
mock_send.side_effect = RedmineAPIError("SMTP connection failed")
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
code = main(
|
||||
[
|
||||
"--date",
|
||||
"2026-06-01--2026-06-30",
|
||||
"--output",
|
||||
str(tmp_path / "report.xlsx"),
|
||||
"--send",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
]
|
||||
)
|
||||
assert code == 1
|
||||
mock_save.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("redmine_reporter.cli.save_period_to_config")
|
||||
@mock.patch("builtins.input")
|
||||
def test_send_commit_declined_send_still_advances(
|
||||
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||
):
|
||||
"""--send --commit: отказ от отправки не мешает явному --commit сдвинуть окно."""
|
||||
mock_input.return_value = "n"
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
mock_get.return_value = mock.MagicMock()
|
||||
|
||||
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"
|
||||
" from: '2026-06-01'\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_not_called()
|
||||
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
|
||||
|
||||
@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_send_commit_no_entries_does_not_save(self, mock_save, mock_fetch):
|
||||
"""--send --commit без записей не трогает конфиг."""
|
||||
mock_fetch.return_value = None
|
||||
code = main(["--date", "2026-06-01--2026-06-30", "--send", "--commit"])
|
||||
assert code == 0
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
class TestSendHtmlBody:
|
||||
"""Tests for email.html body generation."""
|
||||
@@ -1385,7 +1491,7 @@ class TestReportNoTimeIntegration:
|
||||
{
|
||||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||||
"report": {"no_time": True},
|
||||
"period": {"precision": "date", "dynamic": True},
|
||||
"period": {"from": "2026-06-01"},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1425,7 +1531,7 @@ class TestReportNoTimeIntegration:
|
||||
{
|
||||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||||
"report": {"no_time": False},
|
||||
"period": {"precision": "date", "dynamic": True},
|
||||
"period": {"from": "2026-06-01"},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1465,7 +1571,7 @@ class TestReportNoTimeIntegration:
|
||||
{
|
||||
"redmine": {"url": "https://x.com", "api_key": "token"},
|
||||
"report": {"no_time": False},
|
||||
"period": {"precision": "date", "dynamic": True},
|
||||
"period": {"from": "2026-06-01"},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1581,45 +1687,6 @@ class TestReportNoTimeIntegration:
|
||||
assert kwargs.get("no_time") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #59: dynamic + precision=datetime без --date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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: скользящее окно от last_used.to до now (#70).
|
||||
|
||||
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
|
||||
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
|
||||
"""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n"
|
||||
" precision: datetime\n"
|
||||
" dynamic: true\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-06-01T00:00:00'\n"
|
||||
" to: '2026-06-30T23:59:59'\n"
|
||||
)
|
||||
|
||||
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]
|
||||
assert from_date == "2026-06-30T23:59:59"
|
||||
assert to_date == "2026-07-15T10:00:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #55: sanitize Redmine exception output in CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user