diff --git a/.env.example b/.env.example index 22c86e7..d2fb4b7 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ AUTH_BOOTSTRAP_PASSWORD=change-me # Optional: runtime overrides. # Leave these commented out to use the application's built-in defaults. +# TZ=Europe/Amsterdam # APP_DEBUG= # AUTH_SESSION_COOKIE_NAME= # AUTH_SESSION_TTL_HOURS= diff --git a/app/services/timezone.py b/app/services/timezone.py index e9e8657..a296a7d 100644 --- a/app/services/timezone.py +++ b/app/services/timezone.py @@ -19,8 +19,8 @@ Priority for resolving the local timezone ----------------------------------------- 1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``. Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling. -2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``. - This matches the behaviour callers already relied on implicitly. +2. The DST-aware ``Europe/Amsterdam`` business timezone. This keeps local-day + calculations deterministic when a deployment does not set ``TZ``. """ from __future__ import annotations @@ -40,7 +40,7 @@ def local_tz() -> "tzinfo": Resolution order: 1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set ``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 behaviour regardless of CI host configuration:: @@ -51,9 +51,7 @@ def local_tz() -> "tzinfo": tz_env = os.environ.get("TZ", "").strip() if tz_env: return ZoneInfo(tz_env) - # System fallback — identical to the .astimezone() pattern already used - # in homeassistant_inbound.py and poo.py. - return datetime.now().astimezone().tzinfo # type: ignore[return-value] + return ZoneInfo("Europe/Amsterdam") def to_local(dt: datetime) -> datetime: diff --git a/docker-compose.yml b/docker-compose.yml index 7019365..c45a9a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: restart: "no" init: true command: ["python", "-m", "scripts.run_migrations"] + environment: + TZ: "${TZ:-Europe/Amsterdam}" volumes: - ./data:/app/data - ./.env:/app/.env:ro @@ -17,6 +19,8 @@ services: user: "1000:1000" restart: unless-stopped init: true + environment: + TZ: "${TZ:-Europe/Amsterdam}" depends_on: migration: condition: service_completed_successfully diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 93e86d5..8b7f707 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -72,6 +72,21 @@ def test_compose_uses_migration_job_before_app() -> None: 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: """Base Compose maps the configured device with only pyserial's required access. diff --git a/tests/test_meter_cost_api.py b/tests/test_meter_cost_api.py index ab461a9..7e55f4b 100644 --- a/tests/test_meter_cost_api.py +++ b/tests/test_meter_cost_api.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from unittest.mock import patch +from zoneinfo import ZoneInfo import pytest 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 -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 now = datetime.now(UTC) values = { @@ -166,8 +169,10 @@ def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_ db.add_all((heating, water)) db.commit() _login(client) - with patch("app.api.routes.api.meter_costs.local_now", return_value=datetime(2026, 6, 25, 2, tzinfo=UTC)): - response = client.get("/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z") + 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" + ) assert response.status_code == 200 body = response.json() assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8" diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 35a34af..4610cf6 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -7,7 +7,7 @@ on any CI host timezone. from __future__ import annotations from datetime import UTC, date, datetime -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import pytest @@ -56,6 +56,38 @@ def test_local_tz_env_var_overrides(monkeypatch): 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 # ---------------------------------------------------------------------------