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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -605,256 +605,6 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
||||
assert len(result) == 250
|
||||
|
||||
|
||||
# -- #47: Дедупликация time entries по datetime --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
||||
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e2 = mock.MagicMock()
|
||||
e2.issue.id = 2
|
||||
e2.hours = 1.0
|
||||
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1, e2]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0][0].id == 2
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
|
||||
mock_redmine_class,
|
||||
):
|
||||
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
mock_redmine.issue.filter.return_value = []
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
|
||||
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
|
||||
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = None
|
||||
e1.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_handles_string_created_on(mock_redmine_class):
|
||||
"""created_on может быть строкой ISO — парсим корректно."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = "2026-07-01T10:00:00Z"
|
||||
e1.updated_on = "2026-07-01T10:00:00Z"
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
mock_redmine.issue.filter.return_value = []
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
|
||||
"""Без dedup_before фильтрация не применяется."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = None
|
||||
e1.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
||||
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_old = mock.MagicMock()
|
||||
e_old.issue.id = 10
|
||||
e_old.hours = 1.0
|
||||
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_new = mock.MagicMock()
|
||||
e_new.issue.id = 20
|
||||
e_new.hours = 3.0
|
||||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_akiy = mock.MagicMock()
|
||||
e_akiy.issue.id = 30
|
||||
e_akiy.hours = 5.0
|
||||
e_akiy.created_on = None
|
||||
e_akiy.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
|
||||
|
||||
mock_issue_new = mock.MagicMock()
|
||||
mock_issue_new.id = 20
|
||||
mock_issue_new.project = "P"
|
||||
mock_issue_new.subject = "T"
|
||||
mock_issue_new.status = "New"
|
||||
mock_issue_akiy = mock.MagicMock()
|
||||
mock_issue_akiy.id = 30
|
||||
mock_issue_akiy.project = "P"
|
||||
mock_issue_akiy.subject = "T2"
|
||||
mock_issue_akiy.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 2
|
||||
ids = {r[0].id for r in result}
|
||||
assert ids == {20, 30}
|
||||
|
||||
|
||||
# -- Пагинация time entries (>100 записей) --
|
||||
|
||||
|
||||
@@ -904,149 +654,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||
assert hours_by_id[2] == 100.0
|
||||
|
||||
|
||||
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
|
||||
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
|
||||
|
||||
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
|
||||
старые записи отсекаются, новые сохраняются.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_old = mock.MagicMock()
|
||||
e_old.issue.id = 1
|
||||
e_old.hours = 2.0
|
||||
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
|
||||
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||
|
||||
e_new = mock.MagicMock()
|
||||
e_new.issue.id = 2
|
||||
e_new.hours = 1.0
|
||||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
|
||||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_issue2.subject = "T"
|
||||
mock_issue2.status = "New"
|
||||
# API возвращает обе задачи: issue 1 попадёт в результат, только если
|
||||
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
|
||||
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
|
||||
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert [r[0].id for r in result] == [2]
|
||||
|
||||
|
||||
# -- #58: нормализация datetime к aware UTC --
|
||||
|
||||
|
||||
class TestParseDatetimeNormalization:
|
||||
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
|
||||
|
||||
def test_naive_datetime_treated_as_utc(self):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from redmine_reporter.client import _parse_datetime
|
||||
|
||||
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
|
||||
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_aware_datetime_kept(self):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from redmine_reporter.client import _parse_datetime
|
||||
|
||||
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
|
||||
assert _parse_datetime(aware) is aware
|
||||
|
||||
def test_naive_iso_string_treated_as_utc(self):
|
||||
from datetime import timezone
|
||||
|
||||
from redmine_reporter.client import _parse_datetime
|
||||
|
||||
result = _parse_datetime("2026-07-01T10:00:00")
|
||||
assert result is not None
|
||||
assert result.tzinfo == timezone.utc
|
||||
|
||||
def test_aware_iso_string_keeps_offset(self):
|
||||
from datetime import timedelta
|
||||
|
||||
from redmine_reporter.client import _parse_datetime
|
||||
|
||||
result = _parse_datetime("2026-07-01T10:00:00+03:00")
|
||||
assert result is not None
|
||||
assert result.utcoffset() == timedelta(hours=3)
|
||||
|
||||
def test_none_and_garbage_return_none(self):
|
||||
from redmine_reporter.client import _parse_datetime
|
||||
|
||||
assert _parse_datetime(None) is None
|
||||
assert _parse_datetime("not a date") is None
|
||||
assert _parse_datetime(123) is None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
|
||||
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
|
||||
from datetime import datetime
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
|
||||
|
||||
e_old = mock.MagicMock()
|
||||
e_old.issue.id = 1
|
||||
e_old.hours = 2.0
|
||||
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||
|
||||
e_new = mock.MagicMock()
|
||||
e_new.issue.id = 2
|
||||
e_new.hours = 1.0
|
||||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert [r[0].id for r in result] == [2]
|
||||
|
||||
|
||||
# -- #61: предупреждения о молчаливой потере данных --
|
||||
|
||||
|
||||
@@ -1153,3 +760,48 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys):
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "activit" in captured.err.lower()
|
||||
|
||||
|
||||
# -- Границы периода передаются в API как есть --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
|
||||
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
mock_redmine.time_entry.filter.return_value = []
|
||||
|
||||
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
|
||||
|
||||
mock_redmine.time_entry.filter.assert_called_once_with(
|
||||
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_fetch_works_without_dedup_argument(mock_redmine_class):
|
||||
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
entry = mock.MagicMock()
|
||||
entry.issue.id = 1
|
||||
entry.hours = 2.0
|
||||
entry.created_on = None
|
||||
entry.updated_on = None
|
||||
mock_redmine.time_entry.filter.return_value = [entry]
|
||||
|
||||
mock_issue = mock.MagicMock()
|
||||
mock_issue.id = 1
|
||||
mock_issue.project = "P"
|
||||
mock_issue.subject = "T"
|
||||
mock_issue.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue]
|
||||
|
||||
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
|
||||
|
||||
assert result is not None
|
||||
assert [r[0].id for r in result] == [1]
|
||||
assert result[0][1] == 2.0
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -8,6 +9,18 @@ import pytest
|
||||
from redmine_reporter.config import AppConfig, Config, EmailConfig
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mock_today(fake_today):
|
||||
"""Подменяет date.today() в config, сохраняя реальные date() и fromisoformat()."""
|
||||
from datetime import date as real_date
|
||||
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = fake_today
|
||||
mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw)
|
||||
mock_date.fromisoformat.side_effect = real_date.fromisoformat
|
||||
yield
|
||||
|
||||
|
||||
@mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
@@ -62,25 +75,9 @@ def test_get_author_fallback():
|
||||
assert Config.get_author("") == ""
|
||||
|
||||
|
||||
@mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DEFAULT_FROM_DATE": "2026-01-01",
|
||||
"DEFAULT_TO_DATE": "2026-01-31",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True)
|
||||
def test_get_default_date_range_from_env():
|
||||
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
|
||||
|
||||
|
||||
@mock.patch.dict(
|
||||
os.environ,
|
||||
{"DEFAULT_FROM_DATE": "2026-01-01"},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_default_date_range_from_env_without_to():
|
||||
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
|
||||
"""FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
|
||||
from datetime import date
|
||||
|
||||
today = date.today().isoformat()
|
||||
@@ -99,25 +96,6 @@ def test_get_default_date_range_fallback():
|
||||
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_default_date_range_datetime_dynamic_sliding_window():
|
||||
app = AppConfig(
|
||||
period_precision="datetime",
|
||||
period_dynamic=True,
|
||||
period_last_used_to="2026-09-10T15:00:00+00:00",
|
||||
)
|
||||
Config._app = app
|
||||
try:
|
||||
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, 9, 15, 10, 0, 0)
|
||||
rng = Config.get_default_date_range()
|
||||
finally:
|
||||
Config._app = None
|
||||
assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00"
|
||||
|
||||
|
||||
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
||||
|
||||
|
||||
@@ -145,31 +123,98 @@ class TestDefaultPeriodIsCurrentMonth:
|
||||
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_yaml_default_from_falls_back_to_today(self):
|
||||
"""default_from задан, default_to нет → конец периода = сегодня."""
|
||||
def test_yaml_period_from_runs_to_today(self):
|
||||
"""period.from задан -- конец периода всегда сегодня."""
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
|
||||
yaml_path.write_text("period:\n from: '2026-04-01'\n")
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 4, 20)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
with mock_today(date(2026, 4, 20)):
|
||||
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
|
||||
|
||||
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
|
||||
def test_env_from_without_to_falls_back_to_today(self):
|
||||
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
|
||||
|
||||
class TestDefaultDateRangePeriodFrom:
|
||||
"""Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц."""
|
||||
|
||||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True)
|
||||
def test_env_from_runs_to_today(self):
|
||||
"""FROM_DATE задаёт начало, конец всегда сегодня."""
|
||||
from datetime import date
|
||||
|
||||
Config._app = None
|
||||
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 2, 10)
|
||||
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||
with mock_today(date(2026, 2, 10)):
|
||||
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
|
||||
|
||||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True)
|
||||
def test_env_from_wins_over_yaml_period_from(self):
|
||||
"""env FROM_DATE имеет приоритет над YAML period.from."""
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock_today(date(2026, 7, 15)):
|
||||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True)
|
||||
def test_env_from_in_future_clamps_to_today(self):
|
||||
"""from позже сегодня -- окно 'сегодня--сегодня', без инверсии."""
|
||||
from datetime import date
|
||||
|
||||
Config._app = None
|
||||
with mock_today(date(2026, 9, 15)):
|
||||
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_yaml_period_from_in_future_clamps_to_today(self):
|
||||
"""YAML period.from позже сегодня -- окно 'сегодня--сегодня'."""
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n from: '2026-12-31'\n")
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock_today(date(2026, 9, 15)):
|
||||
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog):
|
||||
"""period.from не дата -- warning в лог, окно = текущий месяц."""
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n from: 'not-a-date'\n")
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with mock_today(date(2026, 9, 15)):
|
||||
rng = Config.get_default_date_range()
|
||||
|
||||
assert rng == "2026-09-01--2026-09-15"
|
||||
assert "not-a-date" in caplog.text
|
||||
|
||||
@mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True)
|
||||
def test_malformed_env_from_warns_and_uses_current_month(self, caplog):
|
||||
"""FROM_DATE не дата -- warning в лог, окно = текущий месяц."""
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
Config._app = None
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with mock_today(date(2026, 9, 15)):
|
||||
rng = Config.get_default_date_range()
|
||||
|
||||
assert rng == "2026-09-01--2026-09-15"
|
||||
assert "garbage" in caplog.text
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_redmine_verify_default():
|
||||
@@ -318,13 +363,7 @@ redmine:
|
||||
verify_ssl: false
|
||||
|
||||
period:
|
||||
precision: datetime
|
||||
default_from: "2026-06-01"
|
||||
default_to: "2026-06-30"
|
||||
dynamic: true
|
||||
last_used:
|
||||
from: "2026-06-30T09:00:00"
|
||||
to: "2026-06-30T12:00:00"
|
||||
from: "2026-06-01"
|
||||
|
||||
output:
|
||||
dir: ~/reports
|
||||
@@ -363,12 +402,7 @@ class TestAppConfigFromYaml:
|
||||
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
|
||||
assert cfg.redmine_author == "Тестовый Автор"
|
||||
assert cfg.redmine_verify is False
|
||||
assert cfg.period_precision == "datetime"
|
||||
assert cfg.period_default_from == "2026-06-01"
|
||||
assert cfg.period_default_to == "2026-06-30"
|
||||
assert cfg.period_dynamic is True
|
||||
assert cfg.period_last_used_from == "2026-06-30T09:00:00"
|
||||
assert cfg.period_last_used_to == "2026-06-30T12:00:00"
|
||||
assert cfg.period_from == "2026-06-01"
|
||||
assert cfg.output_dir == "~/reports"
|
||||
assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
|
||||
assert cfg.output_default_format == "xlsx"
|
||||
@@ -391,7 +425,7 @@ class TestAppConfigFromYaml:
|
||||
assert cfg.redmine_url == "https://x.com/"
|
||||
# defaults for everything else
|
||||
assert cfg.redmine_author == ""
|
||||
assert cfg.period_precision == "date"
|
||||
assert cfg.period_from == ""
|
||||
assert cfg.output_dir == ""
|
||||
assert cfg.email.to == []
|
||||
|
||||
@@ -404,7 +438,36 @@ class TestAppConfigFromYaml:
|
||||
|
||||
assert cfg.redmine_url == ""
|
||||
assert cfg.redmine_api_key == ""
|
||||
assert cfg.period_precision == "date"
|
||||
assert cfg.period_from == ""
|
||||
|
||||
def test_period_from_loaded_from_yaml(self):
|
||||
"""period.from читается из YAML как единственный ключ периода."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n from: '2026-06-01'\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.period_from == "2026-06-01"
|
||||
|
||||
def test_old_period_keys_are_ignored(self):
|
||||
"""Устаревшие ключи period молча игнорируются -- читается только from."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: datetime\n"
|
||||
" dynamic: true\n"
|
||||
" default_from: '2026-01-01'\n"
|
||||
" default_to: '2026-01-31'\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-01-31T09:00:00'\n"
|
||||
" to: '2026-01-31T12:00:00'\n"
|
||||
)
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.period_from == ""
|
||||
|
||||
def test_file_not_found_returns_all_defaults(self):
|
||||
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
|
||||
@@ -552,14 +615,15 @@ class TestConfigYamlFallback:
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_date_range_from_yaml(self):
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
|
||||
)
|
||||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock_today(date(2026, 3, 15)):
|
||||
assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
@@ -589,205 +653,17 @@ class TestConfigYamlFallback:
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_default_date_range_env_beats_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
|
||||
)
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"},
|
||||
clear=True,
|
||||
):
|
||||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||
|
||||
|
||||
class TestConfigPeriodPrecision:
|
||||
"""Tests for period.precision and period.last_used."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_period_precision_defaults_to_date(self):
|
||||
"""Без YAML-конфига precision == 'date'."""
|
||||
Config._app = None
|
||||
assert Config.get_period_precision() == "date"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_period_precision_from_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n precision: datetime\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
assert Config.get_period_precision() == "datetime"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_last_used_from_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n"
|
||||
)
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
assert Config.get_last_used_from() == "2026-06-30T09:00:00"
|
||||
assert Config.get_last_used_to() == "2026-06-30T12:00:00"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_last_used_returns_empty_when_not_set(self):
|
||||
Config._app = AppConfig()
|
||||
assert Config.get_last_used_from() == ""
|
||||
assert Config.get_last_used_to() == ""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_yaml_loads_last_used_field(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n"
|
||||
)
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
assert cfg.period_last_used_from == "2026-07-01T08:00:00"
|
||||
assert cfg.period_last_used_to == "2026-07-01T18:00:00"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_yaml_without_last_used_has_empty_fields(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("period:\n precision: date\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
assert cfg.period_last_used_from == ""
|
||||
assert cfg.period_last_used_to == ""
|
||||
|
||||
|
||||
class TestComputeNextPeriod:
|
||||
"""Tests for compute_next_period()."""
|
||||
|
||||
def test_full_month_goes_to_next_full_month(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
|
||||
assert nf == "2026-07-01"
|
||||
assert nt == "2026-07-31"
|
||||
|
||||
def test_december_to_next_year_january(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
|
||||
assert nf == "2026-01-01"
|
||||
assert nt == "2026-01-31"
|
||||
|
||||
def test_february_2026_non_leap_to_march(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
|
||||
assert nf == "2026-03-01"
|
||||
assert nt == "2026-03-31"
|
||||
|
||||
def test_arbitrary_range_same_length_from_to_plus_one(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
|
||||
assert nf == "2026-06-21"
|
||||
assert nt == "2026-06-26"
|
||||
|
||||
def test_single_day_range_moves_one_day(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
|
||||
assert nf == "2026-06-16"
|
||||
assert nt == "2026-06-16"
|
||||
|
||||
def test_cross_month_range(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
|
||||
assert nf == "2026-07-06"
|
||||
assert nt == "2026-07-16"
|
||||
|
||||
def test_datetime_precision_moves_by_seconds(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period(
|
||||
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
|
||||
)
|
||||
assert nf == "2026-06-30T12:00:01"
|
||||
assert nt == "2026-06-30T15:00:01"
|
||||
|
||||
|
||||
class TestDefaultDateRangeWithLastUsed:
|
||||
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_uses_last_used_when_dynamic_true(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: true\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-05-01'\n"
|
||||
" to: '2026-05-31'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-06-01--2026-06-30"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_ignores_last_used_when_dynamic_false(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: false\n"
|
||||
" default_from: '2026-03-01'\n"
|
||||
" default_to: '2026-03-15'\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-06-01'\n"
|
||||
" to: '2026-06-30'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-03-01--2026-03-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_falls_back_when_no_last_used_even_with_dynamic(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: true\n"
|
||||
" default_from: '2026-04-01'\n"
|
||||
" default_to: '2026-04-15'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-04-01--2026-04-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_falls_back_to_today_when_default_to_missing(self):
|
||||
"""Если default_from задан, а default_to нет — конец периода сегодня."""
|
||||
from datetime import date
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: false\n"
|
||||
" default_from: '2026-04-01'\n"
|
||||
)
|
||||
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
today = date.today().isoformat()
|
||||
result = Config.get_default_date_range()
|
||||
assert result == f"2026-04-01--{today}"
|
||||
|
||||
with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True):
|
||||
with mock_today(date(2026, 7, 15)):
|
||||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||
|
||||
|
||||
class TestGetEmailConfig:
|
||||
|
||||
@@ -4,11 +4,14 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import (
|
||||
check_file_permissions,
|
||||
ensure_config_dir,
|
||||
expand_filename_template,
|
||||
resolve_env_vars,
|
||||
save_period_to_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -211,120 +214,65 @@ class TestResolveOutputPath:
|
||||
class TestSavePeriodToConfig:
|
||||
"""Tests for save_period_to_config()."""
|
||||
|
||||
def test_creates_last_used_in_empty_config(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
def test_creates_period_from_in_empty_config(self, tmp_path):
|
||||
config_path = tmp_path / "config.yml"
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
config_path.write_text("")
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01")
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
assert "default_from" not in data["period"]
|
||||
|
||||
def test_overwrites_existing_last_used(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
assert data["period"]["from"] == "2026-06-01"
|
||||
|
||||
def test_overwrites_existing_period_from(self, tmp_path):
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
|
||||
)
|
||||
config_path.write_text("period:\n from: '2026-05-01'\n")
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
save_period_to_config(str(config_path), "2026-06-01")
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_dynamic_false_overwrites_defaults(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
|
||||
)
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["default_from"] == "2026-06-01"
|
||||
assert data["period"]["default_to"] == "2026-06-30"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_datetime_precision_stores_timestamps(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path),
|
||||
"2026-06-30T09:00:00",
|
||||
"2026-06-30T12:00:00",
|
||||
"datetime",
|
||||
True,
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
|
||||
|
||||
def test_preserves_existing_sections(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
assert data["period"]["from"] == "2026-06-01"
|
||||
|
||||
def test_preserves_other_sections_and_keys(self, tmp_path):
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"redmine:\n"
|
||||
" url: https://example.com\n"
|
||||
" author: Test\n"
|
||||
"email:\n"
|
||||
" enabled: true\n"
|
||||
"output:\n"
|
||||
" dir: /tmp\n"
|
||||
)
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
save_period_to_config(str(config_path), "2026-06-01")
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["redmine"]["url"] == "https://example.com"
|
||||
assert data["redmine"]["author"] == "Test"
|
||||
assert data["output"]["dir"] == "/tmp"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["redmine"] == {"url": "https://example.com", "author": "Test"}
|
||||
assert data["email"] == {"enabled": True}
|
||||
assert data["output"] == {"dir": "/tmp"}
|
||||
assert data["period"] == {"from": "2026-06-01"}
|
||||
|
||||
def test_sets_permissions_0600(self, tmp_path):
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
save_period_to_config(str(config_path), "2026-06-01")
|
||||
|
||||
mode = config_path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
def test_creates_config_file_and_dir_when_missing(self, tmp_path):
|
||||
config_path = tmp_path / "nested" / "config.yml"
|
||||
assert not config_path.exists()
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01")
|
||||
|
||||
assert config_path.exists()
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
assert data["period"]["from"] == "2026-06-01"
|
||||
|
||||
Reference in New Issue
Block a user