""" 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 == []