"""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