hass.tibber_prices/tests/test_user_data_validation.py

426 lines
13 KiB
Python

"""
Test user data validation and currency extraction.
This test covers issue #60 where the Tibber API can temporarily return
incomplete or invalid data during maintenance or cache refresh periods.
The issue manifested when:
1. User updated integration while Tibber API was returning incomplete data
2. Integration accepted and cached the incomplete data
3. Next access crashed or used wrong currency (EUR fallback)
4. Next day at 13:02, user_data refreshed (24h interval) with correct data
5. Issue "fixed itself" because cache was updated with valid data
The fix implements data validation that:
- Rejects incomplete user data from API
- Keeps existing cached data when validation fails
- Only accepts data with complete home info (timezone, currency if subscription exists)
- Raises exception if currency cannot be determined (no silent EUR fallback)
"""
from datetime import datetime, timedelta
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
from custom_components.tibber_prices.api.helpers import flatten_price_info
from custom_components.tibber_prices.coordinator.price_data_manager import (
TibberPricesPriceDataManager,
)
@pytest.fixture
def mock_api_client() -> Mock:
"""Create a mock API client."""
return Mock()
@pytest.fixture
def mock_time_service() -> Mock:
"""Create a mock time service."""
time_service = Mock()
time_service.now.return_value = datetime(2025, 11, 22, 12, 0, 0, tzinfo=ZoneInfo("Europe/Berlin"))
time_service.as_local.side_effect = lambda dt: dt
return time_service
@pytest.fixture
def mock_store() -> Mock:
"""Create a mock store."""
return Mock()
@pytest.fixture
def mock_interval_pool() -> Mock:
"""Create a mock interval pool."""
return Mock()
@pytest.mark.unit
def test_validate_user_data_complete(mock_api_client, mock_time_service, mock_store, mock_interval_pool) -> None: # noqa: ANN001
"""Test that complete user data passes validation."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
"timeZone": "Europe/Berlin",
"currentSubscription": {
"priceInfo": {
"current": {
"currency": "EUR",
}
}
},
}
]
}
}
assert price_data_manager._validate_user_data(user_data, "home-123") is True # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_none_subscription(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""
Test that user data without subscription fails validation.
Currency is required for the integration to function - if the API returns
data without a subscription, we cannot extract the currency and must reject
the data. This ensures we keep using previously cached valid data instead
of accepting incomplete API responses.
"""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
"timeZone": "Europe/Berlin",
"currentSubscription": None, # No active subscription
}
]
}
}
# Should FAIL validation - subscription is required for currency
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_missing_timezone(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that user data without timezone fails validation."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
# Missing timeZone!
"currentSubscription": {
"priceInfo": {
"current": {
"currency": "EUR",
}
}
},
}
]
}
}
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_subscription_without_currency(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that user data with subscription but no currency fails validation."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
"timeZone": "Europe/Berlin",
"currentSubscription": {
"priceInfo": {
"current": {"currency": None} # Currency explicitly None
}
},
}
]
}
}
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_home_not_found(mock_api_client, mock_time_service, mock_store, mock_interval_pool) -> None: # noqa: ANN001
"""Test that user data without the requested home fails validation."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "other-home",
"timeZone": "Europe/Berlin",
}
]
}
}
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_rejects_missing_subscription(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that validation rejects user data when subscription is missing."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
# User data with missing subscription (temporary API issue)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
"timeZone": "Europe/Berlin",
"currentSubscription": None, # No subscription - should be rejected
}
]
}
}
# Validation should reject this data
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001
@pytest.mark.unit
def test_validate_user_data_rejects_missing_price_info(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that validation rejects user data when priceInfo is missing."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
user_data = {
"viewer": {
"homes": [
{
"id": "home-123",
"timeZone": "Europe/Berlin",
"currentSubscription": {
"priceInfo": None, # Missing priceInfo
},
}
]
}
}
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001
@pytest.mark.unit
def test_get_currency_raises_on_no_cached_data(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that _get_currency_for_home raises exception when no data cached."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
# No cached data
with pytest.raises(TibberPricesApiClientError, match="No user data cached"):
price_data_manager._get_currency_for_home("home-123") # noqa: SLF001
@pytest.mark.unit
def test_get_currency_raises_on_no_subscription(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that _get_currency_for_home raises exception when home has no subscription."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
price_data_manager._cached_user_data = { # noqa: SLF001
"viewer": {
"homes": [
{
"id": "home-123",
"currentSubscription": None, # No subscription
}
]
}
}
with pytest.raises(TibberPricesApiClientError, match="has no active subscription"):
price_data_manager._get_currency_for_home("home-123") # noqa: SLF001
@pytest.mark.unit
def test_get_currency_extracts_valid_currency(
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
) -> None:
"""Test that _get_currency_for_home successfully extracts currency."""
price_data_manager = TibberPricesPriceDataManager(
api=mock_api_client,
store=mock_store,
log_prefix="[Test]",
user_update_interval=timedelta(days=1),
time=mock_time_service,
home_id="home-123",
interval_pool=mock_interval_pool,
)
price_data_manager._cached_user_data = { # noqa: SLF001
"viewer": {
"homes": [
{
"id": "home-123",
"currentSubscription": {
"priceInfo": {
"current": {
"currency": "NOK",
}
}
},
}
]
}
}
assert price_data_manager._get_currency_for_home("home-123") == "NOK" # noqa: SLF001
@pytest.mark.unit
def test_flatten_price_info_with_none_priceinfo() -> None:
"""Test that flatten_price_info handles None priceInfo gracefully."""
subscription = {
"priceInfoRange": {
"edges": [
{"node": {"startsAt": "2025-12-10T00:00:00", "total": 0.25, "level": "NORMAL"}},
]
},
"priceInfo": None, # ← Key exists but value is None
}
# Should not crash, should return only historical prices
result = flatten_price_info(subscription)
assert len(result) == 1
assert result[0]["total"] == 0.25
@pytest.mark.unit
def test_flatten_price_info_with_none_today() -> None:
"""Test that flatten_price_info handles None today gracefully."""
subscription = {
"priceInfoRange": {"edges": []},
"priceInfo": {
"today": None, # ← Key exists but value is None
"tomorrow": [
{"startsAt": "2025-12-13T00:00:00", "total": 0.30, "level": "NORMAL"},
],
},
}
# Should not crash, should return only tomorrow prices
result = flatten_price_info(subscription)
assert len(result) == 1
assert result[0]["total"] == 0.30
@pytest.mark.unit
def test_flatten_price_info_with_all_none() -> None:
"""Test that flatten_price_info handles all None values gracefully."""
subscription = {
"priceInfoRange": None,
"priceInfo": None,
}
# Should not crash, should return empty list
result = flatten_price_info(subscription)
assert result == []