diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index d882424ef01..3faefe7590f 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="timeout", ) - del self.login_task + self.login_task = None return await self.async_step_user() async def async_step_reauth( diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 6fe0f14bb24..724ff101cb9 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -12,6 +12,6 @@ "documentation": "https://www.home-assistant.io/integrations/actron_air", "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["actron-neo-api==0.4.1"] } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index cb608240459..240b3e4b185 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -37,7 +37,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/actron_air/test_climate.py b/tests/components/actron_air/test_climate.py index da471a3154a..61262dcc850 100644 --- a/tests/components/actron_air/test_climate.py +++ b/tests/components/actron_air/test_climate.py @@ -258,3 +258,61 @@ async def test_zone_set_hvac_mode_api_error( {ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) + + +async def test_system_hvac_mode_unmapped( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test system climate entity returns None for unmapped HVAC mode.""" + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.is_on = True + status.user_aircon_settings.mode = "UNKNOWN_MODE" + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.test_system") + assert state.state == "unknown" + + +async def test_zone_hvac_mode_unmapped( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + mock_zone: MagicMock, +) -> None: + """Test zone climate entity returns None for unmapped HVAC mode.""" + mock_zone.is_active = True + mock_zone.hvac_mode = "UNKNOWN_MODE" + + status = mock_actron_api.state_manager.get_status.return_value + status.remote_zone_info = [mock_zone] + status.zones = {1: mock_zone} + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room") + assert state.state == "unknown" + + +async def test_zone_hvac_mode_inactive( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + mock_zone: MagicMock, +) -> None: + """Test zone climate entity returns OFF when zone is inactive.""" + mock_zone.is_active = False + + status = mock_actron_api.state_manager.get_status.return_value + status.remote_zone_info = [mock_zone] + status.zones = {1: mock_zone} + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room") + assert state.state == "off" diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py index 113af461c89..6315304f1f4 100644 --- a/tests/components/actron_air/test_config_flow.py +++ b/tests/components/actron_air/test_config_flow.py @@ -252,3 +252,84 @@ async def test_reauth_flow_wrong_account( # Should abort because of wrong account assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +async def test_user_flow_timeout( + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test OAuth2 flow when login task raises a non-CannotConnect exception.""" + + # Override the default mock to raise a generic exception (not CannotConnect) + async def raise_generic_error(device_code): + raise RuntimeError("Unexpected error") + + mock_actron_api.poll_for_token = raise_generic_error + + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Task raises a non-CannotConnect exception, so it goes to timeout + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" + + # Continue to the timeout step + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should show the timeout form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + # Now fix the mock to allow successful token polling for recovery + async def successful_poll_for_token(device_code): + await asyncio.sleep(0.1) + return { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + } + + mock_actron_api.poll_for_token = successful_poll_for_token + + # User clicks retry button + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Should start progress again + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should create entry on successful recovery + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + + +async def test_finish_login_auth_error( + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test finish_login step when get_user_info raises ActronAirAuthError.""" + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Now make get_user_info fail with auth error + mock_actron_api.get_user_info = AsyncMock( + side_effect=ActronAirAuthError("Auth error getting user info") + ) + + # Continue the flow after progress is done + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should abort with oauth2_error + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth2_error" diff --git a/tests/components/actron_air/test_coordinator.py b/tests/components/actron_air/test_coordinator.py new file mode 100644 index 00000000000..f1af0c1c476 --- /dev/null +++ b/tests/components/actron_air/test_coordinator.py @@ -0,0 +1,58 @@ +"""Tests for the Actron Air coordinator.""" + +from unittest.mock import AsyncMock, patch + +from actron_neo_api import ActronAirAPIError, ActronAirAuthError +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.actron_air.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_update_auth_error( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handles auth error during update.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_actron_api.update_status.side_effect = ActronAirAuthError("Auth expired") + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # ConfigEntryAuthFailed triggers a reauth flow + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_coordinator_update_api_error( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handles API error during update.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + mock_actron_api.update_status.side_effect = ActronAirAPIError("API error") + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # UpdateFailed sets last_update_success to False on the coordinator + coordinator = list(mock_config_entry.runtime_data.system_coordinators.values())[0] + assert coordinator.last_update_success is False diff --git a/tests/components/actron_air/test_init.py b/tests/components/actron_air/test_init.py new file mode 100644 index 00000000000..5a13dd61853 --- /dev/null +++ b/tests/components/actron_air/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the Actron Air integration setup.""" + +from unittest.mock import AsyncMock + +from actron_neo_api import ActronAirAPIError, ActronAirAuthError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry_auth_error( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup entry raises ConfigEntryAuthFailed on auth error.""" + mock_actron_api.get_ac_systems.side_effect = ActronAirAuthError("Auth failed") + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_api_error( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup entry raises ConfigEntryNotReady on API error.""" + mock_actron_api.get_ac_systems.side_effect = ActronAirAPIError("API failed") + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY