diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py index 6173d4c53ea..6f498fe637c 100644 --- a/homeassistant/components/nederlandse_spoorwegen/config_flow.py +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -49,6 +49,44 @@ class NSConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_api_key(self, api_key: str) -> dict[str, str]: + """Validate the API key by testing connection to NS API. + + Returns a dict of errors, empty if validation successful. + """ + errors: dict[str, str] = {} + client = NSAPI(api_key) + try: + await self.hass.async_add_executor_job(client.get_stations) + except HTTPError: + errors["base"] = "invalid_auth" + except (RequestsConnectionError, Timeout): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception validating API key") + errors["base"] = "unknown" + return errors + + def _is_api_key_already_configured( + self, api_key: str, exclude_entry_id: str | None = None + ) -> dict[str, str]: + """Check if the API key is already configured in another entry. + + Args: + api_key: The API key to check. + exclude_entry_id: Optional entry ID to exclude from the check. + + Returns: + A dict of errors, empty if not already configured. + """ + for entry in self._async_current_entries(): + if ( + entry.entry_id != exclude_entry_id + and entry.data.get(CONF_API_KEY) == api_key + ): + return {"base": "already_configured"} + return {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -56,16 +94,7 @@ class NSConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: self._async_abort_entries_match(user_input) - client = NSAPI(user_input[CONF_API_KEY]) - try: - await self.hass.async_add_executor_job(client.get_stations) - except HTTPError: - errors["base"] = "invalid_auth" - except (RequestsConnectionError, Timeout): - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception validating API key") - errors["base"] = "unknown" + errors = await self._validate_api_key(user_input[CONF_API_KEY]) if not errors: return self.async_create_entry( title=INTEGRATION_TITLE, @@ -77,6 +106,33 @@ class NSConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration to update the API key from the UI.""" + errors: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + # Check if this API key is already used by another entry + errors = self._is_api_key_already_configured( + user_input[CONF_API_KEY], exclude_entry_id=reconfigure_entry.entry_id + ) + + if not errors: + errors = await self._validate_api_key(user_input[CONF_API_KEY]) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Handle import from YAML configuration.""" self._async_abort_entries_match({CONF_API_KEY: import_data[CONF_API_KEY]}) diff --git a/homeassistant/components/nederlandse_spoorwegen/strings.json b/homeassistant/components/nederlandse_spoorwegen/strings.json index 1f06c537a7f..ac2334158f6 100644 --- a/homeassistant/components/nederlandse_spoorwegen/strings.json +++ b/homeassistant/components/nederlandse_spoorwegen/strings.json @@ -1,14 +1,25 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "already_configured": "This API key is already configured for another entry.", "cannot_connect": "Could not connect to NS API. Check your API key.", "invalid_auth": "[%key:common::config_flow::error::invalid_api_key%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nederlandse_spoorwegen::config::step::user::data_description::api_key%]" + }, + "description": "Update your Nederlandse Spoorwegen API key." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" diff --git a/tests/components/nederlandse_spoorwegen/test_config_flow.py b/tests/components/nederlandse_spoorwegen/test_config_flow.py index 264a057a0c5..1c6bd9b8582 100644 --- a/tests/components/nederlandse_spoorwegen/test_config_flow.py +++ b/tests/components/nederlandse_spoorwegen/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.nederlandse_spoorwegen.const import ( CONF_VIA, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -331,3 +331,128 @@ async def test_import_flow_exceptions( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_error + + +async def test_reconfigure_success( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test successfully reconfiguring (updating) the API key.""" + new_key = "new_api_key_123456" + + mock_config_entry.add_to_hass(hass) + + # Start reconfigure flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Submit new API key, mock_nsapi.get_stations returns OK by default + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: new_key} + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # Entry should be updated with the new API key + assert mock_config_entry.data[CONF_API_KEY] == new_key + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (HTTPError("Invalid API key"), "invalid_auth"), + (Timeout("Cannot connect"), "cannot_connect"), + (RequestsConnectionError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow error handling (invalid auth and cannot connect).""" + mock_config_entry.add_to_hass(hass) + + # First present the form + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Make get_stations raise the requested exception + mock_nsapi.get_stations.side_effect = exception + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "bad_key"} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} + + # Clear side effect and submit valid API key to complete the flow + mock_nsapi.get_stations.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_valid_key"} + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_valid_key" + + +async def test_reconfigure_already_configured( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfiguring with an API key that's already used by another entry.""" + # Add first entry + mock_config_entry.add_to_hass(hass) + + # Create and add second entry with different API key + second_entry = MockConfigEntry( + domain=DOMAIN, + title="NS Integration 2", + data={CONF_API_KEY: "another_api_key_456"}, + unique_id="second_entry", + ) + second_entry.add_to_hass(hass) + + # Start reconfigure flow for the first entry + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Try to reconfigure to use the API key from the second entry + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "another_api_key_456"} + ) + + # Should show error that it's already configured + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"} + + # Verify the original entry was not changed + assert mock_config_entry.data[CONF_API_KEY] == API_KEY + + # Now submit a valid unique API key to complete the flow + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_unique_key_789"} + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_unique_key_789"