From ead726313c23b63cbd9bfe4bfaf987405f96b78a Mon Sep 17 00:00:00 2001 From: Anthony Date: Mon, 5 Oct 2026 15:17:25 +0100 Subject: [PATCH] Add ScorpionTrack share reconfiguration (#182089) --- .../components/scorpiontrack/config_flow.py | 33 ++- .../scorpiontrack/quality_scale.yaml | 2 +- .../components/scorpiontrack/strings.json | 13 +- tests/components/scorpiontrack/conftest.py | 27 ++- .../scorpiontrack/test_config_flow.py | 10 +- .../scorpiontrack/test_reconfigure.py | 194 ++++++++++++++++++ 6 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 tests/components/scorpiontrack/test_reconfigure.py diff --git a/homeassistant/components/scorpiontrack/config_flow.py b/homeassistant/components/scorpiontrack/config_flow.py index 6c06cef33bfd..8e0ba7100304 100644 --- a/homeassistant/components/scorpiontrack/config_flow.py +++ b/homeassistant/components/scorpiontrack/config_flow.py @@ -12,7 +12,11 @@ from pyscorpiontrack import ( ScorpionTrackShareUnavailableError, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -55,6 +59,12 @@ class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Update the link for the configured share.""" + return await self.async_step_user(user_input) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -78,6 +88,12 @@ class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: await self.async_set_unique_id(str(share.id)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_share") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_SHARE_TOKEN: share.token}, + ) self._abort_if_unique_id_configured() user_input[CONF_SHARE_TOKEN] = share.token return self.async_create_entry( @@ -85,10 +101,19 @@ class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) + suggested_values = ( + self._get_reconfigure_entry().data + if user_input is None and self.source == SOURCE_RECONFIGURE + else user_input + ) + return self.async_show_form( - step_id="user", - data_schema=probatio.Schema( - {probatio.Required(probatio.Secret(CONF_SHARE_TOKEN)): str} + step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user", + data_schema=self.add_suggested_values_to_schema( + probatio.Schema( + {probatio.Required(probatio.Secret(CONF_SHARE_TOKEN)): str} + ), + suggested_values, ), errors=errors, ) diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml index eccb037584e9..79e0a5a988ca 100644 --- a/homeassistant/components/scorpiontrack/quality_scale.yaml +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -81,7 +81,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: done diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json index 4d012eec9ce6..fd47c9ae20d9 100644 --- a/homeassistant/components/scorpiontrack/strings.json +++ b/homeassistant/components/scorpiontrack/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "This shared location is already configured." + "already_configured": "This shared location is already configured.", + "wrong_share": "This link belongs to a different shared location. Add it as a new integration entry instead." }, "error": { "cannot_connect": "The ScorpionTrack share could not be reached right now.", @@ -10,6 +11,16 @@ "unknown": "An unexpected error occurred." }, "step": { + "reconfigure": { + "data": { + "share_token": "[%key:component::scorpiontrack::config::step::user::data::share_token%]" + }, + "data_description": { + "share_token": "[%key:component::scorpiontrack::config::step::user::data_description::share_token%]" + }, + "description": "Enter a link for the same shared location. A different share must be added as a new integration entry.", + "title": "Update a ScorpionTrack share" + }, "user": { "data": { "share_token": "Share URL or token" diff --git a/tests/components/scorpiontrack/conftest.py b/tests/components/scorpiontrack/conftest.py index efc871fafb79..d9eab066b34d 100644 --- a/tests/components/scorpiontrack/conftest.py +++ b/tests/components/scorpiontrack/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Iterator from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pyscorpiontrack import ( ScorpionTrackClient, @@ -64,11 +64,9 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture(autouse=True) -def mock_scorpiontrack_client( - mock_share: ScorpionTrackShare, -) -> Iterator[AsyncMock]: - """Mock the ScorpionTrack client.""" +@pytest.fixture +def mock_scorpiontrack_client_class() -> Iterator[MagicMock]: + """Mock the ScorpionTrack client constructor.""" with ( patch( "homeassistant.components.scorpiontrack.ScorpionTrackClient", @@ -79,8 +77,17 @@ def mock_scorpiontrack_client( new=mock_client, ), ): - client = mock_client.return_value mock_client.extract_token.side_effect = ScorpionTrackClient.extract_token - client.token = "canonical-token" - client.async_get_share.return_value = mock_share - yield client + yield mock_client + + +@pytest.fixture(autouse=True) +def mock_scorpiontrack_client( + mock_scorpiontrack_client_class: MagicMock, + mock_share: ScorpionTrackShare, +) -> AsyncMock: + """Return the mocked ScorpionTrack client.""" + client = mock_scorpiontrack_client_class.return_value + client.token = "canonical-token" + client.async_get_share.return_value = mock_share + return client diff --git a/tests/components/scorpiontrack/test_config_flow.py b/tests/components/scorpiontrack/test_config_flow.py index e970093b7a8c..57ab8b5fd749 100644 --- a/tests/components/scorpiontrack/test_config_flow.py +++ b/tests/components/scorpiontrack/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value async def _async_start_user_flow(hass: HomeAssistant) -> FlowResult: @@ -33,6 +33,10 @@ async def _async_start_user_flow(hass: HomeAssistant) -> FlowResult: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_SHARE_TOKEN) + is None + ) return result @@ -150,6 +154,10 @@ async def test_user_flow_shows_validation_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": expected_error} + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_SHARE_TOKEN) + == "canonical-token" + ) async def test_user_flow_maps_malformed_input_to_invalid_token( diff --git a/tests/components/scorpiontrack/test_reconfigure.py b/tests/components/scorpiontrack/test_reconfigure.py new file mode 100644 index 000000000000..095fd27cdad2 --- /dev/null +++ b/tests/components/scorpiontrack/test_reconfigure.py @@ -0,0 +1,194 @@ +"""Test reconfiguring a ScorpionTrack share.""" + +from dataclasses import replace +from unittest.mock import ANY, AsyncMock, MagicMock + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.components.scorpiontrack.const import CONF_SHARE_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_RECONFIGURE, ConfigEntryState +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfSpeed +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, get_schema_suggested_value + + +@pytest.mark.parametrize( + ("share_input", "token"), + [ + pytest.param("updated-token", "updated-token", id="changed-token"), + pytest.param("canonical-token", "canonical-token", id="unchanged-token"), + pytest.param( + "https://app.scorpiontrack.com/shared/location?token=canonical-token", + "canonical-token", + id="share-url", + ), + ], +) +async def test_reconfigure_same_share( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + mock_scorpiontrack_client_class: MagicMock, + entity_registry: er.EntityRegistry, + share_input: str, + token: str, +) -> None: + """Update the token and reload without replacing the share or its entities.""" + await setup_integration(hass, mock_config_entry) + original_entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, token=token, title="New share title" + ) + + 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" + + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_SHARE_TOKEN) + == "canonical-token" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SHARE_TOKEN: share_input} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SHARE_TOKEN: token} + assert mock_config_entry.unique_id == "101" + assert mock_config_entry.title == "Family Cars" + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == original_entities + ) + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_scorpiontrack_client_class.assert_called_with(session=ANY, token=token) + assert mock_scorpiontrack_client.async_get_share.await_count == 3 + speed = hass.states.get("sensor.ab12_cde_speed") + assert speed is not None + assert float(speed.state) == pytest.approx(48.3 * 0.621371192237334) + assert speed.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfSpeed.MILES_PER_HOUR + + +async def test_reconfigure_rejects_different_share( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Keep the original configuration when the link identifies another share.""" + mock_config_entry.add_to_hass(hass) + mock_scorpiontrack_client.async_get_share.return_value = replace(mock_share, id=202) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + data={CONF_SHARE_TOKEN: "another-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_share" + assert mock_config_entry.data == {CONF_SHARE_TOKEN: "canonical-token"} + assert mock_config_entry.unique_id == "101" + + +@pytest.mark.parametrize( + ("error", "expected_error"), + [ + pytest.param(ScorpionTrackConnectionError(), "cannot_connect", id="connection"), + pytest.param(ScorpionTrackInvalidTokenError(), "invalid_token", id="invalid"), + pytest.param( + ScorpionTrackShareUnavailableError(), "share_unavailable", id="unavailable" + ), + pytest.param(Exception(), "unknown", id="unexpected"), + ], +) +async def test_reconfigure_validation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + error: Exception, + expected_error: str, +) -> None: + """Recover from a validation error without losing the stored configuration.""" + mock_config_entry.add_to_hass(hass) + mock_scorpiontrack_client.async_get_share.side_effect = error + share_input = "https://app.scorpiontrack.com/shared/location?token=updated-token" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + data={CONF_SHARE_TOKEN: share_input}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_SHARE_TOKEN) + == share_input + ) + assert mock_config_entry.data == {CONF_SHARE_TOKEN: "canonical-token"} + + mock_scorpiontrack_client.async_get_share.side_effect = None + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, token="updated-token" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SHARE_TOKEN: share_input} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SHARE_TOKEN: "updated-token"} + assert mock_config_entry.unique_id == "101" + + +async def test_reconfigure_malformed_link( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Recover from an empty token by submitting a valid token.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + data={CONF_SHARE_TOKEN: " "}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_token"} + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_SHARE_TOKEN) + == " " + ) + assert mock_config_entry.data == {CONF_SHARE_TOKEN: "canonical-token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SHARE_TOKEN: "canonical-token"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SHARE_TOKEN: "canonical-token"} + assert mock_config_entry.unique_id == "101"