Add ScorpionTrack share reconfiguration (#182089)

This commit is contained in:
Anthony
2026-10-05 16:17:25 +02:00
committed by GitHub
parent f92d304993
commit ead726313c
6 changed files with 262 additions and 17 deletions
@@ -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,
)
@@ -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
@@ -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"
+17 -10
View File
@@ -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
@@ -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(
@@ -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"