hass.tibber_prices/tests/test_cache_age.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

180 lines
5.1 KiB
Python

"""
Unit tests for cache age calculation.
Tests the get_cache_age_minutes() method which calculates how old
the cached data is in minutes.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.sensor.calculators.lifecycle import (
TibberPricesLifecycleCalculator,
)
@pytest.mark.unit
def test_cache_age_no_update() -> None:
"""
Test cache age is None when no updates have occurred.
Scenario: Integration just started, no data fetched yet
Expected: Cache age is None
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
coordinator.time.now.return_value = current_time
coordinator._last_price_update = None # noqa: SLF001 - No update yet!
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age is None
@pytest.mark.unit
def test_cache_age_recent() -> None:
"""
Test cache age for recent data.
Scenario: Last update was 5 minutes ago
Expected: Cache age is 5 minutes
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(minutes=5)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age == 5
@pytest.mark.unit
def test_cache_age_old() -> None:
"""
Test cache age for older data.
Scenario: Last update was 90 minutes ago (6 update cycles missed)
Expected: Cache age is 90 minutes
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(minutes=90)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age == 90
@pytest.mark.unit
def test_cache_age_exact_minute() -> None:
"""
Test cache age calculation rounds down to minutes.
Scenario: Last update was 5 minutes and 45 seconds ago
Expected: Cache age is 5 minutes (int conversion truncates)
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(minutes=5, seconds=45)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
# int() truncates: 5.75 minutes → 5
assert age == 5
@pytest.mark.unit
def test_cache_age_zero_fresh_data() -> None:
"""
Test cache age is 0 for brand new data.
Scenario: Last update was just now (< 60 seconds ago)
Expected: Cache age is 0 minutes
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(seconds=30)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age == 0
@pytest.mark.unit
def test_cache_age_multiple_hours() -> None:
"""
Test cache age for very old data (multiple hours).
Scenario: Last update was 3 hours ago (180 minutes)
Expected: Cache age is 180 minutes
This could happen if API was down or integration was stopped.
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(hours=3)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age == 180
@pytest.mark.unit
def test_cache_age_boundary_60_seconds() -> None:
"""
Test cache age exactly at 60 seconds (1 minute boundary).
Scenario: Last update was exactly 60 seconds ago
Expected: Cache age is 1 minute
"""
coordinator = Mock()
coordinator.time = Mock()
current_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=ZoneInfo("Europe/Oslo"))
last_update = current_time - timedelta(seconds=60)
coordinator.time.now.return_value = current_time
coordinator._last_price_update = last_update # noqa: SLF001
calculator = TibberPricesLifecycleCalculator(coordinator)
age = calculator.get_cache_age_minutes()
assert age == 1