From 99e4c87f5e9af55521191e04751bfe81065daad5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 10 Apr 2026 10:55:33 -0700 Subject: [PATCH] Add reauthentication and reconfiguration flows in Google Weather to reach platinum (#166106) --- .../components/google_weather/config_flow.py | 122 ++++++-- .../components/google_weather/coordinator.py | 10 + .../components/google_weather/manifest.json | 2 +- .../google_weather/quality_scale.yaml | 4 +- .../components/google_weather/strings.json | 11 +- .../google_weather/test_config_flow.py | 290 +++++++++++++++++- tests/components/google_weather/test_init.py | 21 +- .../components/google_weather/test_sensor.py | 30 +- 8 files changed, 464 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/google_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py index 661146ab01d..b03890e8a52 100644 --- a/homeassistant/components/google_weather/config_flow.py +++ b/homeassistant/components/google_weather/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,6 +10,9 @@ from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -81,11 +85,16 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: def _is_location_already_configured( - hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4 + hass: HomeAssistant, + new_data: dict[str, float], + epsilon: float = 1e-4, + exclude_subentry_id: str | None = None, ) -> bool: """Check if the location is already configured.""" for entry in hass.config_entries.async_entries(DOMAIN): for subentry in entry.subentries.values(): + if exclude_subentry_id and subentry.subentry_id == exclude_subentry_id: + continue # A more accurate way is to use the haversine formula, but for simplicity # we use a simple distance check. The epsilon value is small anyway. # This is mostly to capture cases where the user has slightly moved the location pin. @@ -106,7 +115,7 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle a flow initialized by the user, reauth or reconfigure.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = { "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", @@ -116,21 +125,45 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) self._async_abort_entries_match({CONF_API_KEY: api_key}) - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): - return self.async_abort(reason="already_configured") + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + subentry = next(iter(entry.subentries.values()), None) + if subentry: + latitude = subentry.data[CONF_LATITUDE] + longitude = subentry.data[CONF_LONGITUDE] + else: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + validation_input = { + CONF_LOCATION: {CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude} + } + else: + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION] + ): + return self.async_abort(reason="already_configured") + validation_input = user_input + api = GoogleWeatherApi( session=async_get_clientsession(self.hass), api_key=api_key, referrer=referrer, language_code=self.hass.config.language, ) - if await _validate_input(user_input, api, errors, description_placeholders): + if await _validate_input( + validation_input, api, errors, description_placeholders + ): + data = {CONF_API_KEY: api_key, CONF_REFERRER: referrer} + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + return self.async_update_reload_and_abort(entry, data=data) + return self.async_create_entry( title="Google Weather", - data={ - CONF_API_KEY: api_key, - CONF_REFERRER: referrer, - }, + data=data, subentries=[ { "subentry_type": "location", @@ -140,19 +173,47 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): }, ], ) + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + if user_input is None: + user_input = { + CONF_API_KEY: entry.data.get(CONF_API_KEY), + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: entry.data.get(CONF_REFERRER) + }, + } + schema = STEP_USER_DATA_SCHEMA else: - user_input = {} - schema = STEP_USER_DATA_SCHEMA.schema.copy() - schema.update(_get_location_schema(self.hass).schema) + if user_input is None: + user_input = {} + schema_dict = STEP_USER_DATA_SCHEMA.schema.copy() + schema_dict.update(_get_location_schema(self.hass).schema) + schema = vol.Schema(schema_dict) + return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema(schema), user_input - ), + data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, description_placeholders=description_placeholders, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + return await self.async_step_user(user_input) + @classmethod @callback def async_get_supported_subentry_types( @@ -165,6 +226,11 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): class LocationSubentryFlowHandler(ConfigSubentryFlow): """Handle a subentry flow for location.""" + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + async def async_step_location( self, user_input: dict[str, Any] | None = None, @@ -176,16 +242,35 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} if user_input is not None: - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + exclude_id = ( + None if self._is_new else self._get_reconfigure_subentry().subentry_id + ) + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION], exclude_subentry_id=exclude_id + ): return self.async_abort(reason="already_configured") api: GoogleWeatherApi = self._get_entry().runtime_data.api if await _validate_input(user_input, api, errors, description_placeholders): - return self.async_create_entry( + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input[CONF_LOCATION], + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), title=user_input[CONF_NAME], data=user_input[CONF_LOCATION], ) - else: + elif self._is_new: user_input = {} + else: + subentry = self._get_reconfigure_subentry() + user_input = { + CONF_NAME: subentry.title, + CONF_LOCATION: dict(subentry.data), + } + return self.async_show_form( step_id="location", data_schema=self.add_suggested_values_to_schema( @@ -196,3 +281,4 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): ) async_step_user = async_step_location + async_step_reconfigure = async_step_location diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py index 695dc5ea191..3c66186394e 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -12,6 +12,7 @@ from google_weather_api import ( CurrentConditionsResponse, DailyForecastResponse, GoogleWeatherApi, + GoogleWeatherApiAuthError, GoogleWeatherApiError, HourlyForecastResponse, ) @@ -19,6 +20,7 @@ from google_weather_api import ( from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( TimestampDataUpdateCoordinator, UpdateFailed, @@ -92,6 +94,14 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]): self.subentry.data[CONF_LATITUDE], self.subentry.data[CONF_LONGITUDE], ) + except GoogleWeatherApiAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "error": str(err), + }, + ) from err except GoogleWeatherApiError as err: _LOGGER.error( "Error fetching %s for %s: %s", diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json index 4f22a57d875..e7ec2e05563 100644 --- a/homeassistant/components/google_weather/manifest.json +++ b/homeassistant/components/google_weather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google_weather_api"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["python-google-weather-api==0.0.6"] } diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml index ec5e4edbb41..4ae4a8358a3 100644 --- a/homeassistant/components/google_weather/quality_scale.yaml +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repairs. diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json index 977adb306fc..7b8ab5b060c 100644 --- a/homeassistant/components/google_weather/strings.json +++ b/homeassistant/components/google_weather/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}", @@ -38,7 +40,8 @@ "location": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Location", "error": { @@ -46,6 +49,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "initiate_flow": { + "reconfigure": "Reconfigure location", "user": "Add location" }, "step": { @@ -100,6 +104,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed: {error}" + }, "update_error": { "message": "Error fetching weather data: {error}" } diff --git a/tests/components/google_weather/test_config_flow.py b/tests/components/google_weather/test_config_flow.py index 719c545beb5..b32a77b52ff 100644 --- a/tests/components/google_weather/test_config_flow.py +++ b/tests/components/google_weather/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from homeassistant import config_entries @@ -359,6 +359,294 @@ async def test_subentry_flow_location_already_configured( assert len(entry.subentries) == 1 +async def test_reauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) is None + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error", "expected_placeholders"), + [ + ( + GoogleWeatherApiAuthError("Invalid API key"), + "cannot_connect", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "error_message": "Invalid API key", + "name": "Google Weather", + }, + ), + ( + ValueError(), + "unknown", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "name": "Google Weather", + }, + ), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_exception: Exception, + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test reauth flow with exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "invalid-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + + mock_google_weather_api.async_get_current_conditions.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "valid-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_same_api_key_different_referrer( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reauth flow with same API key but different referrer.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "new-referrer", + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "test-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "new-referrer", + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error", "expected_placeholders"), + [ + ( + GoogleWeatherApiAuthError("Invalid API key"), + "cannot_connect", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "error_message": "Invalid API key", + }, + ), + ( + ValueError(), + "unknown", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + }, + ), + ], +) +async def test_reconfigure_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_exception: Exception, + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test reconfigure flow with exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "invalid-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + + mock_google_weather_api.async_get_current_conditions.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "valid-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_no_subentries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfigure flow when there are no subentries.""" + mock_config_entry = MockConfigEntry( + title="Google Weather", + domain=DOMAIN, + data={ + CONF_API_KEY: "test-api-key", + }, + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_subentry_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfiguring a location subentry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + subentry = next(iter(mock_config_entry.subentries.values())) + result = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "New Work", + CONF_LOCATION: { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + }, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # Reload the entry to see changes + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + updated_subentry = entry.subentries[subentry.subentry_id] + + assert updated_subentry.title == "New Work" + assert updated_subentry.data == { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + } + + async def test_subentry_flow_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/google_weather/test_init.py b/tests/components/google_weather/test_init.py index aa3b6629e94..6bc1d3ccc89 100644 --- a/tests/components/google_weather/test_init.py +++ b/tests/components/google_weather/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from homeassistant.components.google_weather.const import DOMAIN @@ -56,6 +56,25 @@ async def test_config_not_ready( assert "Error fetching weather data: API error" in caplog.text +async def test_setup_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test auth failed during setup.""" + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiAuthError() + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any( + flow["step_id"] == "user" + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + ) + + async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/google_weather/test_sensor.py b/tests/components/google_weather/test_sensor.py index 73d170e71fd..aa5b69fc98f 100644 --- a/tests/components/google_weather/test_sensor.py +++ b/tests/components/google_weather/test_sensor.py @@ -4,14 +4,16 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_weather.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -170,3 +172,29 @@ async def test_state_update( state = hass.states.get(entity_id) assert state assert state.state == "15.0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_auth_failure_during_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure that we start a reauth flow when auth fails during an update.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiAuthError() + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any( + flow["step_id"] == "user" + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + )