Implement bridge-backed Home Assistant integration
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""Shared fixtures for MB-Secure integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_plugins = "pytest_homeassistant_custom_component"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
|
||||
"""Enable loading the custom integration in every test."""
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the asynchronous neutral Bridge API client."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient
|
||||
|
||||
from custom_components.mb_secure.api import (
|
||||
BridgeAuthenticationError,
|
||||
BridgeClient,
|
||||
)
|
||||
|
||||
TOKEN = "x" * 32
|
||||
|
||||
|
||||
async def test_client_authenticates_and_parses_additive_info(
|
||||
aiohttp_client,
|
||||
socket_enabled: None,
|
||||
) -> None:
|
||||
"""The client authenticates locally and ignores additive response fields."""
|
||||
|
||||
async def info(request: web.Request) -> web.Response:
|
||||
assert request.headers["Authorization"] == f"Bearer {TOKEN}"
|
||||
return web.json_response(
|
||||
{
|
||||
"bridge_id": "bridge-installation-1",
|
||||
"bridge_version": "0.1.0",
|
||||
"api_version": 1,
|
||||
"connected": True,
|
||||
"capabilities": ["snapshot", "events"],
|
||||
"future_field": True,
|
||||
}
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/info", info)
|
||||
http_client: TestClient = await aiohttp_client(app)
|
||||
client = BridgeClient(
|
||||
http_client.session,
|
||||
host=str(http_client.make_url("/")),
|
||||
port=80,
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
result = await client.async_get_info()
|
||||
|
||||
assert result.bridge_id == "bridge-installation-1"
|
||||
assert result.connected
|
||||
|
||||
|
||||
async def test_client_maps_authentication_failures(
|
||||
aiohttp_client,
|
||||
socket_enabled: None,
|
||||
) -> None:
|
||||
"""HTTP authentication failures use the dedicated client exception."""
|
||||
|
||||
async def unauthorized(request: web.Request) -> web.Response:
|
||||
return web.json_response({}, status=401)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/info", unauthorized)
|
||||
http_client: TestClient = await aiohttp_client(app)
|
||||
client = BridgeClient(
|
||||
http_client.session,
|
||||
host=str(http_client.make_url("/")),
|
||||
port=80,
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
with pytest.raises(BridgeAuthenticationError):
|
||||
await client.async_get_info()
|
||||
|
||||
|
||||
async def test_client_parses_server_sent_domain_events(
|
||||
aiohttp_client,
|
||||
socket_enabled: None,
|
||||
) -> None:
|
||||
"""The event client accepts the mock's server-sent event framing."""
|
||||
event_payload = {
|
||||
"revision": 2,
|
||||
"event_type": "state_changed",
|
||||
"object_type": "area",
|
||||
"object_id": "area-1",
|
||||
"state": "armed_away",
|
||||
}
|
||||
|
||||
async def events(request: web.Request) -> web.StreamResponse:
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
await response.prepare(request)
|
||||
await response.write(
|
||||
f"event: domain_event\ndata: {json.dumps(event_payload)}\n\n".encode()
|
||||
)
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/events", events)
|
||||
http_client: TestClient = await aiohttp_client(app)
|
||||
client = BridgeClient(
|
||||
http_client.session,
|
||||
host=str(http_client.make_url("/")),
|
||||
port=80,
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
stream = client.async_events()
|
||||
event = await anext(stream)
|
||||
await stream.aclose()
|
||||
|
||||
assert event.object_id == "area-1"
|
||||
assert event.revision == 2
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for the MB-Secure config flow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from custom_components.mb_secure.api import BridgeAuthenticationError
|
||||
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
|
||||
from custom_components.mb_secure.models import BridgeInfo
|
||||
|
||||
USER_INPUT = {
|
||||
CONF_HOST: "bridge.local",
|
||||
CONF_PORT: 8099,
|
||||
CONF_TOKEN: "x" * 32,
|
||||
}
|
||||
|
||||
|
||||
async def test_user_flow_creates_entry_with_stable_bridge_id(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Successful validation uses the installation ID as config entry ID."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
info = BridgeInfo(
|
||||
bridge_id="bridge-installation-1",
|
||||
bridge_version="0.1.0",
|
||||
api_version=1,
|
||||
connected=False,
|
||||
capabilities=frozenset({"snapshot", "events"}),
|
||||
)
|
||||
with patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_info",
|
||||
return_value=info,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == USER_INPUT
|
||||
assert result["result"].unique_id == "bridge-installation-1"
|
||||
|
||||
|
||||
async def test_user_flow_reports_invalid_token(hass: HomeAssistant) -> None:
|
||||
"""A rejected local bridge token remains inside the form flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_info",
|
||||
side_effect=BridgeAuthenticationError,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_user_flow_rejects_bridge_without_stable_id(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A bridge without an installation ID cannot create stable entities."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
info = BridgeInfo(
|
||||
bridge_id=None,
|
||||
bridge_version="0.1.0",
|
||||
api_version=1,
|
||||
connected=False,
|
||||
capabilities=frozenset(),
|
||||
)
|
||||
with patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_info",
|
||||
return_value=info,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "missing_bridge_id"}
|
||||
|
||||
|
||||
async def test_hassio_discovery_requires_confirmation_and_uses_bridge_id(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Supervisor discovery remains user-confirmed and adopts the bridge ID."""
|
||||
discovery = HassioServiceInfo(
|
||||
config=USER_INPUT,
|
||||
name="MB-Secure Bridge",
|
||||
slug="mb_secure_bridge",
|
||||
uuid="discovery-installation-1",
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_HASSIO},
|
||||
data=discovery,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "hassio_confirm"
|
||||
|
||||
info = BridgeInfo(
|
||||
bridge_id="bridge-installation-1",
|
||||
bridge_version="0.1.0",
|
||||
api_version=1,
|
||||
connected=True,
|
||||
capabilities=frozenset({"snapshot", "events"}),
|
||||
)
|
||||
with patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_info",
|
||||
return_value=info,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == "bridge-installation-1"
|
||||
assert result["data"] == USER_INPUT
|
||||
|
||||
|
||||
async def test_hassio_discovery_rejects_incomplete_data(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Supervisor discovery never guesses missing connection credentials."""
|
||||
discovery = HassioServiceInfo(
|
||||
config={"host": "bridge.local"},
|
||||
name="MB-Secure Bridge",
|
||||
slug="mb_secure_bridge",
|
||||
uuid="discovery-installation-1",
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_HASSIO},
|
||||
data=discovery,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "invalid_discovery"
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for MB-Secure diagnostics redaction."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.mb_secure.const import CONF_TOKEN, DOMAIN
|
||||
from custom_components.mb_secure.diagnostics import async_get_config_entry_diagnostics
|
||||
|
||||
|
||||
async def test_diagnostics_redact_config_and_whitelist_bridge_data(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Tokens, hosts, and unrecognized bridge fields never leave diagnostics."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"host": "private-host.local",
|
||||
"port": 8099,
|
||||
CONF_TOKEN: "x" * 32,
|
||||
},
|
||||
unique_id="bridge-installation-1",
|
||||
)
|
||||
coordinator = MagicMock()
|
||||
coordinator.client.async_get_diagnostics = AsyncMock(
|
||||
return_value={
|
||||
"api_version": 1,
|
||||
"connected": True,
|
||||
"snapshot_revision": 4,
|
||||
"object_counts": {"areas": 1},
|
||||
"unexpected_private_field": "must-not-leak",
|
||||
}
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["config_entry"]["host"] != "private-host.local"
|
||||
assert result["config_entry"][CONF_TOKEN] != entry.data[CONF_TOKEN]
|
||||
assert result["config_entry"]["port"] == 8099
|
||||
assert "unexpected_private_field" not in result["bridge"]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Tests for MB-Secure devices and entities."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
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
|
||||
|
||||
ENTRY_DATA = {
|
||||
"host": "bridge.local",
|
||||
"port": 8099,
|
||||
CONF_TOKEN: "x" * 32,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_payload() -> dict[str, object]:
|
||||
"""Return a capability-complete normalized test snapshot."""
|
||||
return {
|
||||
"revision": 1,
|
||||
"controllers": [
|
||||
{
|
||||
"id": "controller-1",
|
||||
"name": "Main Controller",
|
||||
"state": "online",
|
||||
"capabilities": ["snapshot"],
|
||||
}
|
||||
],
|
||||
"areas": [
|
||||
{
|
||||
"id": "area-1",
|
||||
"controller_id": "controller-1",
|
||||
"name": "Ground Floor",
|
||||
"state": "disarmed",
|
||||
"capabilities": ["arm_away", "disarm"],
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"id": "point-1",
|
||||
"controller_id": "controller-1",
|
||||
"area_id": "area-1",
|
||||
"module_id": None,
|
||||
"name": "Front Door",
|
||||
"state": "closed",
|
||||
"capabilities": ["binary_state"],
|
||||
},
|
||||
{
|
||||
"id": "point-without-capability",
|
||||
"controller_id": "controller-1",
|
||||
"area_id": "area-1",
|
||||
"module_id": None,
|
||||
"name": "Unsupported Point",
|
||||
"state": "unknown",
|
||||
"capabilities": [],
|
||||
},
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"id": "module-1",
|
||||
"controller_id": "controller-1",
|
||||
"name": "Expansion Module",
|
||||
"state": "online",
|
||||
"capabilities": ["status"],
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"controller_id": "controller-1",
|
||||
"module_id": "module-1",
|
||||
"name": "Indicator",
|
||||
"state": "off",
|
||||
"capabilities": ["set"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def setup_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Set up an entry against mocked neutral Bridge API responses."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="bridge-installation-1",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
info = BridgeInfo(
|
||||
bridge_id="bridge-installation-1",
|
||||
bridge_version="0.1.0",
|
||||
api_version=1,
|
||||
connected=True,
|
||||
capabilities=frozenset({"snapshot", "events", "commands"}),
|
||||
)
|
||||
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
|
||||
with (
|
||||
patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_info",
|
||||
return_value=info,
|
||||
),
|
||||
patch(
|
||||
"custom_components.mb_secure.api.BridgeClient.async_get_snapshot",
|
||||
return_value=snapshot,
|
||||
),
|
||||
patch.object(MBSecureCoordinator, "async_start_event_listener"),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_creates_capability_gated_entities_and_hierarchy(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Only reported capabilities create stable entities and linked devices."""
|
||||
entry = await setup_entry(hass)
|
||||
registry = er.async_get(hass)
|
||||
unique_ids = {
|
||||
entity.unique_id
|
||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id)
|
||||
}
|
||||
assert unique_ids == {
|
||||
"bridge-installation-1:areas:area-1",
|
||||
"bridge-installation-1:controllers:controller-1",
|
||||
"bridge-installation-1:modules:module-1",
|
||||
"bridge-installation-1:outputs:output-1",
|
||||
"bridge-installation-1:points:point-1",
|
||||
}
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
controller = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, "bridge-installation-1:controller:controller-1"),
|
||||
entry.entry_id,
|
||||
)
|
||||
area = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, "bridge-installation-1:area:area-1"),
|
||||
entry.entry_id,
|
||||
)
|
||||
module = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, "bridge-installation-1:module:module-1"),
|
||||
entry.entry_id,
|
||||
)
|
||||
assert controller is not None
|
||||
assert area is not None and area.via_device_id == controller.id
|
||||
assert module is not None and module.via_device_id == controller.id
|
||||
|
||||
|
||||
async def test_snapshot_topology_changes_add_and_remove_entities(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Snapshot reconciliation updates the runtime entity topology."""
|
||||
entry = await setup_entry(hass)
|
||||
coordinator = entry.runtime_data
|
||||
registry = er.async_get(hass)
|
||||
old_entry = registry.async_get_entity_id(
|
||||
"binary_sensor",
|
||||
DOMAIN,
|
||||
"bridge-installation-1:points:point-1",
|
||||
)
|
||||
assert old_entry is not None
|
||||
payload = deepcopy(snapshot_payload())
|
||||
payload["revision"] = 2
|
||||
points = payload["points"]
|
||||
assert isinstance(points, list)
|
||||
points.pop(0)
|
||||
points.append(
|
||||
{
|
||||
"id": "point-2",
|
||||
"controller_id": "controller-1",
|
||||
"area_id": "area-1",
|
||||
"module_id": None,
|
||||
"name": "Back Door",
|
||||
"state": "open",
|
||||
"capabilities": ["binary_state"],
|
||||
}
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data(BridgeSnapshot.from_dict(payload))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unique_ids = {
|
||||
entity.unique_id
|
||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id)
|
||||
}
|
||||
assert "bridge-installation-1:points:point-1" not in unique_ids
|
||||
assert "bridge-installation-1:points:point-2" in unique_ids
|
||||
assert hass.states.get(old_entry) is None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for vendor-neutral Bridge API v1 models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.mb_secure.models import (
|
||||
BridgeDataError,
|
||||
BridgeInfo,
|
||||
BridgeSnapshot,
|
||||
DomainEvent,
|
||||
)
|
||||
|
||||
|
||||
def test_bridge_info_accepts_stable_bridge_id() -> None:
|
||||
"""Bridge metadata accepts the installation identifier used by config flow."""
|
||||
info = BridgeInfo.from_dict(
|
||||
{
|
||||
"bridge_id": "bridge-1",
|
||||
"bridge_version": "0.1.0",
|
||||
"api_version": 1,
|
||||
"connected": False,
|
||||
"capabilities": ["snapshot", "events"],
|
||||
"future_field": "ignored",
|
||||
}
|
||||
)
|
||||
|
||||
assert info.bridge_id == "bridge-1"
|
||||
|
||||
|
||||
def test_bridge_info_tolerates_legacy_v1_without_bridge_id() -> None:
|
||||
"""A newer client remains compatible with the original API v1 response."""
|
||||
info = BridgeInfo.from_dict(
|
||||
{
|
||||
"bridge_version": "0.1.0",
|
||||
"api_version": 1,
|
||||
"connected": False,
|
||||
"capabilities": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert info.bridge_id is None
|
||||
|
||||
|
||||
def snapshot_payload() -> dict[str, Any]:
|
||||
"""Return a minimal neutral snapshot."""
|
||||
return {
|
||||
"revision": 4,
|
||||
"controllers": [
|
||||
{
|
||||
"id": "controller-1",
|
||||
"name": "Controller",
|
||||
"state": "online",
|
||||
"capabilities": [],
|
||||
"future_field": True,
|
||||
}
|
||||
],
|
||||
"areas": [
|
||||
{
|
||||
"id": "area-1",
|
||||
"controller_id": "controller-1",
|
||||
"name": "Area",
|
||||
"state": "disarmed",
|
||||
"capabilities": ["arm_away", "disarm"],
|
||||
}
|
||||
],
|
||||
"points": [],
|
||||
"modules": [],
|
||||
"outputs": [],
|
||||
"future_collection": [],
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_tolerates_additive_fields() -> None:
|
||||
"""API v1 parsers ignore fields added in compatible updates."""
|
||||
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
|
||||
|
||||
assert snapshot.controllers["controller-1"].name == "Controller"
|
||||
assert snapshot.revision == 4
|
||||
|
||||
|
||||
def test_snapshot_accepts_empty_display_name_allowed_by_v1() -> None:
|
||||
"""Display names are not identifiers and may be empty in API v1."""
|
||||
payload = snapshot_payload()
|
||||
payload["controllers"][0]["name"] = ""
|
||||
|
||||
snapshot = BridgeSnapshot.from_dict(payload)
|
||||
|
||||
assert snapshot.controllers["controller-1"].name == ""
|
||||
|
||||
|
||||
def test_snapshot_rejects_duplicate_stable_ids() -> None:
|
||||
"""Duplicate technical IDs cannot create ambiguous entities."""
|
||||
payload = snapshot_payload()
|
||||
payload["controllers"].append(dict(payload["controllers"][0]))
|
||||
|
||||
with pytest.raises(BridgeDataError):
|
||||
BridgeSnapshot.from_dict(payload)
|
||||
|
||||
|
||||
def test_snapshot_rejects_unknown_parent_reference() -> None:
|
||||
"""Objects cannot reference a controller absent from the snapshot."""
|
||||
payload = snapshot_payload()
|
||||
payload["areas"][0]["controller_id"] = "missing-controller"
|
||||
|
||||
with pytest.raises(BridgeDataError):
|
||||
BridgeSnapshot.from_dict(payload)
|
||||
|
||||
|
||||
def test_contiguous_state_event_is_applied() -> None:
|
||||
"""A contiguous state event updates one object and the revision."""
|
||||
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
|
||||
event = DomainEvent.from_dict(
|
||||
{
|
||||
"revision": 5,
|
||||
"event_type": "state_changed",
|
||||
"object_type": "area",
|
||||
"object_id": "area-1",
|
||||
"state": "armed_away",
|
||||
}
|
||||
)
|
||||
|
||||
updated = snapshot.apply_event(event)
|
||||
|
||||
assert updated is not None
|
||||
assert updated.revision == 5
|
||||
assert updated.areas["area-1"].state == "armed_away"
|
||||
|
||||
|
||||
def test_event_gap_requires_snapshot_reconciliation() -> None:
|
||||
"""A missing event is not applied over an incomplete state."""
|
||||
snapshot = BridgeSnapshot.from_dict(snapshot_payload())
|
||||
event = DomainEvent.from_dict(
|
||||
{
|
||||
"revision": 6,
|
||||
"event_type": "state_changed",
|
||||
"object_type": "area",
|
||||
"object_id": "area-1",
|
||||
"state": "armed_away",
|
||||
}
|
||||
)
|
||||
|
||||
assert snapshot.apply_event(event) is None
|
||||
Reference in New Issue
Block a user