"""Tests for app/services/timezone.py (FU10-energy-tz-accrual). All tests monkeypatch ``local_tz`` to a fixed zone so they are deterministic on any CI host timezone. """ from __future__ import annotations from datetime import UTC, date, datetime from zoneinfo import ZoneInfo import pytest from app.services import timezone as tz_mod # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @pytest.fixture() def ams(monkeypatch): """Pin local_tz to Europe/Amsterdam for all tests in this module.""" monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) @pytest.fixture() def utc_tz(monkeypatch): """Pin local_tz to UTC for simple arithmetic tests.""" monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("UTC")) # --------------------------------------------------------------------------- # local_tz() — resolution and monkeypatch contract # --------------------------------------------------------------------------- def test_local_tz_returns_tzinfo(ams): """local_tz() must return a tzinfo object.""" tz = tz_mod.local_tz() assert tz is not None # Verify it behaves like a valid tzinfo. d = datetime(2026, 6, 25, 12, 0, tzinfo=tz) assert d.utcoffset() is not None def test_local_tz_env_var_overrides(monkeypatch): """When TZ env var is set, local_tz() returns that zone.""" monkeypatch.setenv("TZ", "America/New_York") tz = tz_mod.local_tz() # ZoneInfo("America/New_York") should have UTC-5 or UTC-4 offset. d = datetime(2026, 1, 1, 12, 0, tzinfo=tz) # January → UTC-5 offset_hours = d.utcoffset().total_seconds() / 3600 assert offset_hours == pytest.approx(-5.0) monkeypatch.delenv("TZ") # --------------------------------------------------------------------------- # to_local() — conversion correctness # --------------------------------------------------------------------------- def test_to_local_aware_utc(ams): """Aware UTC datetime must convert to CEST (UTC+2) in summer.""" dt_utc = datetime(2026, 6, 25, 12, 0, tzinfo=UTC) local = tz_mod.to_local(dt_utc) assert local.tzinfo is not None assert local.hour == 14 # CEST = UTC+2 def test_to_local_naive_treated_as_utc(ams): """Naive datetime must be treated as UTC and converted to local.""" naive = datetime(2026, 6, 25, 12, 0) # naive = UTC convention local = tz_mod.to_local(naive) assert local.hour == 14 # CEST = UTC+2 def test_to_local_winter_offset(monkeypatch): """CET (UTC+1) applies in January.""" monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) dt_utc = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) local = tz_mod.to_local(dt_utc) assert local.hour == 13 # CET = UTC+1 # --------------------------------------------------------------------------- # local_date() — date extraction # --------------------------------------------------------------------------- def test_local_date_around_cest_midnight(ams): """UTC 22:00 on June 24 is local midnight June 25 in CEST.""" # CEST = UTC+2; June 24 22:00 UTC = June 25 00:00 CEST dt = datetime(2026, 6, 24, 22, 0, tzinfo=UTC) d = tz_mod.local_date(dt) assert d == date(2026, 6, 25) def test_local_date_just_before_cest_midnight(ams): """UTC 21:59 on June 24 is still June 24 in CEST (23:59 local).""" dt = datetime(2026, 6, 24, 21, 59, tzinfo=UTC) d = tz_mod.local_date(dt) assert d == date(2026, 6, 24) # --------------------------------------------------------------------------- # local_midnight_utc() — round-trip correctness # --------------------------------------------------------------------------- def test_local_midnight_utc_cest_summer(ams): """Europe/Amsterdam local midnight June 25 → June 24 22:00 UTC (CEST=UTC+2).""" utc_midnight = tz_mod.local_midnight_utc(date(2026, 6, 25)) expected = datetime(2026, 6, 24, 22, 0, tzinfo=UTC) assert utc_midnight == expected def test_local_midnight_utc_cet_winter(monkeypatch): """Europe/Amsterdam local midnight Jan 15 → Jan 14 23:00 UTC (CET=UTC+1).""" monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) utc_midnight = tz_mod.local_midnight_utc(date(2026, 1, 15)) expected = datetime(2026, 1, 14, 23, 0, tzinfo=UTC) assert utc_midnight == expected def test_local_midnight_utc_roundtrip(ams): """local_date(local_midnight_utc(D)) must return D for any date D.""" for day in range(1, 32): d = date(2026, 6, day) if day <= 30 else date(2026, 7, day - 30) if day > 30: continue midnight_utc = tz_mod.local_midnight_utc(d) recovered = tz_mod.local_date(midnight_utc) assert recovered == d, f"Round-trip failed for {d}" # --------------------------------------------------------------------------- # local_now() — sanity check # --------------------------------------------------------------------------- def test_local_now_is_aware(ams): """local_now() must return a timezone-aware datetime.""" now = tz_mod.local_now() assert now.tzinfo is not None assert now.utcoffset() is not None # --------------------------------------------------------------------------- # effective_from naive→local→UTC (Principle A — contract routes) # --------------------------------------------------------------------------- def test_localize_naive_effective_from(monkeypatch): """A naive '2026-06-25T00:00:00' must be interpreted as CEST local midnight. Expected: CEST midnight June 25 = UTC June 24 22:00. """ monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) # Simulate what the route helper does: from app.api.routes.api.energy_contracts import _localize_effective_from naive = datetime(2026, 6, 25, 0, 0, 0) # naive, no tzinfo utc_result = _localize_effective_from(naive) assert utc_result.tzinfo is not None expected = datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC) assert utc_result == expected, ( f"Naive local midnight should localize to {expected}, got {utc_result}" ) def test_localize_aware_effective_from(): """An aware datetime must be stored as UTC unchanged (no double conversion).""" from app.api.routes.api.energy_contracts import _localize_effective_from aware = datetime(2026, 6, 25, 0, 0, 0, tzinfo=UTC) utc_result = _localize_effective_from(aware) assert utc_result == aware def test_localize_none_effective_from(): """None effective_from must return current UTC time (as before).""" from app.api.routes.api.energy_contracts import _localize_effective_from before = datetime.now(UTC) result = _localize_effective_from(None) after = datetime.now(UTC) assert result.tzinfo is not None assert before <= result <= after