Files

188 lines
6.8 KiB
Python
Raw Permalink Normal View History

"""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