210 lines
6.4 KiB
Python
210 lines
6.4 KiB
Python
"""Tests for MB-Secure snapshot and event coordination."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.mb_secure.api import (
|
|
BridgeAuthenticationError,
|
|
BridgeConnectionError,
|
|
)
|
|
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
|
|
from custom_components.mb_secure.coordinator import MBSecureCoordinator
|
|
from custom_components.mb_secure.models import BridgeInfo, BridgeSnapshot, DomainEvent
|
|
|
|
|
|
def snapshot(state: str = "disarmed", revision: int = 1) -> BridgeSnapshot:
|
|
"""Return a minimal area snapshot."""
|
|
return BridgeSnapshot.from_dict(
|
|
{
|
|
"revision": revision,
|
|
"controllers": [
|
|
{
|
|
"id": "controller-1",
|
|
"name": "Controller",
|
|
"state": "online",
|
|
"capabilities": [],
|
|
}
|
|
],
|
|
"areas": [
|
|
{
|
|
"id": "area-1",
|
|
"controller_id": "controller-1",
|
|
"name": "Area",
|
|
"state": state,
|
|
"capabilities": ["arm_away"],
|
|
}
|
|
],
|
|
"points": [],
|
|
"modules": [],
|
|
"outputs": [],
|
|
}
|
|
)
|
|
|
|
|
|
def coordinator(
|
|
hass: HomeAssistant, client: MagicMock
|
|
) -> tuple[MBSecureCoordinator, MockConfigEntry]:
|
|
"""Create a coordinator and attached mock config entry."""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={"host": "bridge.local", "port": 8099, CONF_TOKEN: "x" * 32},
|
|
unique_id="bridge-installation-1",
|
|
)
|
|
entry.add_to_hass(hass)
|
|
return MBSecureCoordinator(hass, entry, client), entry
|
|
|
|
|
|
async def test_snapshot_authentication_failure_starts_reauth(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Rejected bridge credentials are converted to Home Assistant reauth."""
|
|
client = MagicMock()
|
|
client.async_get_info = AsyncMock(side_effect=BridgeAuthenticationError)
|
|
instance, _ = coordinator(hass, client)
|
|
|
|
with pytest.raises(ConfigEntryAuthFailed):
|
|
await instance._async_update_data()
|
|
|
|
|
|
async def test_snapshot_rejects_changed_bridge_identity(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""An endpoint cannot silently replace the configured bridge instance."""
|
|
client = MagicMock()
|
|
client.async_get_info = AsyncMock(
|
|
return_value=BridgeInfo(
|
|
bridge_id="different-installation",
|
|
bridge_version="0.1.0",
|
|
api_version=1,
|
|
connected=True,
|
|
capabilities=frozenset(),
|
|
)
|
|
)
|
|
client.async_get_snapshot = AsyncMock(return_value=snapshot())
|
|
instance, _ = coordinator(hass, client)
|
|
|
|
with pytest.raises(UpdateFailed, match="bridge installation ID has changed"):
|
|
await instance._async_update_data()
|
|
|
|
client.async_get_snapshot.assert_not_awaited()
|
|
|
|
|
|
async def test_bridge_restart_accepts_a_new_snapshot_revision(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""A full snapshot reconciles revision reset after a bridge restart."""
|
|
client = MagicMock()
|
|
client.async_get_info = AsyncMock(
|
|
return_value=BridgeInfo(
|
|
bridge_id="bridge-installation-1",
|
|
bridge_version="0.1.0",
|
|
api_version=1,
|
|
connected=True,
|
|
capabilities=frozenset(),
|
|
)
|
|
)
|
|
client.async_get_snapshot = AsyncMock(return_value=snapshot(revision=0))
|
|
instance, _ = coordinator(hass, client)
|
|
instance.async_set_updated_data(snapshot(revision=20))
|
|
|
|
result = await instance._async_update_data()
|
|
|
|
assert result.revision == 0
|
|
|
|
|
|
async def test_event_stream_reconnects_after_connection_loss(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""The event listener reconnects and resumes contiguous updates."""
|
|
client = MagicMock()
|
|
attempts = 0
|
|
|
|
async def events():
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
if False:
|
|
yield
|
|
raise BridgeConnectionError
|
|
yield DomainEvent(
|
|
revision=2,
|
|
event_type="state_changed",
|
|
object_type="area",
|
|
object_id="area-1",
|
|
state="armed_away",
|
|
)
|
|
raise BridgeAuthenticationError
|
|
|
|
client.async_events = events
|
|
instance, entry = coordinator(hass, client)
|
|
instance.async_set_updated_data(snapshot())
|
|
entry.async_start_reauth = MagicMock()
|
|
|
|
with patch("custom_components.mb_secure.coordinator.asyncio.sleep") as sleep:
|
|
await instance._async_event_loop()
|
|
|
|
sleep.assert_awaited_once_with(1)
|
|
assert attempts == 2
|
|
assert instance.data.areas["area-1"].state == "armed_away"
|
|
|
|
|
|
async def test_contiguous_event_updates_coordinator_data(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""A contiguous event updates state without waiting for the next poll."""
|
|
client = MagicMock()
|
|
|
|
async def events():
|
|
yield DomainEvent(
|
|
revision=2,
|
|
event_type="state_changed",
|
|
object_type="area",
|
|
object_id="area-1",
|
|
state="armed_away",
|
|
)
|
|
raise BridgeAuthenticationError
|
|
|
|
client.async_events = events
|
|
instance, entry = coordinator(hass, client)
|
|
instance.async_set_updated_data(snapshot())
|
|
entry.async_start_reauth = MagicMock()
|
|
|
|
await instance._async_event_loop()
|
|
|
|
assert instance.data.revision == 2
|
|
assert instance.data.areas["area-1"].state == "armed_away"
|
|
entry.async_start_reauth.assert_called_once_with(hass)
|
|
|
|
|
|
async def test_event_gap_requests_snapshot_reconciliation(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""A revision gap schedules a full snapshot refresh."""
|
|
client = MagicMock()
|
|
|
|
async def events():
|
|
yield DomainEvent(
|
|
revision=3,
|
|
event_type="state_changed",
|
|
object_type="area",
|
|
object_id="area-1",
|
|
state="armed_away",
|
|
)
|
|
raise BridgeAuthenticationError
|
|
|
|
client.async_events = events
|
|
instance, entry = coordinator(hass, client)
|
|
instance.async_set_updated_data(snapshot())
|
|
instance.async_request_refresh = AsyncMock()
|
|
entry.async_start_reauth = MagicMock()
|
|
|
|
await instance._async_event_loop()
|
|
|
|
instance.async_request_refresh.assert_awaited_once()
|
|
assert instance.data.revision == 1
|