hass.tibber_prices/tests/test_cache_validity.py
Julian Pawlowski 49866f26fa fix(coordinator): use coordinator update timestamp for cache validity
Cache validity now checks _last_coordinator_update (within 30min)
instead of _api_calls_today counter. Fixes false "stale" status
when coordinator runs every 15min but cache validation was only
checking API call counter.

Bug #1: Cache validity shows "stale" at 05:57 AM
Bug #2: Cache age calculation incorrect after midnight turnover
Bug #3: get_cache_validity inconsistent with cache_age sensor

Changes:
- Coordinator: Use _last_coordinator_update for cache validation
- Lifecycle: Extract cache validation to dedicated helper function
- Tests: 7 new tests covering midnight scenarios and edge cases

Impact: Cache validity sensor now accurately reflects coordinator
activity, not just explicit API calls. Correctly handles midnight
turnover without false "stale" status.
2025-11-22 04:44:22 +00:00

263 lines
8.5 KiB
Python

"""
Unit tests for cache validity checks.
Tests the is_cache_valid() function which determines if cached price data
is still current or needs to be refreshed.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.coordinator.cache import (
TibberPricesCacheData,
is_cache_valid,
)
@pytest.mark.unit
def test_cache_valid_same_day() -> None:
"""
Test cache is valid when data is from the same calendar day.
Scenario: Cache from 10:00, current time 15:00 (same day)
Expected: Cache is valid
"""
time_service = Mock()
cache_time = datetime(2025, 11, 22, 10, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is True
@pytest.mark.unit
def test_cache_invalid_different_day() -> None:
"""
Test cache is invalid when data is from a different calendar day.
Scenario: Cache from yesterday, current time today
Expected: Cache is invalid (date mismatch)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 50, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 10, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_invalid_no_price_data() -> None:
"""
Test cache is invalid when no price data exists.
Scenario: Cache exists but price_data is None
Expected: Cache is invalid
"""
time_service = Mock()
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data=None, # No price data!
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=current_time,
last_user_update=current_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_invalid_no_last_update() -> None:
"""
Test cache is invalid when last_price_update is None.
Scenario: Cache has data but no update timestamp
Expected: Cache is invalid
"""
time_service = Mock()
current_time = datetime(2025, 11, 22, 15, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=None, # No timestamp!
last_user_update=None,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_valid_after_midnight_turnover() -> None:
"""
Test cache validity after midnight turnover with updated timestamp.
Scenario: Midnight turnover occurred, _last_price_update was updated to new day
Expected: Cache is valid (same date as current)
This tests the fix for the "date_mismatch" bug where cache appeared invalid
after midnight despite successful data rotation.
"""
time_service = Mock()
# After midnight turnover, _last_price_update should be set to current time
turnover_time = datetime(2025, 11, 22, 0, 0, 5, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 10, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"yesterday": [1], "today": [2], "tomorrow": []}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=turnover_time, # Updated during turnover!
last_user_update=turnover_time,
last_midnight_check=turnover_time,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is True
@pytest.mark.unit
def test_cache_invalid_midnight_crossing_without_update() -> None:
"""
Test cache becomes invalid at midnight if timestamp not updated.
Scenario: HA restarted after midnight, cache still has yesterday's timestamp
Expected: Cache is invalid (would be caught and refreshed)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 55, 0, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 5, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time, # Still yesterday!
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False
@pytest.mark.unit
def test_cache_validity_timezone_aware() -> None:
"""
Test cache validity uses local timezone for date comparison.
Scenario: UTC midnight vs local timezone midnight (different dates)
Expected: Comparison done in local timezone, not UTC
This ensures that midnight turnover happens at local midnight,
not UTC midnight.
"""
time_service = Mock()
# 23:00 UTC on Nov 21 = 00:00 CET on Nov 22 (UTC+1)
cache_time_utc = datetime(2025, 11, 21, 23, 0, 0, tzinfo=ZoneInfo("UTC"))
current_time_utc = datetime(2025, 11, 21, 23, 30, 0, tzinfo=ZoneInfo("UTC"))
# Convert to local timezone (CET = UTC+1)
cache_time_local = cache_time_utc.astimezone(ZoneInfo("Europe/Oslo")) # 00:00 Nov 22
current_time_local = current_time_utc.astimezone(ZoneInfo("Europe/Oslo")) # 00:30 Nov 22
time_service.now.return_value = current_time_utc
time_service.as_local.return_value = current_time_local
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time_utc,
last_user_update=cache_time_utc,
last_midnight_check=None,
)
# Mock as_local for cache_time
def as_local_side_effect(dt: datetime) -> datetime:
if dt == cache_time_utc:
return cache_time_local
return current_time_local
time_service.as_local.side_effect = as_local_side_effect
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
# Both times are Nov 22 in local timezone → same date → valid
assert result is True
@pytest.mark.unit
def test_cache_validity_exact_midnight_boundary() -> None:
"""
Test cache validity exactly at midnight boundary.
Scenario: Cache from 23:59:59, current time 00:00:00
Expected: Cache is invalid (different calendar days)
"""
time_service = Mock()
cache_time = datetime(2025, 11, 21, 23, 59, 59, tzinfo=ZoneInfo("Europe/Oslo"))
current_time = datetime(2025, 11, 22, 0, 0, 0, tzinfo=ZoneInfo("Europe/Oslo"))
time_service.now.return_value = current_time
time_service.as_local.side_effect = lambda dt: dt
cache_data = TibberPricesCacheData(
price_data={"priceInfo": {"today": [1, 2, 3]}},
user_data={"viewer": {"home": {"id": "test"}}},
last_price_update=cache_time,
last_user_update=cache_time,
last_midnight_check=None,
)
result = is_cache_valid(cache_data, "[TEST]", time=time_service)
assert result is False