M8-R11: make business timezone deterministic
frontend / frontend (push) Successful in 46s
pytest / test (push) Successful in 3m56s

This commit is contained in:
2026-08-27 12:16:52 +02:00
parent d5623b9fcb
commit 33ca3da593
6 changed files with 65 additions and 10 deletions
+1
View File
@@ -14,6 +14,7 @@ AUTH_BOOTSTRAP_PASSWORD=change-me
# Optional: runtime overrides. # Optional: runtime overrides.
# Leave these commented out to use the application's built-in defaults. # Leave these commented out to use the application's built-in defaults.
# TZ=Europe/Amsterdam
# APP_DEBUG= # APP_DEBUG=
# AUTH_SESSION_COOKIE_NAME= # AUTH_SESSION_COOKIE_NAME=
# AUTH_SESSION_TTL_HOURS= # AUTH_SESSION_TTL_HOURS=
+4 -6
View File
@@ -19,8 +19,8 @@ Priority for resolving the local timezone
----------------------------------------- -----------------------------------------
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``. 1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling. Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``. 2. The DST-aware ``Europe/Amsterdam`` business timezone. This keeps local-day
This matches the behaviour callers already relied on implicitly. calculations deterministic when a deployment does not set ``TZ``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,7 +40,7 @@ def local_tz() -> "tzinfo":
Resolution order: Resolution order:
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set 1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling. ``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
2. System local timezone via ``datetime.now().astimezone().tzinfo``. 2. The DST-aware ``Europe/Amsterdam`` business timezone.
**Monkeypatch this function in tests** to get deterministic timezone **Monkeypatch this function in tests** to get deterministic timezone
behaviour regardless of CI host configuration:: behaviour regardless of CI host configuration::
@@ -51,9 +51,7 @@ def local_tz() -> "tzinfo":
tz_env = os.environ.get("TZ", "").strip() tz_env = os.environ.get("TZ", "").strip()
if tz_env: if tz_env:
return ZoneInfo(tz_env) return ZoneInfo(tz_env)
# System fallback — identical to the .astimezone() pattern already used return ZoneInfo("Europe/Amsterdam")
# in homeassistant_inbound.py and poo.py.
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
def to_local(dt: datetime) -> datetime: def to_local(dt: datetime) -> datetime:
+4
View File
@@ -6,6 +6,8 @@ services:
restart: "no" restart: "no"
init: true init: true
command: ["python", "-m", "scripts.run_migrations"] command: ["python", "-m", "scripts.run_migrations"]
environment:
TZ: "${TZ:-Europe/Amsterdam}"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
@@ -17,6 +19,8 @@ services:
user: "1000:1000" user: "1000:1000"
restart: unless-stopped restart: unless-stopped
init: true init: true
environment:
TZ: "${TZ:-Europe/Amsterdam}"
depends_on: depends_on:
migration: migration:
condition: service_completed_successfully condition: service_completed_successfully
+15
View File
@@ -72,6 +72,21 @@ def test_compose_uses_migration_job_before_app() -> None:
assert dev["services"]["app"]["build"] == "." assert dev["services"]["app"]["build"] == "."
def test_compose_defaults_business_timezone_and_dev_inherits_it() -> None:
"""Both production services receive an overridable Amsterdam timezone.
Compose merges service ``environment`` mappings, and the dev override does
not replace either mapping, so the base default applies to base+dev too.
"""
base = _read_yaml("docker-compose.yml")
dev = _read_yaml("docker-compose.dev.yml")
default_tz = "${TZ:-Europe/Amsterdam}"
for service_name in ("migration", "app"):
assert base["services"][service_name]["environment"]["TZ"] == default_tz
assert "TZ" not in dev["services"].get(service_name, {}).get("environment", {})
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None: def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
"""Base Compose maps the configured device with only pyserial's required access. """Base Compose maps the configured device with only pyserial's required access.
+8 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -139,7 +140,9 @@ def test_meter_cost_summary_empty_and_recompute_csrf_window_validation(meter_cos
).status_code == 422 ).status_code == 422
def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_client) -> None: def test_meter_cost_summary_returns_five_fixed_components_and_totals(
meter_cost_client, monkeypatch: pytest.MonkeyPatch
) -> None:
client, engine = meter_cost_client client, engine = meter_cost_client
now = datetime.now(UTC) now = datetime.now(UTC)
values = { values = {
@@ -166,8 +169,10 @@ def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_
db.add_all((heating, water)) db.add_all((heating, water))
db.commit() db.commit()
_login(client) _login(client)
with patch("app.api.routes.api.meter_costs.local_now", return_value=datetime(2026, 6, 25, 2, tzinfo=UTC)): monkeypatch.setattr("app.services.timezone.local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
response = client.get("/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z") response = client.get(
"/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z"
)
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8" assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8"
+33 -1
View File
@@ -7,7 +7,7 @@ on any CI host timezone.
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytest import pytest
@@ -56,6 +56,38 @@ def test_local_tz_env_var_overrides(monkeypatch):
monkeypatch.delenv("TZ") monkeypatch.delenv("TZ")
@pytest.mark.parametrize("value", [None, "", " "])
def test_local_tz_defaults_to_dst_aware_amsterdam(monkeypatch, value: str | None):
"""Unset or blank TZ must use the deterministic Amsterdam business zone."""
if value is None:
monkeypatch.delenv("TZ", raising=False)
else:
monkeypatch.setenv("TZ", value)
tz = tz_mod.local_tz()
assert isinstance(tz, ZoneInfo)
assert tz.key == "Europe/Amsterdam"
winter = datetime(2026, 1, 15, 12, tzinfo=tz)
summer = datetime(2026, 7, 15, 12, tzinfo=tz)
assert winter.utcoffset().total_seconds() == 3600
assert summer.utcoffset().total_seconds() == 7200
def test_local_tz_explicit_utc_override(monkeypatch):
"""A non-empty TZ remains an operator-controlled override."""
monkeypatch.setenv("TZ", "UTC")
tz = tz_mod.local_tz()
assert isinstance(tz, ZoneInfo)
assert tz.key == "UTC"
def test_local_tz_invalid_explicit_override_fails_loudly(monkeypatch):
"""A non-empty invalid override must not silently become Amsterdam."""
monkeypatch.setenv("TZ", "Invalid/Timezone")
with pytest.raises(ZoneInfoNotFoundError):
tz_mod.local_tz()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# to_local() — conversion correctness # to_local() — conversion correctness
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------