Files
core/tests/components/specialized_turbo/test_init.py
T

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