Implement bridge-backed Home Assistant integration

This commit is contained in:
2026-09-04 12:57:32 +02:00
parent 42050010b2
commit 9766f7f9b2
25 changed files with 2154 additions and 7 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Tests for MB-Secure snapshot and event coordination."""
from unittest.mock import AsyncMock, MagicMock
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
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_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