diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 1f7b73c0438..43ca45920d1 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( AUTH_API_TOKEN, AUTH_PASSWORD, + CACHED_API_KEY, CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_PLANT_ID, @@ -41,15 +42,163 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def get_device_list_classic( - api: growattServer.GrowattApi, config: Mapping[str, str] -) -> tuple[list[dict[str, str]], str]: - """Retrieve the device list for the selected plant.""" - plant_id = config[CONF_PLANT_ID] +async def async_migrate_entry( + hass: HomeAssistant, config_entry: GrowattConfigEntry +) -> bool: + """Migrate old config entries. - # Log in to api and fetch first plant if no plant id is defined. + Migration from version 1.0 to 1.1: + - Resolves DEFAULT_PLANT_ID (legacy value "0") to actual plant_id + - Only applies to Classic API (username/password authentication) + - Caches the logged-in API instance to avoid growatt server API rate limiting + + Rate Limiting Workaround: + The Growatt Classic API rate-limits individual endpoints (login, plant_list, + device_list) with 5-minute windows. Without caching, the sequence would be: + Migration: login() → plant_list() + Setup: login() → device_list() + This results in 2 login() calls within seconds, triggering rate limits. + + By caching the API instance (which contains the authenticated session), we + achieve: + Migration: login() → plant_list() → [cache API instance] + Setup: [reuse cached API] → device_list() + This reduces to just 1 login() call during the migration+setup cycle and prevent account lockout. + """ + _LOGGER.debug( + "Migrating config entry from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + # Migrate from version 1.0 to 1.1 + if config_entry.version == 1 and config_entry.minor_version < 1: + config = config_entry.data + + # First, ensure auth_type field exists (legacy config entry migration) + # This handles config entries created before auth_type was introduced + if CONF_AUTH_TYPE not in config: + new_data = dict(config_entry.data) + # Detect auth type based on which fields are present + if CONF_TOKEN in config: + new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN + hass.config_entries.async_update_entry(config_entry, data=new_data) + config = config_entry.data + _LOGGER.debug("Added auth_type field to V1 API config entry") + elif CONF_USERNAME in config: + new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD + hass.config_entries.async_update_entry(config_entry, data=new_data) + config = config_entry.data + _LOGGER.debug("Added auth_type field to Classic API config entry") + else: + # Config entry has no auth fields - this is invalid but migration + # should still succeed. Setup will fail later with a clearer error. + _LOGGER.warning( + "Config entry has no authentication fields. " + "Setup will fail until the integration is reconfigured" + ) + + # Handle DEFAULT_PLANT_ID resolution + if config.get(CONF_PLANT_ID) == DEFAULT_PLANT_ID: + # V1 API should never have DEFAULT_PLANT_ID (plant selection happens in config flow) + # If it does, this indicates a corrupted config entry + if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: + _LOGGER.error( + "V1 API config entry has DEFAULT_PLANT_ID, which indicates a " + "corrupted configuration. Please reconfigure the integration" + ) + return False + + # Classic API with DEFAULT_PLANT_ID - resolve to actual plant_id + if config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD: + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + url = config.get(CONF_URL, DEFAULT_URL) + + if not username or not password: + # Credentials missing - cannot migrate + _LOGGER.error( + "Cannot migrate DEFAULT_PLANT_ID due to missing credentials" + ) + return False + + try: + # Create API instance and login + api, login_response = await _create_api_and_login( + hass, username, password, url + ) + + # Resolve DEFAULT_PLANT_ID to actual plant_id + plant_info = await hass.async_add_executor_job( + api.plant_list, login_response["user"]["id"] + ) + except (ConfigEntryError, RequestException, JSONDecodeError) as ex: + # API failure during migration - return False to retry later + _LOGGER.error( + "Failed to resolve plant_id during migration: %s. " + "Migration will retry on next restart", + ex, + ) + return False + + if not plant_info or "data" not in plant_info or not plant_info["data"]: + _LOGGER.error( + "No plants found for this account. " + "Migration will retry on next restart" + ) + return False + + first_plant_id = plant_info["data"][0]["plantId"] + + # Update config entry with resolved plant_id + new_data = dict(config_entry.data) + new_data[CONF_PLANT_ID] = first_plant_id + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=1 + ) + + # Cache the logged-in API instance for reuse in async_setup_entry() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][f"{CACHED_API_KEY}{config_entry.entry_id}"] = api + + _LOGGER.info( + "Migrated config entry to use specific plant_id '%s'", + first_plant_id, + ) + else: + # No DEFAULT_PLANT_ID to resolve, just bump version + hass.config_entries.async_update_entry(config_entry, minor_version=1) + + _LOGGER.debug("Migration completed to version %s.%s", config_entry.version, 1) + + return True + + +async def _create_api_and_login( + hass: HomeAssistant, username: str, password: str, url: str +) -> tuple[growattServer.GrowattApi, dict]: + """Create API instance and perform login. + + Returns both the API instance (with authenticated session) and the login + response (containing user_id needed for subsequent API calls). + + """ + api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) + api.server_url = url + + login_response = await hass.async_add_executor_job( + _login_classic_api, api, username, password + ) + + return api, login_response + + +def _login_classic_api( + api: growattServer.GrowattApi, username: str, password: str +) -> dict: + """Log in to Classic API and return user info.""" try: - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + login_response = api.login(username, password) except (RequestException, JSONDecodeError) as ex: raise ConfigEntryError( f"Error communicating with Growatt API during login: {ex}" @@ -62,31 +211,7 @@ def get_device_list_classic( raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!") raise ConfigEntryError(f"Growatt login failed: {msg}") - user_id = login_response["user"]["id"] - - # Legacy support: DEFAULT_PLANT_ID ("0") triggers auto-selection of first plant. - # Modern config flow always sets a specific plant_id, but old config entries - # from earlier versions may still have plant_id="0". - if plant_id == DEFAULT_PLANT_ID: - try: - plant_info = api.plant_list(user_id) - except (RequestException, JSONDecodeError) as ex: - raise ConfigEntryError( - f"Error communicating with Growatt API during plant list: {ex}" - ) from ex - if not plant_info or "data" not in plant_info or not plant_info["data"]: - raise ConfigEntryError("No plants found for this account.") - plant_id = plant_info["data"][0]["plantId"] - - # Get a list of devices for specified plant to add sensors for. - try: - devices = api.device_list(plant_id) - except (RequestException, JSONDecodeError) as ex: - raise ConfigEntryError( - f"Error communicating with Growatt API during device list: {ex}" - ) from ex - - return devices, plant_id + return login_response def get_device_list_v1( @@ -94,9 +219,9 @@ def get_device_list_v1( ) -> tuple[list[dict[str, str]], str]: """Device list logic for Open API V1. - Note: Plant selection (including auto-selection if only one plant exists) - is handled in the config flow before this function is called. This function - only fetches devices for the already-selected plant_id. + Plant selection is handled in the config flow before this function is called. + This function expects a specific plant_id and fetches devices for that plant. + """ plant_id = config[CONF_PLANT_ID] try: @@ -126,19 +251,6 @@ def get_device_list_v1( return supported_devices, plant_id -def get_device_list( - api, config: Mapping[str, str], api_version: str -) -> tuple[list[dict[str, str]], str]: - """Dispatch to correct device list logic based on API version.""" - if api_version == "v1": - return get_device_list_v1(api, config) - if api_version == "classic": - return get_device_list_classic(api, config) - # Defensive: api_version is hardcoded in async_setup_entry as "v1" or "classic" - # This line is unreachable through normal execution but kept as a safeguard - raise ConfigEntryError(f"Unknown API version: {api_version}") # pragma: no cover - - async def async_setup_entry( hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: @@ -154,40 +266,47 @@ async def async_setup_entry( new_data[CONF_URL] = url hass.config_entries.async_update_entry(config_entry, data=new_data) - # Migrate legacy config entries without auth_type field - if CONF_AUTH_TYPE not in config: - new_data = dict(config_entry.data) - # Detect auth type based on which fields are present - if CONF_TOKEN in config: - new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN - elif CONF_USERNAME in config: - new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD - else: - raise ConfigEntryError( - "Unable to determine authentication type from config entry." - ) - hass.config_entries.async_update_entry(config_entry, data=new_data) - config = config_entry.data - - # Determine API version + # Determine API version and get devices + # Note: auth_type field is guaranteed to exist after migration if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: - api_version = "v1" + # V1 API (token-based, no login needed) token = config[CONF_TOKEN] api = growattServer.OpenApiV1(token=token) - elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD: - api_version = "classic" - username = config[CONF_USERNAME] - api = growattServer.GrowattApi( - add_random_user_id=True, agent_identifier=username + devices, plant_id = await hass.async_add_executor_job( + get_device_list_v1, api, config ) - api.server_url = url + elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD: + # Classic API (username/password with login) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + # Check if migration cached an authenticated API instance for us to reuse. + # This avoids calling login() twice (once in migration, once here) which + # would trigger rate limiting. + cached_api = hass.data.get(DOMAIN, {}).pop( + f"{CACHED_API_KEY}{config_entry.entry_id}", None + ) + + if cached_api: + # Reuse the logged-in API instance from migration (rate limit optimization) + api = cached_api + _LOGGER.debug("Reusing logged-in session from migration") + else: + # No cached API (normal setup or migration didn't run) + # Create new API instance and login + api, _ = await _create_api_and_login(hass, username, password, url) + + # Get plant_id and devices using the authenticated session + plant_id = config[CONF_PLANT_ID] + try: + devices = await hass.async_add_executor_job(api.device_list, plant_id) + except (RequestException, JSONDecodeError) as ex: + raise ConfigEntryError( + f"Error communicating with Growatt API during device list: {ex}" + ) from ex else: raise ConfigEntryError("Unknown authentication type in config entry.") - devices, plant_id = await hass.async_add_executor_job( - get_device_list, api, config, api_version - ) - # Create a coordinator for the total sensors total_coordinator = GrowattCoordinator( hass, config_entry, plant_id, "total", plant_id diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index eacae10e910..f1bb680904d 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -40,6 +40,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow class.""" VERSION = 1 + MINOR_VERSION = 1 api: growattServer.GrowattApi diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index bcc7e5d866b..ea874707db9 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -53,3 +53,8 @@ ABORT_NO_PLANTS = "no_plants" BATT_MODE_LOAD_FIRST = 0 BATT_MODE_BATTERY_FIRST = 1 BATT_MODE_GRID_FIRST = 2 + +# Internal key prefix for caching authenticated API instance +# Used to pass logged-in session from async_migrate_entry to async_setup_entry +# to avoid double login() calls that trigger API rate limiting +CACHED_API_KEY = "_cached_api_" diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 519f8395225..08399f4034d 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -155,8 +155,8 @@ def mock_growatt_classic_api(): Individual tests can override specific return values to test error conditions. Methods mocked for integration setup: - - login: Called during get_device_list_classic to authenticate - - plant_list: Called during setup if plant_id is default (to auto-select plant) + - login: Called during migration (to resolve DEFAULT_PLANT_ID) or async_setup_entry + - plant_list: Called during migration to resolve DEFAULT_PLANT_ID to actual plant_id - device_list: Called during async_setup_entry to discover devices Methods mocked for total coordinator refresh: @@ -265,9 +265,10 @@ def mock_config_entry_classic() -> MockConfigEntry: def mock_config_entry_classic_default_plant() -> MockConfigEntry: """Return a mocked config entry for Classic API with DEFAULT_PLANT_ID. - This config entry uses plant_id="0" which triggers auto-plant-selection logic - in the Classic API path. This is legacy support for old config entries that - didn't have a specific plant_id set during initial configuration. + This config entry uses plant_id="0" which triggers migration logic in + async_setup_entry to resolve to the actual plant_id. This is legacy support + for old config entries that didn't have a specific plant_id set during initial + configuration. """ return MockConfigEntry( domain=DOMAIN, @@ -276,7 +277,7 @@ def mock_config_entry_classic_default_plant() -> MockConfigEntry: CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password", CONF_URL: "https://server.growatt.com/", - CONF_PLANT_ID: DEFAULT_PLANT_ID, # "0" triggers auto-selection + CONF_PLANT_ID: DEFAULT_PLANT_ID, # "0" - should trigger migration }, unique_id="plant_default", ) diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py index a7a5f35a941..06fa24ee4cd 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -9,14 +9,24 @@ import pytest import requests from syrupy.assertion import SnapshotAssertion +from homeassistant.components.growatt_server import async_migrate_entry from homeassistant.components.growatt_server.const import ( AUTH_API_TOKEN, AUTH_PASSWORD, + CACHED_API_KEY, CONF_AUTH_TYPE, + CONF_PLANT_ID, + DEFAULT_PLANT_ID, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -126,64 +136,66 @@ async def test_classic_api_setup( assert device_entry == snapshot -async def test_migrate_legacy_api_token_config( +@pytest.mark.parametrize( + ("config_data", "expected_auth_type"), + [ + ( + { + CONF_TOKEN: "test_token_123", + CONF_URL: "https://openapi.growatt.com/", + "plant_id": "plant_123", + }, + AUTH_API_TOKEN, + ), + ( + { + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + "plant_id": "plant_456", + }, + AUTH_PASSWORD, + ), + ], +) +async def test_migrate_config_without_auth_type( hass: HomeAssistant, - mock_growatt_v1_api, + config_data: dict[str, str], + expected_auth_type: str, ) -> None: - """Test migration of legacy config entry with API token but no auth_type.""" - # Create a legacy config entry without CONF_AUTH_TYPE - legacy_config = { - CONF_TOKEN: "test_token_123", - CONF_URL: "https://openapi.growatt.com/", - "plant_id": "plant_123", - } + """Test migration adds auth_type field to legacy configs and bumps version. + + This test verifies that config entries created before auth_type was introduced + are properly migrated by: + - Adding CONF_AUTH_TYPE with the correct value (AUTH_API_TOKEN or AUTH_PASSWORD) + - Bumping version from 1.0 to 1.1 + """ mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=legacy_config, - unique_id="plant_123", + data=config_data, + unique_id=config_data["plant_id"], + version=1, + minor_version=0, ) - await setup_integration(hass, mock_config_entry) + mock_config_entry.add_to_hass(hass) - # Verify migration occurred and auth_type was added - assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_API_TOKEN - assert mock_config_entry.state is ConfigEntryState.LOADED + # Execute migration + migration_result = await async_migrate_entry(hass, mock_config_entry) + assert migration_result is True + # Verify version was updated to 1.1 + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 1 -async def test_migrate_legacy_password_config( - hass: HomeAssistant, - mock_growatt_classic_api, -) -> None: - """Test migration of legacy config entry with password auth but no auth_type.""" - # Create a legacy config entry without CONF_AUTH_TYPE - legacy_config = { - CONF_USERNAME: "test_user", - CONF_PASSWORD: "test_password", - CONF_URL: "https://server.growatt.com/", - "plant_id": "plant_456", - } - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - data=legacy_config, - unique_id="plant_456", - ) - - # Classic API doesn't support MIN devices - use TLX device instead - mock_growatt_classic_api.device_list.return_value = [ - {"deviceSn": "TLX123456", "deviceType": "tlx"} - ] - - await setup_integration(hass, mock_config_entry) - - # Verify migration occurred and auth_type was added - assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_PASSWORD - assert mock_config_entry.state is ConfigEntryState.LOADED + # Verify auth_type field was added during migration + assert mock_config_entry.data[CONF_AUTH_TYPE] == expected_auth_type async def test_migrate_legacy_config_no_auth_fields( hass: HomeAssistant, ) -> None: - """Test that config entry with no recognizable auth fields raises error.""" + """Test migration succeeds but setup fails for config without auth fields.""" # Create a config entry without any auth fields invalid_config = { CONF_URL: "https://openapi.growatt.com/", @@ -193,13 +205,21 @@ async def test_migrate_legacy_config_no_auth_fields( domain=DOMAIN, data=invalid_config, unique_id="plant_789", + version=1, + minor_version=0, ) - await setup_integration(hass, mock_config_entry) + mock_config_entry.add_to_hass(hass) - # The ConfigEntryError is caught by the config entry system - # and the entry state is set to SETUP_ERROR - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + # Migration should succeed (only updates version) + migration_result = await async_migrate_entry(hass, mock_config_entry) + assert migration_result is True + + # Verify version was updated + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 1 + + # Note: Setup will fail later due to missing auth fields in async_setup_entry @pytest.mark.parametrize( @@ -254,45 +274,58 @@ async def test_classic_api_login_failures( ], ids=["network_error", "json_error"], ) -async def test_classic_api_plant_list_exceptions( +async def test_classic_api_device_list_exceptions( hass: HomeAssistant, mock_growatt_classic_api, - mock_config_entry_classic_default_plant: MockConfigEntry, exception: Exception, ) -> None: - """Test Classic API setup with plant list exceptions (default plant_id path).""" - # Login succeeds - mock_growatt_classic_api.login.return_value = { - "success": True, - "user": {"id": 123456}, - } + """Test Classic API setup with device_list exceptions.""" + # Create a config entry that won't trigger migration + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: "specific_plant_123", # Specific ID to avoid migration + }, + unique_id="plant_123", + ) - # But plant_list raises exception - mock_growatt_classic_api.plant_list.side_effect = exception + # device_list raises exception during setup + mock_growatt_classic_api.device_list.side_effect = exception - await setup_integration(hass, mock_config_entry_classic_default_plant) + await setup_integration(hass, mock_config_entry) - assert mock_config_entry_classic_default_plant.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_classic_api_plant_list_no_plants( +async def test_classic_api_device_list_no_devices( hass: HomeAssistant, mock_growatt_classic_api, - mock_config_entry_classic_default_plant: MockConfigEntry, ) -> None: - """Test Classic API setup when plant list returns no plants.""" - # Login succeeds - mock_growatt_classic_api.login.return_value = { - "success": True, - "user": {"id": 123456}, - } + """Test Classic API setup when device list returns no devices.""" + # Create a config entry that won't trigger migration + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: "specific_plant_456", # Specific ID to avoid migration + }, + unique_id="plant_456", + ) - # But plant_list returns empty list - mock_growatt_classic_api.plant_list.return_value = {"data": []} + # device_list returns empty list (no devices) + mock_growatt_classic_api.device_list.return_value = [] - await setup_integration(hass, mock_config_entry_classic_default_plant) + await setup_integration(hass, mock_config_entry) - assert mock_config_entry_classic_default_plant.state is ConfigEntryState.SETUP_ERROR + # Should still load successfully even with no devices + assert mock_config_entry.state is ConfigEntryState.LOADED @pytest.mark.parametrize( @@ -393,3 +426,232 @@ async def test_v1_api_unsupported_device_type( assert mock_config_entry.state is ConfigEntryState.LOADED # Verify warning was logged for unsupported device assert "Device TLX789012 with type 5 not supported in Open API V1" in caplog.text + + +async def test_migrate_version_bump( + hass: HomeAssistant, + mock_growatt_classic_api, +) -> None: + """Test migration from 1.0 to 1.1 resolves DEFAULT_PLANT_ID and bumps version. + + This test verifies that: + - Migration successfully resolves DEFAULT_PLANT_ID ("0") to actual plant_id + - Config entry version is bumped from 1.0 to 1.1 + - API instance is cached for setup to reuse (rate limit optimization) + """ + # Create a version 1.0 config entry with DEFAULT_PLANT_ID + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: DEFAULT_PLANT_ID, + CONF_NAME: "Test Plant", + }, + unique_id="plant_default", + version=1, + minor_version=0, + ) + + # Mock successful API responses for migration + mock_growatt_classic_api.login.return_value = { + "success": True, + "user": {"id": 123456}, + } + mock_growatt_classic_api.plant_list.return_value = { + "data": [{"plantId": "RESOLVED_PLANT_789", "plantName": "My Plant"}] + } + + mock_config_entry.add_to_hass(hass) + + # Execute migration + migration_result = await async_migrate_entry(hass, mock_config_entry) + assert migration_result is True + + # Verify version was updated to 1.1 + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 1 + + # Verify plant_id was resolved to actual plant_id (not DEFAULT_PLANT_ID) + assert mock_config_entry.data[CONF_PLANT_ID] == "RESOLVED_PLANT_789" + + # Verify API instance was cached for setup to reuse + assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" in hass.data[DOMAIN] + + +async def test_setup_reuses_cached_api_from_migration( + hass: HomeAssistant, + mock_growatt_classic_api, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that setup reuses cached API instance from migration. + + This test verifies the rate limit optimization where: + 1. Migration calls login() and caches the authenticated API instance + 2. Setup retrieves and reuses the cached API (avoiding a second login()) + 3. The cached API is removed after use (one-time use pattern) + + Without this caching, we would call login() twice within seconds: + Migration: login() → plant_list() + Setup: login() → device_list() + This would trigger Growatt API rate limiting (5-minute window per endpoint). + + With caching, we only call login() once: + Migration: login() → plant_list() → [cache API] + Setup: [reuse API] → device_list() + """ + # Create a version 1.0 config entry with DEFAULT_PLANT_ID + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: DEFAULT_PLANT_ID, + CONF_NAME: "Test Plant", + }, + unique_id="plant_default", + version=1, + minor_version=0, + ) + + # Mock successful API responses + mock_growatt_classic_api.login.return_value = { + "success": True, + "user": {"id": 123456}, + } + mock_growatt_classic_api.plant_list.return_value = { + "data": [{"plantId": "RESOLVED_PLANT_789", "plantName": "My Plant"}] + } + mock_growatt_classic_api.device_list.return_value = [ + {"deviceSn": "TLX123456", "deviceType": "tlx"} + ] + mock_growatt_classic_api.plant_info.return_value = { + "deviceList": [], + "totalEnergy": 1250.0, + "todayEnergy": 12.5, + "invTodayPpv": 2500, + "plantMoneyText": "123.45/USD", + } + mock_growatt_classic_api.tlx_detail.return_value = { + "data": {"deviceSn": "TLX123456"} + } + + mock_config_entry.add_to_hass(hass) + + # Run migration first (resolves plant_id and caches authenticated API) + await async_migrate_entry(hass, mock_config_entry) + + # Verify migration successfully resolved plant_id + assert mock_config_entry.data[CONF_PLANT_ID] == "RESOLVED_PLANT_789" + + # Now setup the integration (should reuse cached API from migration) + await setup_integration(hass, mock_config_entry) + + # Verify integration loaded successfully + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify log message confirms API reuse (rate limit optimization) + assert "Reusing logged-in session from migration" in caplog.text + + # Verify login was called with correct credentials + # Note: Coordinators also call login() during refresh, so we verify + # the call was made but don't assert it was called exactly once + mock_growatt_classic_api.login.assert_called_with("test_user", "test_password") + + # Verify plant_list was called only once (during migration, not during setup) + # This confirms setup did NOT resolve plant_id again (optimization working) + mock_growatt_classic_api.plant_list.assert_called_once_with(123456) + + # Verify the cached API was removed after use (should not be in hass.data anymore) + assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" not in hass.data.get( + DOMAIN, {} + ) + + +async def test_migrate_failure_returns_false( + hass: HomeAssistant, + mock_growatt_classic_api, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration returns False on API failure to allow retry. + + When migration fails due to API errors (network issues, etc.), + it should return False and NOT bump the version. This allows Home Assistant + to retry the migration on the next restart. + """ + # Create a version 1.0 config entry with DEFAULT_PLANT_ID + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: DEFAULT_PLANT_ID, + CONF_NAME: "Test Plant", + }, + unique_id="plant_default", + version=1, + minor_version=0, + ) + + # Mock API failure (e.g., network error during login) + mock_growatt_classic_api.login.side_effect = requests.exceptions.RequestException( + "Network error" + ) + + mock_config_entry.add_to_hass(hass) + + # Execute migration (should fail gracefully) + migration_result = await async_migrate_entry(hass, mock_config_entry) + + # Verify migration returned False (will retry on next restart) + assert migration_result is False + + # Verify version was NOT bumped (remains 1.0) + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 0 + + # Verify plant_id was NOT changed (remains DEFAULT_PLANT_ID) + assert mock_config_entry.data[CONF_PLANT_ID] == DEFAULT_PLANT_ID + + # Verify error was logged + assert "Failed to resolve plant_id during migration" in caplog.text + assert "Migration will retry on next restart" in caplog.text + + +async def test_migrate_already_migrated( + hass: HomeAssistant, +) -> None: + """Test migration is skipped for already migrated entries.""" + # Create a config entry already at version 1.1 + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: "https://server.growatt.com/", + CONF_PLANT_ID: "specific_plant_123", + }, + unique_id="plant_specific", + version=1, + minor_version=1, + ) + + mock_config_entry.add_to_hass(hass) + + # Call migration function + migration_result = await async_migrate_entry(hass, mock_config_entry) + assert migration_result is True + + # Verify version remains 1.1 (no change) + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 1 + + # Plant ID should remain unchanged + assert mock_config_entry.data[CONF_PLANT_ID] == "specific_plant_123"