1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Use new method to get the access token in the Volvo integration (#151625)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Thomas D
2025-09-16 20:17:43 +02:00
committed by GitHub
parent 048f64eccf
commit 450c47f932
6 changed files with 198 additions and 81 deletions

View File

@@ -4,9 +4,8 @@ from __future__ import annotations
import asyncio
from aiohttp import ClientResponseError
from volvocarsapi.api import VolvoCarsApi
from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle
from volvocarsapi.models import VolvoApiException, VolvoAuthException, VolvoCarsVehicle
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
@@ -69,22 +68,22 @@ async def _async_auth_and_create_api(
oauth_session = OAuth2Session(hass, entry, implementation)
web_session = async_get_clientsession(hass)
auth = VolvoAuth(web_session, oauth_session)
try:
await auth.async_get_access_token()
except ClientResponseError as err:
if err.status in (400, 401):
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
return VolvoCarsApi(
api = VolvoCarsApi(
web_session,
auth,
entry.data[CONF_API_KEY],
entry.data[CONF_VIN],
)
try:
await api.async_get_access_token()
except VolvoAuthException as err:
raise ConfigEntryAuthFailed from err
except VolvoApiException as err:
raise ConfigEntryNotReady from err
return api
async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle:
try:

View File

@@ -1,11 +1,16 @@
"""API for Volvo bound to Home Assistant OAuth."""
import logging
from typing import cast
from aiohttp import ClientSession
from volvocarsapi.auth import AccessTokenManager
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.redact import async_redact_data
_LOGGER = logging.getLogger(__name__)
_TO_REDACT = ["access_token", "id_token", "refresh_token"]
class VolvoAuth(AccessTokenManager):
@@ -18,7 +23,20 @@ class VolvoAuth(AccessTokenManager):
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
current_access_token = self._oauth_session.token["access_token"]
current_refresh_token = self._oauth_session.token["refresh_token"]
await self._oauth_session.async_ensure_token_valid()
_LOGGER.debug(
"Token: %s", async_redact_data(self._oauth_session.token, _TO_REDACT)
)
_LOGGER.debug(
"Token changed: access %s, refresh %s",
current_access_token != self._oauth_session.token["access_token"],
current_refresh_token != self._oauth_session.token["refresh_token"],
)
return cast(str, self._oauth_session.token["access_token"])

View File

@@ -1,6 +1,7 @@
"""Define fixtures for Volvo unit tests."""
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
import pytest
@@ -9,6 +10,7 @@ from volvocarsapi.auth import TOKEN_URL
from volvocarsapi.models import (
VolvoCarsAvailableCommand,
VolvoCarsLocation,
VolvoCarsValueField,
VolvoCarsValueStatusField,
VolvoCarsVehicle,
)
@@ -17,10 +19,16 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.volvo.api import VolvoAuth
from homeassistant.components.volvo.const import CONF_VIN, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonObjectType
from . import async_load_fixture_as_json, async_load_fixture_as_value_field
from .const import (
@@ -37,6 +45,30 @@ from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@dataclass
class MockApiData:
"""Container for mock API data."""
vehicle: VolvoCarsVehicle
commands: list[VolvoCarsAvailableCommand]
location: dict[str, VolvoCarsLocation]
availability: dict[str, VolvoCarsValueField]
brakes: dict[str, VolvoCarsValueField]
diagnostics: dict[str, VolvoCarsValueField]
doors: dict[str, VolvoCarsValueField]
energy_capabilities: JsonObjectType
energy_state: dict[str, VolvoCarsValueStatusField]
engine_status: dict[str, VolvoCarsValueField]
engine_warnings: dict[str, VolvoCarsValueField]
fuel_status: dict[str, VolvoCarsValueField]
odometer: dict[str, VolvoCarsValueField]
recharge_status: dict[str, VolvoCarsValueField]
statistics: dict[str, VolvoCarsValueField]
tyres: dict[str, VolvoCarsValueField]
warnings: dict[str, VolvoCarsValueField]
windows: dict[str, VolvoCarsValueField]
@pytest.fixture(params=[DEFAULT_MODEL])
def full_model(request: pytest.FixtureRequest) -> str:
"""Define which model to use when running the test. Use as a decorator."""
@@ -65,81 +97,62 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
return config_entry
@pytest.fixture(autouse=True)
async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]:
@pytest.fixture
async def mock_api(
hass: HomeAssistant,
full_model: str,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
setup_credentials,
) -> AsyncGenerator[VolvoCarsApi]:
"""Mock the Volvo API."""
mock_api_data = await _async_load_mock_api_data(hass, full_model)
implementation = await async_get_config_entry_implementation(
hass, mock_config_entry
)
oauth_session = OAuth2Session(hass, mock_config_entry, implementation)
auth = VolvoAuth(aioclient_mock, oauth_session)
api = VolvoCarsApi(
aioclient_mock,
auth,
mock_config_entry.data[CONF_API_KEY],
mock_config_entry.data[CONF_VIN],
)
with patch(
"homeassistant.components.volvo.VolvoCarsApi",
autospec=True,
) as mock_api:
vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model)
vehicle = VolvoCarsVehicle.from_dict(vehicle_data)
commands_data = (
await async_load_fixture_as_json(hass, "commands", full_model)
).get("data")
commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data]
location_data = await async_load_fixture_as_json(hass, "location", full_model)
location = {"location": VolvoCarsLocation.from_dict(location_data)}
availability = await async_load_fixture_as_value_field(
hass, "availability", full_model
return_value=api,
):
api.async_get_brakes_status = AsyncMock(return_value=mock_api_data.brakes)
api.async_get_command_accessibility = AsyncMock(
return_value=mock_api_data.availability
)
brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model)
diagnostics = await async_load_fixture_as_value_field(
hass, "diagnostics", full_model
api.async_get_commands = AsyncMock(return_value=mock_api_data.commands)
api.async_get_diagnostics = AsyncMock(return_value=mock_api_data.diagnostics)
api.async_get_doors_status = AsyncMock(return_value=mock_api_data.doors)
api.async_get_energy_capabilities = AsyncMock(
return_value=mock_api_data.energy_capabilities
)
doors = await async_load_fixture_as_value_field(hass, "doors", full_model)
energy_capabilities = await async_load_fixture_as_json(
hass, "energy_capabilities", full_model
api.async_get_energy_state = AsyncMock(return_value=mock_api_data.energy_state)
api.async_get_engine_status = AsyncMock(
return_value=mock_api_data.engine_status
)
energy_state_data = await async_load_fixture_as_json(
hass, "energy_state", full_model
api.async_get_engine_warnings = AsyncMock(
return_value=mock_api_data.engine_warnings
)
energy_state = {
key: VolvoCarsValueStatusField.from_dict(value)
for key, value in energy_state_data.items()
}
engine_status = await async_load_fixture_as_value_field(
hass, "engine_status", full_model
api.async_get_fuel_status = AsyncMock(return_value=mock_api_data.fuel_status)
api.async_get_location = AsyncMock(return_value=mock_api_data.location)
api.async_get_odometer = AsyncMock(return_value=mock_api_data.odometer)
api.async_get_recharge_status = AsyncMock(
return_value=mock_api_data.recharge_status
)
engine_warnings = await async_load_fixture_as_value_field(
hass, "engine_warnings", full_model
)
fuel_status = await async_load_fixture_as_value_field(
hass, "fuel_status", full_model
)
odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model)
recharge_status = await async_load_fixture_as_value_field(
hass, "recharge_status", full_model
)
statistics = await async_load_fixture_as_value_field(
hass, "statistics", full_model
)
tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model)
warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model)
windows = await async_load_fixture_as_value_field(hass, "windows", full_model)
api: VolvoCarsApi = mock_api.return_value
api.async_get_brakes_status = AsyncMock(return_value=brakes)
api.async_get_command_accessibility = AsyncMock(return_value=availability)
api.async_get_commands = AsyncMock(return_value=commands)
api.async_get_diagnostics = AsyncMock(return_value=diagnostics)
api.async_get_doors_status = AsyncMock(return_value=doors)
api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities)
api.async_get_energy_state = AsyncMock(return_value=energy_state)
api.async_get_engine_status = AsyncMock(return_value=engine_status)
api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings)
api.async_get_fuel_status = AsyncMock(return_value=fuel_status)
api.async_get_location = AsyncMock(return_value=location)
api.async_get_odometer = AsyncMock(return_value=odometer)
api.async_get_recharge_status = AsyncMock(return_value=recharge_status)
api.async_get_statistics = AsyncMock(return_value=statistics)
api.async_get_tyre_states = AsyncMock(return_value=tyres)
api.async_get_vehicle_details = AsyncMock(return_value=vehicle)
api.async_get_warnings = AsyncMock(return_value=warnings)
api.async_get_window_states = AsyncMock(return_value=windows)
api.async_get_statistics = AsyncMock(return_value=mock_api_data.statistics)
api.async_get_tyre_states = AsyncMock(return_value=mock_api_data.tyres)
api.async_get_vehicle_details = AsyncMock(return_value=mock_api_data.vehicle)
api.async_get_warnings = AsyncMock(return_value=mock_api_data.warnings)
api.async_get_window_states = AsyncMock(return_value=mock_api_data.windows)
yield api
@@ -183,3 +196,76 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.volvo.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
async def _async_load_mock_api_data(
hass: HomeAssistant, full_model: str
) -> MockApiData:
"""Load all mock API data from fixtures."""
vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model)
vehicle = VolvoCarsVehicle.from_dict(vehicle_data)
commands_data = (
await async_load_fixture_as_json(hass, "commands", full_model)
).get("data")
commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data]
location_data = await async_load_fixture_as_json(hass, "location", full_model)
location = {"location": VolvoCarsLocation.from_dict(location_data)}
availability = await async_load_fixture_as_value_field(
hass, "availability", full_model
)
brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model)
diagnostics = await async_load_fixture_as_value_field(
hass, "diagnostics", full_model
)
doors = await async_load_fixture_as_value_field(hass, "doors", full_model)
energy_capabilities = await async_load_fixture_as_json(
hass, "energy_capabilities", full_model
)
energy_state_data = await async_load_fixture_as_json(
hass, "energy_state", full_model
)
energy_state = {
key: VolvoCarsValueStatusField.from_dict(value)
for key, value in energy_state_data.items()
}
engine_status = await async_load_fixture_as_value_field(
hass, "engine_status", full_model
)
engine_warnings = await async_load_fixture_as_value_field(
hass, "engine_warnings", full_model
)
fuel_status = await async_load_fixture_as_value_field(
hass, "fuel_status", full_model
)
odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model)
recharge_status = await async_load_fixture_as_value_field(
hass, "recharge_status", full_model
)
statistics = await async_load_fixture_as_value_field(hass, "statistics", full_model)
tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model)
warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model)
windows = await async_load_fixture_as_value_field(hass, "windows", full_model)
return MockApiData(
vehicle=vehicle,
commands=commands,
location=location,
availability=availability,
brakes=brakes,
diagnostics=diagnostics,
doors=doors,
energy_capabilities=energy_capabilities,
energy_state=energy_state,
engine_status=engine_status,
engine_warnings=engine_warnings,
fuel_status=fuel_status,
odometer=odometer,
recharge_status=recharge_status,
statistics=statistics,
tyres=tyres,
warnings=warnings,
windows=windows,
)

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("mock_api", "full_model")
@pytest.mark.parametrize(
"full_model",
["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"],

View File

@@ -21,6 +21,7 @@ from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.mark.usefixtures("mock_api")
async def test_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -38,6 +39,7 @@ async def test_setup(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("mock_api")
async def test_token_refresh_success(
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
@@ -61,7 +63,6 @@ async def test_token_refresh_success(
@pytest.mark.parametrize(
("token_response"),
[
(HTTPStatus.FORBIDDEN),
(HTTPStatus.INTERNAL_SERVER_ERROR),
(HTTPStatus.NOT_FOUND),
],
@@ -80,15 +81,23 @@ async def test_token_refresh_fail(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("token_response"),
[
(HTTPStatus.BAD_REQUEST),
(HTTPStatus.FORBIDDEN),
],
)
async def test_token_refresh_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
setup_integration: Callable[[], Awaitable[bool]],
token_response: HTTPStatus,
) -> None:
"""Test where token refresh indicates unauthorized."""
aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED)
aioclient_mock.post(TOKEN_URL, status=token_response)
assert not await setup_integration()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("mock_api", "full_model")
@pytest.mark.parametrize(
"full_model",
[
@@ -38,6 +39,7 @@ async def test_sensor(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("mock_api", "full_model")
@pytest.mark.parametrize(
"full_model",
["xc40_electric_2024"],
@@ -54,6 +56,7 @@ async def test_distance_to_empty_battery(
assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250"
@pytest.mark.usefixtures("mock_api", "full_model")
@pytest.mark.parametrize(
("full_model", "short_model"),
[("ex30_2024", "ex30"), ("xc60_phev_2020", "xc60")],
@@ -71,6 +74,7 @@ async def test_skip_invalid_api_fields(
assert not hass.states.get(f"sensor.volvo_{short_model}_charging_current_limit")
@pytest.mark.usefixtures("mock_api", "full_model")
@pytest.mark.parametrize(
"full_model",
["ex30_2024"],