diff --git a/homeassistant/components/scorpiontrack/diagnostics.py b/homeassistant/components/scorpiontrack/diagnostics.py new file mode 100644 index 000000000000..c5bc7b6c1dd0 --- /dev/null +++ b/homeassistant/components/scorpiontrack/diagnostics.py @@ -0,0 +1,40 @@ +"""Diagnostics support for ScorpionTrack.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from .const import CONF_SHARE_TOKEN +from .coordinator import ScorpionTrackConfigEntry + +TO_REDACT = { + CONF_SHARE_TOKEN, + CONF_LATITUDE, + CONF_LONGITUDE, + "address", + "id", + "name", + "owner_name", + "registration", + "title", + "token", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + data = asdict(coordinator.data) + # The redactor traverses lists, but not tuples. + data["vehicles"] = list(data["vehicles"]) + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "last_update_success": coordinator.last_update_success, + "data": async_redact_data(data, TO_REDACT), + } diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml index 726a2e41a9ef..a41fb6e77f82 100644 --- a/homeassistant/components/scorpiontrack/quality_scale.yaml +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -50,7 +50,7 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery: status: exempt comment: | diff --git a/tests/components/scorpiontrack/snapshots/test_diagnostics.ambr b/tests/components/scorpiontrack/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..de09787104b5 --- /dev/null +++ b/tests/components/scorpiontrack/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_diagnostics[failed_update] + dict({ + 'data': dict({ + 'created_at': '2026-09-04T12:00:00+00:00', + 'distance_units': 'miles', + 'expires_at': '2026-10-05T12:00:00+00:00', + 'id': '**REDACTED**', + 'owner_name': '**REDACTED**', + 'title': '**REDACTED**', + 'token': '**REDACTED**', + 'vehicles': list([ + dict({ + 'id': '**REDACTED**', + 'make': 'Volkswagen', + 'model': 'Golf R', + 'name': '**REDACTED**', + 'position': dict({ + 'address': '**REDACTED**', + 'bearing': 182.0, + 'ignition': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'speed_kmh': 48.3, + 'timestamp': '2026-09-05T12:00:00+00:00', + }), + 'registration': '**REDACTED**', + 'status': 'Moving', + }), + dict({ + 'id': '**REDACTED**', + 'make': 'Volkswagen', + 'model': 'Golf R', + 'name': '**REDACTED**', + 'position': dict({ + 'address': '**REDACTED**', + 'bearing': 182.0, + 'ignition': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'speed_kmh': 48.3, + 'timestamp': '2026-09-05T12:00:00+00:00', + }), + 'registration': '**REDACTED**', + 'status': 'Moving', + }), + ]), + }), + 'entry_data': dict({ + 'share_token': '**REDACTED**', + }), + 'last_update_success': False, + }) +# --- +# name: test_diagnostics[successful_update] + dict({ + 'data': dict({ + 'created_at': '2026-09-04T12:00:00+00:00', + 'distance_units': 'miles', + 'expires_at': '2026-10-05T12:00:00+00:00', + 'id': '**REDACTED**', + 'owner_name': '**REDACTED**', + 'title': '**REDACTED**', + 'token': '**REDACTED**', + 'vehicles': list([ + dict({ + 'id': '**REDACTED**', + 'make': 'Volkswagen', + 'model': 'Golf R', + 'name': '**REDACTED**', + 'position': dict({ + 'address': '**REDACTED**', + 'bearing': 182.0, + 'ignition': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'speed_kmh': 48.3, + 'timestamp': '2026-09-05T12:00:00+00:00', + }), + 'registration': '**REDACTED**', + 'status': 'Moving', + }), + dict({ + 'id': '**REDACTED**', + 'make': 'Volkswagen', + 'model': 'Golf R', + 'name': '**REDACTED**', + 'position': dict({ + 'address': '**REDACTED**', + 'bearing': 182.0, + 'ignition': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'speed_kmh': 48.3, + 'timestamp': '2026-09-05T12:00:00+00:00', + }), + 'registration': '**REDACTED**', + 'status': 'Moving', + }), + ]), + }), + 'entry_data': dict({ + 'share_token': '**REDACTED**', + }), + 'last_update_success': True, + }) +# --- +# name: test_diagnostics_missing_position + dict({ + 'data': dict({ + 'created_at': '2026-09-04T12:00:00+00:00', + 'distance_units': 'miles', + 'expires_at': '2026-10-05T12:00:00+00:00', + 'id': '**REDACTED**', + 'owner_name': '**REDACTED**', + 'title': '**REDACTED**', + 'token': '**REDACTED**', + 'vehicles': list([ + dict({ + 'id': '**REDACTED**', + 'make': 'Volkswagen', + 'model': 'Golf R', + 'name': '**REDACTED**', + 'position': dict({ + 'address': None, + 'bearing': None, + 'ignition': None, + 'latitude': None, + 'longitude': None, + 'speed_kmh': None, + 'timestamp': None, + }), + 'registration': '**REDACTED**', + 'status': 'unknown', + }), + ]), + }), + 'entry_data': dict({ + 'share_token': '**REDACTED**', + }), + 'last_update_success': True, + }) +# --- diff --git a/tests/components/scorpiontrack/test_diagnostics.py b/tests/components/scorpiontrack/test_diagnostics.py new file mode 100644 index 000000000000..b01d85b85d01 --- /dev/null +++ b/tests/components/scorpiontrack/test_diagnostics.py @@ -0,0 +1,97 @@ +"""Test ScorpionTrack diagnostics.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scorpiontrack.const import CONF_SHARE_TOKEN +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +pytestmark = pytest.mark.freeze_time("2026-09-07T12:00:00Z") + + +@pytest.mark.parametrize( + "update_error", + [ + pytest.param(None, id="successful_update"), + pytest.param( + ScorpionTrackConnectionError("Failed request for canonical-token"), + id="failed_update", + ), + ], +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, + mock_share: ScorpionTrackShare, + snapshot: SnapshotAssertion, + update_error: ScorpionTrackConnectionError | None, +) -> None: + """Test redaction for every vehicle without fetching or changing cached data.""" + vehicle = mock_share.vehicles[0] + share = replace( + mock_share, + vehicles=(vehicle, replace(vehicle, id=2, registration="EF34 GHI")), + ) + mock_scorpiontrack_client.async_get_share.return_value = share + await setup_integration(hass, mock_config_entry) + mock_scorpiontrack_client.async_get_share.side_effect = update_error + await mock_config_entry.runtime_data.async_refresh() + mock_scorpiontrack_client.async_get_share.reset_mock() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot + mock_scorpiontrack_client.async_get_share.assert_not_awaited() + assert mock_config_entry.runtime_data.data == share + assert mock_config_entry.data[CONF_SHARE_TOKEN] == "canonical-token" + + +async def test_diagnostics_missing_position( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, + mock_share: ScorpionTrackShare, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics when a vehicle has no position data.""" + vehicle = mock_share.vehicles[0] + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, + vehicles=( + replace( + vehicle, + position=replace( + vehicle.position, + latitude=None, + longitude=None, + timestamp=None, + speed_kmh=None, + ignition=None, + bearing=None, + address=None, + ), + status="unknown", + ), + ), + ) + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + )