1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-28 05:46:00 +00:00
Files
core/tests/components/growatt_server/test_init.py

658 lines
21 KiB
Python

"""Tests for the Growatt Server integration."""
from datetime import timedelta
import json
from freezegun.api import FrozenDateTimeFactory
import growattServer
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_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("init_integration")
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading and unloading the integration."""
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("init_integration")
async def test_device_info(
snapshot: SnapshotAssertion,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device registry integration."""
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
assert device_entry is not None
assert device_entry == snapshot
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(growattServer.GrowattV1ApiError("API Error"), ConfigEntryState.SETUP_ERROR),
(
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
ConfigEntryState.SETUP_ERROR,
),
],
)
async def test_setup_error_on_api_failure(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup error on API failures during device list."""
mock_growatt_v1_api.device_list.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator handles update failures gracefully."""
# Integration should be loaded
assert mock_config_entry.state is ConfigEntryState.LOADED
# Cause coordinator update to fail
mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
)
# Trigger coordinator refresh
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Integration should remain loaded despite coordinator error
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_classic_api_setup(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test integration setup with Classic API (password auth)."""
# 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_classic)
assert mock_config_entry_classic.state is ConfigEntryState.LOADED
# Verify Classic API login was called
mock_growatt_classic_api.login.assert_called()
# Verify device was created
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")})
assert device_entry is not None
assert device_entry == snapshot
@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,
config_data: dict[str, str],
expected_auth_type: str,
) -> None:
"""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=config_data,
unique_id=config_data["plant_id"],
version=1,
minor_version=0,
)
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 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 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/",
"plant_id": "plant_789",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=invalid_config,
unique_id="plant_789",
version=1,
minor_version=0,
)
mock_config_entry.add_to_hass(hass)
# 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(
"exception",
[
requests.exceptions.RequestException("Connection error"),
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
],
ids=["network_error", "json_error"],
)
async def test_classic_api_login_exceptions(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
exception: Exception,
) -> None:
"""Test Classic API setup with login exceptions."""
mock_growatt_classic_api.login.side_effect = exception
await setup_integration(hass, mock_config_entry_classic)
assert mock_config_entry_classic.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize(
"login_response",
[
{"success": False, "msg": "502"},
{"success": False, "msg": "Server maintenance"},
],
ids=["invalid_auth", "other_login_error"],
)
async def test_classic_api_login_failures(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
login_response: dict,
) -> None:
"""Test Classic API setup with login failures."""
mock_growatt_classic_api.login.return_value = login_response
await setup_integration(hass, mock_config_entry_classic)
assert mock_config_entry_classic.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize(
"exception",
[
requests.exceptions.RequestException("Connection error"),
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
],
ids=["network_error", "json_error"],
)
async def test_classic_api_device_list_exceptions(
hass: HomeAssistant,
mock_growatt_classic_api,
exception: Exception,
) -> None:
"""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",
)
# device_list raises exception during setup
mock_growatt_classic_api.device_list.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_classic_api_device_list_no_devices(
hass: HomeAssistant,
mock_growatt_classic_api,
) -> None:
"""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",
)
# device_list returns empty list (no devices)
mock_growatt_classic_api.device_list.return_value = []
await setup_integration(hass, mock_config_entry)
# Should still load successfully even with no devices
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
"exception",
[
requests.exceptions.RequestException("Connection error"),
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
],
ids=["network_error", "json_error"],
)
async def test_classic_api_device_list_errors(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
exception: Exception,
) -> None:
"""Test Classic API setup with device list errors."""
mock_growatt_classic_api.device_list.side_effect = exception
await setup_integration(hass, mock_config_entry_classic)
assert mock_config_entry_classic.state is ConfigEntryState.SETUP_ERROR
async def test_unknown_api_version(
hass: HomeAssistant,
) -> None:
"""Test setup with unknown API version."""
# Create a config entry with invalid auth type
config = {
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_123",
CONF_AUTH_TYPE: "unknown_auth", # Invalid auth type
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=config,
unique_id="plant_123",
)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_classic_api_auto_select_plant(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic_default_plant: MockConfigEntry,
) -> None:
"""Test Classic API setup with default plant ID (auto-selects first plant)."""
# Login succeeds and plant_list returns a plant
mock_growatt_classic_api.login.return_value = {
"success": True,
"user": {"id": 123456},
}
mock_growatt_classic_api.plant_list.return_value = {
"data": [{"plantId": "AUTO_PLANT_123", "plantName": "Auto Plant"}]
}
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX999999", "deviceType": "tlx"}
]
await setup_integration(hass, mock_config_entry_classic_default_plant)
# Should be loaded successfully with auto-selected plant
assert mock_config_entry_classic_default_plant.state is ConfigEntryState.LOADED
async def test_v1_api_unsupported_device_type(
hass: HomeAssistant,
mock_growatt_v1_api,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test V1 API logs warning for unsupported device types (non-MIN)."""
config = {
CONF_TOKEN: "test_token_123",
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_123",
CONF_AUTH_TYPE: AUTH_API_TOKEN,
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=config,
unique_id="plant_123",
)
# Return mix of MIN (type 7) and other device types
mock_growatt_v1_api.device_list.return_value = {
"devices": [
{"device_sn": "MIN123456", "type": 7}, # Supported
{"device_sn": "TLX789012", "type": 5}, # Unsupported
]
}
await setup_integration(hass, mock_config_entry)
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"