diff --git a/elementary/monitor/alerts/alert.py b/elementary/monitor/alerts/alert.py index eb1af224f..10a2aeeac 100644 --- a/elementary/monitor/alerts/alert.py +++ b/elementary/monitor/alerts/alert.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone as dt_timezone from typing import Dict, List, Optional from dateutil import tz @@ -58,8 +58,16 @@ def __init__( self.detected_at = None if detected_at is not None: try: - self.detected_at_utc = detected_at - self.detected_at = detected_at.astimezone( + # Timestamps from the DB are stored as UTC but arrive as naive + # datetimes. Python's astimezone() interprets a naive datetime + # as *local* time, so we must attach UTC tzinfo first. + detected_at_utc_aware = ( + detected_at.replace(tzinfo=dt_timezone.utc) + if detected_at.tzinfo is None + else detected_at + ) + self.detected_at_utc = detected_at_utc_aware + self.detected_at = detected_at_utc_aware.astimezone( tz.gettz(timezone) if timezone else tz.tzlocal() ) except Exception: diff --git a/tests/unit/alerts/test_alert_detected_at_timezone.py b/tests/unit/alerts/test_alert_detected_at_timezone.py new file mode 100644 index 000000000..dd510f93d --- /dev/null +++ b/tests/unit/alerts/test_alert_detected_at_timezone.py @@ -0,0 +1,58 @@ +""" +Regression tests for issue #2304: +AlertModel must treat naive detected_at values as UTC, not local time. +""" +from datetime import datetime, timezone as dt_timezone + +import pytest + +from elementary.monitor.alerts.alert import AlertModel + + +def _make_alert(detected_at, timezone=None): + return AlertModel( + id="test", + alert_class_id="cls", + detected_at=detected_at, + timezone=timezone, + ) + + +def test_naive_detected_at_treated_as_utc_not_local(): + """A naive datetime must be interpreted as UTC, not as local wall-clock time. + + Regression: alert.detected_at_utc must equal the naive input with UTC tzinfo + attached, regardless of the process timezone. + """ + naive_utc = datetime(2026, 7, 22, 9, 8, 22) + alert = _make_alert(naive_utc, timezone="Asia/Tokyo") + + expected_utc = naive_utc.replace(tzinfo=dt_timezone.utc) + assert alert.detected_at_utc == expected_utc, ( + "detected_at_utc must be the naive input with UTC tzinfo, " + f"got {alert.detected_at_utc!r}" + ) + + # After conversion to JST (+09:00) the hour must be 18 (9 + 9) + assert alert.detected_at is not None + assert alert.detected_at.hour == 18, ( + "09:08:22 UTC converted to JST (+09:00) must be 18:08:22, " + f"got {alert.detected_at}" + ) + + +def test_aware_detected_at_is_not_re_interpreted(): + """An already-aware datetime must be used as-is (no double-conversion).""" + aware_utc = datetime(2026, 7, 22, 9, 8, 22, tzinfo=dt_timezone.utc) + alert = _make_alert(aware_utc, timezone="Asia/Tokyo") + + assert alert.detected_at_utc == aware_utc + assert alert.detected_at is not None + assert alert.detected_at.hour == 18 + + +def test_none_detected_at_leaves_fields_none(): + alert = _make_alert(None) + assert alert.detected_at is None + assert alert.detected_at_utc is None + assert alert.detected_at_str == "N/A"