mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 14:41:38 +01:00
308 lines
10 KiB
Python
308 lines
10 KiB
Python
"""Tests for Specialized Turbo integration setup."""
|
|
|
|
import asyncio
|
|
from datetime import timedelta
|
|
import logging
|
|
import time
|
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from specialized_turbo import (
|
|
DecryptionError,
|
|
EncryptionKeyProviderError,
|
|
EncryptionKeyRequiredError,
|
|
)
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
|
from homeassistant.components.specialized_turbo.const import (
|
|
CONF_HMI_HARDWARE,
|
|
CONF_HMI_SERIAL,
|
|
CONF_WRAPPED_KEY,
|
|
DOMAIN,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntryState
|
|
from homeassistant.const import CONF_ADDRESS
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.device_registry import format_mac
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from . import setup_integration
|
|
from .conftest import (
|
|
ENCRYPTED_SERVICE_INFO,
|
|
MOCK_ADDRESS,
|
|
MOCK_MANUFACTURER_DATA,
|
|
NAME_ONLY_SERVICE_INFO,
|
|
TCU1_SERVICE_INFO,
|
|
TCX_SERVICE_INFO,
|
|
MockLibrary,
|
|
make_service_info,
|
|
)
|
|
|
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
|
from tests.components.bluetooth import inject_bluetooth_service_info
|
|
|
|
|
|
async def test_setup_and_unload_entry(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test setup, polling, and unload through the library boundary."""
|
|
await setup_integration(hass, mock_config_entry, TCX_SERVICE_INFO)
|
|
|
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
|
mock_library.connection.connect.assert_awaited_once()
|
|
mock_library.monitor.start.assert_awaited_once_with(prime=False)
|
|
mock_library.monitor.poll.assert_awaited_once()
|
|
|
|
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
|
mock_library.monitor.stop.assert_awaited_once()
|
|
mock_library.connection.disconnect.assert_awaited_once()
|
|
|
|
|
|
async def test_runtime_uses_managed_ble_client(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test the runtime library client factory uses bleak_retry_connector."""
|
|
await setup_integration(hass, mock_config_entry, TCX_SERVICE_INFO)
|
|
client_factory = mock_library.connection_constructor.call_args.kwargs[
|
|
"client_factory"
|
|
]
|
|
client = MagicMock()
|
|
disconnected_callback = MagicMock()
|
|
|
|
with patch(
|
|
"homeassistant.components.specialized_turbo.coordinator.establish_connection",
|
|
new_callable=AsyncMock,
|
|
return_value=client,
|
|
) as establish_connection:
|
|
result_client = await client_factory(
|
|
TCX_SERVICE_INFO.device,
|
|
disconnected_callback,
|
|
)
|
|
|
|
assert result_client is client
|
|
establish_connection.assert_awaited_once_with(
|
|
ANY,
|
|
TCX_SERVICE_INFO.device,
|
|
MOCK_ADDRESS,
|
|
disconnected_callback=disconnected_callback,
|
|
)
|
|
|
|
|
|
async def test_periodic_poll_reuses_connection(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test a periodic poll does not create another library connection."""
|
|
with patch(
|
|
"homeassistant.components.bluetooth.active_update_coordinator.monotonic_time_coarse",
|
|
return_value=0.0,
|
|
):
|
|
await setup_integration(hass, mock_config_entry, TCX_SERVICE_INFO)
|
|
service_info = make_service_info(
|
|
manufacturer_data={
|
|
**MOCK_MANUFACTURER_DATA,
|
|
0xFFFF: b"\x01",
|
|
},
|
|
time=time.monotonic() + 61,
|
|
)
|
|
|
|
with patch(
|
|
"homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow"
|
|
):
|
|
inject_bluetooth_service_info(hass, service_info)
|
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11))
|
|
await hass.async_block_till_done()
|
|
|
|
assert mock_library.monitor.poll.await_count == 2
|
|
mock_library.connection_constructor.assert_called_once()
|
|
|
|
|
|
async def test_setup_succeeds_without_bike_in_range(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test setup does not require the bike to be awake."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
|
mock_library.connection_constructor.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"service_info",
|
|
[TCU1_SERVICE_INFO, NAME_ONLY_SERVICE_INFO],
|
|
ids=["tcu1", "name_only"],
|
|
)
|
|
async def test_setup_supported_bike_variants(
|
|
hass: HomeAssistant,
|
|
mock_library: MockLibrary,
|
|
service_info: BluetoothServiceInfoBleak,
|
|
) -> None:
|
|
"""Test setup passes parsed advertisement metadata to the library."""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
version=3,
|
|
title="Mock title",
|
|
data={CONF_ADDRESS: service_info.address},
|
|
unique_id=format_mac(service_info.address),
|
|
)
|
|
await setup_integration(hass, entry, service_info)
|
|
|
|
kwargs = mock_library.connection_constructor.call_args.kwargs
|
|
assert kwargs["bike_info"] is not None
|
|
mock_library.connection.connect.assert_awaited_once()
|
|
|
|
|
|
async def test_setup_encrypted_entry(
|
|
hass: HomeAssistant,
|
|
encrypted_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test stored encryption metadata reaches the library connection."""
|
|
await setup_integration(hass, encrypted_config_entry, ENCRYPTED_SERVICE_INFO)
|
|
|
|
kwargs = mock_library.connection_constructor.call_args.kwargs
|
|
assert kwargs["wrapped_key"] == encrypted_config_entry.data[CONF_WRAPPED_KEY]
|
|
assert kwargs["advertisement"].hmi_hardware == "B.3.3"
|
|
assert kwargs["advertisement"].hmi_serial == "80005338"
|
|
|
|
|
|
async def test_setup_encrypted_entry_with_partial_advertisement(
|
|
hass: HomeAssistant,
|
|
encrypted_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test stored HMI metadata reconstructs bike info after a partial advertisement."""
|
|
await setup_integration(hass, encrypted_config_entry, NAME_ONLY_SERVICE_INFO)
|
|
|
|
kwargs = mock_library.connection_constructor.call_args.kwargs
|
|
assert kwargs["bike_info"] is None
|
|
assert kwargs["advertisement"].hmi_hardware == "B.3.3"
|
|
assert kwargs["advertisement"].hmi_serial == "80005338"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
EncryptionKeyRequiredError("missing"),
|
|
EncryptionKeyProviderError("invalid"),
|
|
DecryptionError("stale"),
|
|
],
|
|
)
|
|
async def test_encryption_error_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
error: Exception,
|
|
) -> None:
|
|
"""Test missing, invalid, and stale keys start reauthentication."""
|
|
mock_library.connection.connect.side_effect = error
|
|
await setup_integration(hass, mock_config_entry, ENCRYPTED_SERVICE_INFO)
|
|
|
|
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH
|
|
assert mock_config_entry.data[CONF_HMI_HARDWARE] == "B.3.3"
|
|
assert mock_config_entry.data[CONF_HMI_SERIAL] == "80005338"
|
|
mock_library.monitor_constructor.assert_not_called()
|
|
|
|
|
|
async def test_key_error_without_hmi_does_not_start_broken_reauth(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test incomplete advertisements do not start an unusable reauth flow."""
|
|
mock_library.connection.connect.side_effect = EncryptionKeyRequiredError("missing")
|
|
await setup_integration(hass, mock_config_entry, NAME_ONLY_SERVICE_INFO)
|
|
|
|
assert hass.config_entries.flow.async_progress_by_handler(DOMAIN) == []
|
|
|
|
|
|
async def test_monitor_start_failure_disconnects(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test notification setup failure closes the partial connection."""
|
|
mock_library.monitor.start.side_effect = RuntimeError("failed")
|
|
await setup_integration(hass, mock_config_entry, TCX_SERVICE_INFO)
|
|
|
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
|
mock_library.connection.disconnect.assert_awaited_once()
|
|
|
|
|
|
async def test_unload_waits_for_in_flight_connection(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
) -> None:
|
|
"""Test unload waits for a connection poll and then closes it."""
|
|
connect_started = asyncio.Event()
|
|
connect_continue = asyncio.Event()
|
|
|
|
async def connect() -> None:
|
|
connect_started.set()
|
|
await connect_continue.wait()
|
|
mock_library.connection.is_connected = True
|
|
|
|
mock_library.connection.connect.side_effect = connect
|
|
mock_config_entry.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
coordinator = mock_config_entry.runtime_data
|
|
|
|
with patch(
|
|
"homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow"
|
|
):
|
|
inject_bluetooth_service_info(hass, TCX_SERVICE_INFO)
|
|
await connect_started.wait()
|
|
|
|
unload_task = hass.async_create_task(
|
|
hass.config_entries.async_unload(mock_config_entry.entry_id),
|
|
"Unload Specialized Turbo during connection",
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert not unload_task.done()
|
|
|
|
connect_continue.set()
|
|
assert await unload_task
|
|
await hass.async_block_till_done()
|
|
|
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
|
mock_library.monitor.stop.assert_awaited_once()
|
|
mock_library.connection.disconnect.assert_awaited_once()
|
|
assert coordinator.connected is False
|
|
|
|
|
|
async def test_unload_tolerates_library_cleanup_errors(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_library: MockLibrary,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test cleanup errors do not prevent config entry unloading."""
|
|
await setup_integration(hass, mock_config_entry, TCX_SERVICE_INFO)
|
|
mock_library.monitor.stop.side_effect = RuntimeError("stop failed")
|
|
mock_library.connection.disconnect.side_effect = RuntimeError("disconnect failed")
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert "Error stopping telemetry monitor" in caplog.text
|
|
assert "Error disconnecting" in caplog.text
|