diff --git a/homeassistant/components/freshr/config_flow.py b/homeassistant/components/freshr/config_flow.py index e3d366ff03d..90c5dd21420 100644 --- a/homeassistant/components/freshr/config_flow.py +++ b/homeassistant/components/freshr/config_flow.py @@ -30,22 +30,31 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, username: str, password: str) -> str | None: + """Validate credentials, returning an error key or None on success.""" + client = FreshrClient(session=async_get_clientsession(self.hass)) + try: + await client.login(username, password) + except LoginError: + return "invalid_auth" + except ClientError: + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) self._abort_if_unique_id_configured() @@ -58,6 +67,34 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + error = await self._validate_input( + reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={ + CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] + }, + errors=errors, + ) + async def async_step_reauth( self, _user_input: Mapping[str, Any] ) -> ConfigFlowResult: @@ -72,18 +109,11 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login( - reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: return self.async_update_reload_and_abort( reauth_entry, diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index ffc87c678a8..1d3ae24ae3e 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. diff --git a/homeassistant/components/freshr/strings.json b/homeassistant/components/freshr/strings.json index ee833d999c9..f7627054914 100644 --- a/homeassistant/components/freshr/strings.json +++ b/homeassistant/components/freshr/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,15 @@ }, "description": "Re-enter the password for your Fresh-r account `{username}`." }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::freshr::config::step::user::data_description::password%]" + }, + "description": "Update the password for your Fresh-r account `{username}`." + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/freshr/test_config_flow.py b/tests/components/freshr/test_config_flow.py index 16841d4f353..0a12f560ac3 100644 --- a/tests/components/freshr/test_config_flow.py +++ b/tests/components/freshr/test_config_flow.py @@ -159,6 +159,68 @@ async def test_reauth_error( assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" +@pytest.mark.usefixtures("mock_freshr_client") +async def test_reconfigure_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfiguration updates the password and reloads.""" + 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"] == "reconfigure" + assert result["description_placeholders"] == {CONF_USERNAME: "test-user"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new-pass"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" + assert mock_config_entry.data[CONF_USERNAME] == "test-user" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (LoginError("bad credentials"), "invalid_auth"), + (RuntimeError("unexpected"), "unknown"), + (ClientError("network"), "cannot_connect"), + ], +) +async def test_reconfigure_error( + hass: HomeAssistant, + mock_freshr_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfiguration handles errors and recovers correctly.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_freshr_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "wrong-pass"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_freshr_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new-pass"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" + assert mock_config_entry.data[CONF_USERNAME] == "test-user" + + @pytest.mark.usefixtures("mock_freshr_client") async def test_form_already_configured_case_insensitive( hass: HomeAssistant,