mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
426 lines
13 KiB
Python
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 == []
|