- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py). - summarize: fixed fee / tax credit accrue only for elapsed whole local days, integrated across contract versions — no future-day inflation, no version-switch collapse. - MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline apportionment), anchored at the active contract's earliest version effective_from so the total sensors stay monotonic and never jump backward. - effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC. - frontend ContractForm sends local-midnight naive datetime (was UTC midnight). - get_costs_summary default window uses server-local "today".
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
"""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
|