#43 — Имя файла и пути по умолчанию: - resolve_output_path() в yaml_config.py: резолвит --output с учётом output.dir, filename_template, default_format из YAML-конфига - Bare format (xlsx/odt/...) → путь по шаблону - Без расширения → автодописывается default_format (.xlsx) - Config.get_output_dir/filename/default_format() #47 — datetime precision и дедупликация: - period.last_used.from/to в YAML-конфиге и AppConfig - period.precision: date|datetime - _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет cutoff из period.last_used.to - _parse_datetime() + AND-логика дедупликации в client.py: запись исключается если created_on И updated_on < cutoff - Пропуск issues без часов после дедупликации Closes #43 Closes #47
This commit is contained in:
@@ -435,3 +435,239 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
||||
assert len(call_chunks[2].split(",")) == 50
|
||||
assert result is not None
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user