diff --git a/tests/test_coordinator_shutdown.py b/tests/test_coordinator_shutdown.py new file mode 100644 index 0000000..b627bbe --- /dev/null +++ b/tests/test_coordinator_shutdown.py @@ -0,0 +1,62 @@ +"""Test coordinator shutdown and cache persistence.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Import at module level to avoid PLC0415 +from custom_components.tibber_prices.coordinator.core import ( + TibberPricesDataUpdateCoordinator, +) + + +@pytest.mark.asyncio +async def test_coordinator_shutdown_saves_cache() -> None: + """ + Test that coordinator saves cache during shutdown. + + This ensures no data is lost when Home Assistant shuts down. + """ + # Create mock coordinator bypassing __init__ + coordinator = object.__new__(TibberPricesDataUpdateCoordinator) + + # Mock the _store_cache method and listener manager + coordinator._store_cache = AsyncMock() # noqa: SLF001 + mock_manager = MagicMock() + mock_manager.cancel_timers = MagicMock() + coordinator._listener_manager = mock_manager # noqa: SLF001 + coordinator._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Call shutdown + await coordinator.async_shutdown() + + # Verify cache was saved + coordinator._store_cache.assert_called_once() # noqa: SLF001 + # Verify timers were cancelled + mock_manager.cancel_timers.assert_called_once() + + +@pytest.mark.asyncio +async def test_coordinator_shutdown_handles_cache_error() -> None: + """ + Test that shutdown completes even if cache save fails. + + Shutdown should be resilient and not raise exceptions. + """ + # Create mock coordinator bypassing __init__ + coordinator = object.__new__(TibberPricesDataUpdateCoordinator) + + # Mock _store_cache to raise an exception + coordinator._store_cache = AsyncMock(side_effect=OSError("Disk full")) # noqa: SLF001 + mock_manager = MagicMock() + mock_manager.cancel_timers = MagicMock() + coordinator._listener_manager = mock_manager # noqa: SLF001 + coordinator._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Shutdown should complete without raising + await coordinator.async_shutdown() + + # Verify _store_cache was called (even though it raised) + coordinator._store_cache.assert_called_once() # noqa: SLF001 + # Verify timers were still cancelled despite error + mock_manager.cancel_timers.assert_called_once() diff --git a/tests/test_resource_cleanup.py b/tests/test_resource_cleanup.py new file mode 100644 index 0000000..c5c7fe3 --- /dev/null +++ b/tests/test_resource_cleanup.py @@ -0,0 +1,350 @@ +"""Test resource cleanup and memory leak prevention.""" + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from custom_components.tibber_prices.binary_sensor.core import ( + TibberPricesBinarySensor, +) +from custom_components.tibber_prices.coordinator.core import ( + TibberPricesDataUpdateCoordinator, +) +from custom_components.tibber_prices.coordinator.listeners import ( + TibberPricesListenerManager, +) +from custom_components.tibber_prices.sensor.core import TibberPricesSensor + + +@pytest.mark.unit +class TestListenerCleanup: + """Test that listeners are properly removed to prevent memory leaks.""" + + def test_listener_manager_removes_time_sensitive_listeners(self) -> None: + """Test that time-sensitive listeners can be removed.""" + # Create listener manager + manager = object.__new__(TibberPricesListenerManager) + manager._time_sensitive_listeners = [] # noqa: SLF001 + manager._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Add a listener + callback = Mock() + remove_fn = manager.async_add_time_sensitive_listener(callback) + + # Verify listener was added + assert callback in manager._time_sensitive_listeners # noqa: SLF001 + assert len(manager._time_sensitive_listeners) == 1 # noqa: SLF001 + + # Remove listener + remove_fn() + + # Verify listener was removed + assert callback not in manager._time_sensitive_listeners # noqa: SLF001 + assert len(manager._time_sensitive_listeners) == 0 # noqa: SLF001 + + def test_listener_manager_removes_minute_listeners(self) -> None: + """Test that minute-update listeners can be removed.""" + # Create listener manager + manager = object.__new__(TibberPricesListenerManager) + manager._minute_update_listeners = [] # noqa: SLF001 + manager._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Add a listener + callback = Mock() + remove_fn = manager.async_add_minute_update_listener(callback) + + # Verify listener was added + assert callback in manager._minute_update_listeners # noqa: SLF001 + assert len(manager._minute_update_listeners) == 1 # noqa: SLF001 + + # Remove listener + remove_fn() + + # Verify listener was removed + assert callback not in manager._minute_update_listeners # noqa: SLF001 + assert len(manager._minute_update_listeners) == 0 # noqa: SLF001 + + def test_coordinator_removes_lifecycle_callbacks(self) -> None: + """Test that lifecycle callbacks can be unregistered.""" + # Create mock coordinator + coordinator = object.__new__(TibberPricesDataUpdateCoordinator) + coordinator._lifecycle_callbacks = [] # noqa: SLF001 + + # Register a callback + callback = Mock() + unregister_fn = coordinator.register_lifecycle_callback(callback) + + # Verify callback was registered + assert callback in coordinator._lifecycle_callbacks # noqa: SLF001 + assert len(coordinator._lifecycle_callbacks) == 1 # noqa: SLF001 + + # Unregister callback + unregister_fn() + + # Verify callback was removed + assert callback not in coordinator._lifecycle_callbacks # noqa: SLF001 + assert len(coordinator._lifecycle_callbacks) == 0 # noqa: SLF001 + + @pytest.mark.asyncio + async def test_sensor_cleanup_pattern_exists(self) -> None: + """ + Test that sensor cleanup code is present and follows correct pattern. + + Note: We can't easily test full entity initialization (requires too much mocking), + but we verify the cleanup pattern exists in the code. + """ + # Verify the async_will_remove_from_hass method exists and has cleanup code + assert hasattr(TibberPricesSensor, "async_will_remove_from_hass") + + # The implementation should call remove functions for all listener types + # This is verified by code inspection rather than runtime test + # Pattern exists in sensor/core.py lines 143-160 + + @pytest.mark.asyncio + async def test_binary_sensor_cleanup_pattern_exists(self) -> None: + """ + Test that binary sensor cleanup code is present and follows correct pattern. + + Note: We can't easily test full entity initialization (requires too much mocking), + but we verify the cleanup pattern exists in the code. + """ + # Verify the async_will_remove_from_hass method exists and has cleanup code + assert hasattr(TibberPricesBinarySensor, "async_will_remove_from_hass") + + # The implementation should call remove functions for all listener types + # This is verified by code inspection rather than runtime test + # Pattern exists in binary_sensor/core.py lines 65-79 + + +@pytest.mark.unit +class TestTimerCleanup: + """Test that timers are properly cancelled to prevent resource leaks.""" + + def test_cancel_timers_clears_quarter_hour_timer(self) -> None: + """Test that quarter-hour timer is cancelled and cleared.""" + # Create listener manager + manager = object.__new__(TibberPricesListenerManager) + mock_cancel = Mock() + manager._quarter_hour_timer_cancel = mock_cancel # noqa: SLF001 + manager._minute_timer_cancel = None # noqa: SLF001 + + # Cancel timers + manager.cancel_timers() + + # Verify cancel was called + mock_cancel.assert_called_once() + + # Verify reference was cleared + assert manager._quarter_hour_timer_cancel is None # noqa: SLF001 + + def test_cancel_timers_clears_minute_timer(self) -> None: + """Test that minute timer is cancelled and cleared.""" + # Create listener manager + manager = object.__new__(TibberPricesListenerManager) + manager._quarter_hour_timer_cancel = None # noqa: SLF001 + mock_cancel = Mock() + manager._minute_timer_cancel = mock_cancel # noqa: SLF001 + + # Cancel timers + manager.cancel_timers() + + # Verify cancel was called + mock_cancel.assert_called_once() + + # Verify reference was cleared + assert manager._minute_timer_cancel is None # noqa: SLF001 + + def test_cancel_timers_handles_both_timers(self) -> None: + """Test that both timers are cancelled together.""" + # Create listener manager + manager = object.__new__(TibberPricesListenerManager) + mock_quarter_cancel = Mock() + mock_minute_cancel = Mock() + manager._quarter_hour_timer_cancel = mock_quarter_cancel # noqa: SLF001 + manager._minute_timer_cancel = mock_minute_cancel # noqa: SLF001 + + # Cancel timers + manager.cancel_timers() + + # Verify both were called + mock_quarter_cancel.assert_called_once() + mock_minute_cancel.assert_called_once() + + # Verify references were cleared + assert manager._quarter_hour_timer_cancel is None # noqa: SLF001 + assert manager._minute_timer_cancel is None # noqa: SLF001 + + def test_cancel_timers_handles_none_gracefully(self) -> None: + """Test that cancel_timers doesn't crash if timers are None.""" + # Create listener manager with no timers + manager = object.__new__(TibberPricesListenerManager) + manager._quarter_hour_timer_cancel = None # noqa: SLF001 + manager._minute_timer_cancel = None # noqa: SLF001 + + # Should not raise + manager.cancel_timers() + + # Verify still None + assert manager._quarter_hour_timer_cancel is None # noqa: SLF001 + assert manager._minute_timer_cancel is None # noqa: SLF001 + + +@pytest.mark.unit +class TestConfigEntryCleanup: + """Test that config entry options listeners are properly managed.""" + + @pytest.mark.asyncio + async def test_options_update_listener_registered(self) -> None: + """Test that options update listener is registered via async_on_unload.""" + # This tests the pattern: entry.async_on_unload(entry.add_update_listener(...)) + # We test that this pattern exists in coordinator initialization + + from homeassistant.config_entries import ConfigEntry # noqa: PLC0415 + + # Create minimal mocks + hass = MagicMock() + config_entry = MagicMock(spec=ConfigEntry) + config_entry.entry_id = "test_entry" + config_entry.data = {} + config_entry.options = {} + config_entry.async_on_unload = Mock() + config_entry.add_update_listener = Mock(return_value=Mock()) + + # Create coordinator (which should register the listener) + coordinator = object.__new__(TibberPricesDataUpdateCoordinator) + coordinator.hass = hass + coordinator.config_entry = config_entry + coordinator._log_prefix = "[test]" # noqa: SLF001 + coordinator._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Initialize necessary components + from custom_components.tibber_prices.coordinator.data_transformation import ( # noqa: PLC0415 + TibberPricesDataTransformer, + ) + from custom_components.tibber_prices.coordinator.listeners import ( # noqa: PLC0415 + TibberPricesListenerManager, + ) + from custom_components.tibber_prices.coordinator.periods import ( # noqa: PLC0415 + TibberPricesPeriodCalculator, + ) + from custom_components.tibber_prices.coordinator.time_service import ( # noqa: PLC0415 + TibberPricesTimeService, + ) + + coordinator.time = TibberPricesTimeService(hass) + coordinator._listener_manager = object.__new__(TibberPricesListenerManager) # noqa: SLF001 + coordinator._data_transformer = object.__new__(TibberPricesDataTransformer) # noqa: SLF001 + coordinator._period_calculator = object.__new__(TibberPricesPeriodCalculator) # noqa: SLF001 + coordinator._lifecycle_callbacks = [] # noqa: SLF001 + + # Manually call the registration that happens in __init__ + # This tests the pattern: entry.async_on_unload(entry.add_update_listener(...)) + update_listener = config_entry.add_update_listener( + coordinator._handle_options_update # noqa: SLF001 + ) + config_entry.async_on_unload(update_listener) + + # Verify the listener was registered + config_entry.add_update_listener.assert_called_once() + config_entry.async_on_unload.assert_called_once() + + # Verify the cleanup function was passed to async_on_unload + cleanup_fn = config_entry.async_on_unload.call_args[0][0] + assert cleanup_fn is not None + + +@pytest.mark.unit +class TestCacheInvalidation: + """Test that caches are properly invalidated to prevent stale data.""" + + def test_config_cache_invalidated_on_options_change(self) -> None: + """Test that config caches are cleared when options change.""" + from custom_components.tibber_prices.coordinator.data_transformation import ( # noqa: PLC0415 + TibberPricesDataTransformer, + ) + + # Create transformer with cached config + transformer = object.__new__(TibberPricesDataTransformer) + transformer._config_cache = {"some": "data"} # noqa: SLF001 + transformer._config_cache_valid = True # noqa: SLF001 + transformer._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Invalidate cache + transformer.invalidate_config_cache() + + # Verify cache was cleared + assert transformer._config_cache_valid is False # noqa: SLF001 + assert transformer._config_cache is None # noqa: SLF001 + + def test_period_cache_invalidated_on_options_change(self) -> None: + """Test that period calculation cache is cleared when options change.""" + from custom_components.tibber_prices.coordinator.periods import ( # noqa: PLC0415 + TibberPricesPeriodCalculator, + ) + + # Create calculator with cached data + calculator = object.__new__(TibberPricesPeriodCalculator) + calculator._config_cache = {"some": "data"} # noqa: SLF001 + calculator._config_cache_valid = True # noqa: SLF001 + calculator._cached_periods = {"cached": "periods"} # noqa: SLF001 + calculator._last_periods_hash = "some_hash" # noqa: SLF001 + calculator._log = lambda *_a, **_kw: None # noqa: SLF001 + + # Invalidate cache + calculator.invalidate_config_cache() + + # Verify all caches were cleared + assert calculator._config_cache_valid is False # noqa: SLF001 + assert calculator._config_cache is None # noqa: SLF001 + assert calculator._cached_periods is None # noqa: SLF001 + assert calculator._last_periods_hash is None # noqa: SLF001 + + def test_trend_cache_cleared_on_coordinator_update(self) -> None: + """Test that trend cache is cleared when coordinator updates.""" + from custom_components.tibber_prices.sensor.calculators.trend import ( # noqa: PLC0415 + TibberPricesTrendCalculator, + ) + + # Create calculator with cached trend + calculator = object.__new__(TibberPricesTrendCalculator) + calculator._cached_trend_value = "some_trend" # noqa: SLF001 + calculator._trend_attributes = {"some": "data"} # noqa: SLF001 + + # Clear cache + calculator.clear_trend_cache() + + # Verify cache was cleared (clears _cached_trend_value + _trend_attributes) + assert calculator._cached_trend_value is None # noqa: SLF001 + assert calculator._trend_attributes == {} # noqa: SLF001 + + +@pytest.mark.unit +class TestStorageCleanup: + """Test that storage files are properly removed on entry removal.""" + + @pytest.mark.asyncio + async def test_storage_removed_on_entry_removal(self) -> None: + """Test that cache storage is deleted when config entry is removed.""" + from custom_components.tibber_prices import async_remove_entry # noqa: PLC0415 + + # Create mocks + hass = MagicMock() + config_entry = MagicMock() + config_entry.entry_id = "test_entry_123" + + # Mock Store + mock_store = AsyncMock() + mock_store.async_remove = AsyncMock() + + # Patch Store creation + from unittest.mock import patch # noqa: PLC0415 + + with patch( + "custom_components.tibber_prices.Store", + return_value=mock_store, + ): + # Call removal + await async_remove_entry(hass, config_entry) + + # Verify storage was removed + mock_store.async_remove.assert_called_once()