fix: normalize datetimes to aware UTC in dedup
Dedup with precision=datetime crashed with TypeError: redminelib returns naive created_on/updated_on while the cutoff from _compute_dedup_cutoff is aware UTC. Normalize at a single point: _parse_datetime now always returns an aware datetime (naive treated as UTC), matching its docstring. The dedup cutoff is normalized the same way, and any residual comparison TypeError is wrapped in RedmineAPIError instead of leaking raw. Also fix --commit saving last_used.to as naive local time; it now stores aware UTC (datetime.now(timezone.utc)) so the next run computes a correct aware cutoff. Closes #58
This commit is contained in:
@@ -745,11 +745,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="bug #58: naive created_on vs aware dedup cutoff raises TypeError; "
|
||||
"fix must normalize entry datetimes to aware (UTC)",
|
||||
)
|
||||
@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):
|
||||
@@ -801,3 +796,90 @@ def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
|
||||
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user