mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 11:46:40 +01:00
1264 lines
40 KiB
Python
1264 lines
40 KiB
Python
"""Tests for the UniFi Access event platform."""
|
|
|
|
from collections.abc import Awaitable, Callable, Generator
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
from unifi_access_api.models.websocket import (
|
|
DeviceUpdate,
|
|
DeviceUpdateData,
|
|
DeviceUpdateDoor,
|
|
HwDoorbell,
|
|
HwDoorbellData,
|
|
InsightsAdd,
|
|
InsightsAddData,
|
|
InsightsMetadata,
|
|
InsightsMetadataEntry,
|
|
LogActor,
|
|
LogAdd,
|
|
LogAddData,
|
|
LogAuthentication,
|
|
LogEvent,
|
|
LogSource,
|
|
LogTarget,
|
|
RemoteView,
|
|
RemoteViewData,
|
|
V2DeviceLocationState,
|
|
V2DeviceUpdate,
|
|
V2DeviceUpdateData,
|
|
V2LocationUpdate,
|
|
V2LocationUpdateData,
|
|
WebsocketMessage,
|
|
)
|
|
|
|
from homeassistant.const import Platform
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from . import setup_integration
|
|
|
|
from tests.common import MockConfigEntry, snapshot_platform
|
|
|
|
FRONT_DOOR_DOORBELL_ENTITY = "event.front_door_doorbell"
|
|
FRONT_DOOR_ACCESS_ENTITY = "event.front_door_access"
|
|
BACK_DOOR_DOORBELL_ENTITY = "event.back_door_doorbell"
|
|
BACK_DOOR_ACCESS_ENTITY = "event.back_door_access"
|
|
|
|
|
|
def _get_ws_handlers(
|
|
mock_client: MagicMock,
|
|
) -> dict[str, Callable[[WebsocketMessage], Awaitable[None]]]:
|
|
"""Extract WebSocket handlers from mock client."""
|
|
return mock_client.start_websocket.call_args[0][0]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def only_event_platform() -> Generator[None]:
|
|
"""Limit setup to the event platform for event tests."""
|
|
with patch("homeassistant.components.unifi_access.PLATFORMS", [Platform.EVENT]):
|
|
yield
|
|
|
|
|
|
async def test_event_entities(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
entity_registry: er.EntityRegistry,
|
|
snapshot: SnapshotAssertion,
|
|
) -> None:
|
|
"""Test event entities are created with expected state."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
|
|
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_doorbell_ring_event(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test doorbell ring event is fired when WebSocket message arrives."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
doorbell_msg = HwDoorbell(
|
|
event="access.hw.door_bell",
|
|
data=HwDoorbellData(
|
|
door_id="door-001",
|
|
door_name="Front Door",
|
|
request_id="req-123",
|
|
),
|
|
)
|
|
|
|
await handlers["access.hw.door_bell"](doorbell_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "ring"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_doorbell_ring_event_wrong_door(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test doorbell ring event for unknown door is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
doorbell_msg = HwDoorbell(
|
|
event="access.hw.door_bell",
|
|
data=HwDoorbellData(
|
|
door_id="door-unknown",
|
|
door_name="Unknown Door",
|
|
request_id="req-999",
|
|
),
|
|
)
|
|
|
|
await handlers["access.hw.door_bell"](doorbell_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# Front door entity should still have no event
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"result",
|
|
"expected_event_type",
|
|
"door_id",
|
|
"entity_id",
|
|
"actor",
|
|
"authentication",
|
|
),
|
|
[
|
|
(
|
|
"ACCESS",
|
|
"access_granted",
|
|
"door-001",
|
|
FRONT_DOOR_ACCESS_ENTITY,
|
|
"John Doe",
|
|
"NFC",
|
|
),
|
|
(
|
|
"BLOCKED",
|
|
"access_denied",
|
|
"door-002",
|
|
BACK_DOOR_ACCESS_ENTITY,
|
|
"Unknown",
|
|
"PIN_CODE",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_access_event(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
result: str,
|
|
expected_event_type: str,
|
|
door_id: str,
|
|
entity_id: str,
|
|
actor: str,
|
|
authentication: str,
|
|
) -> None:
|
|
"""Test access event is fired with correct mapping from API result."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData.model_construct(
|
|
event_type="access.door.unlock",
|
|
result=result,
|
|
metadata=InsightsMetadata(
|
|
door=[
|
|
InsightsMetadataEntry(
|
|
id=door_id,
|
|
display_name="Door",
|
|
)
|
|
],
|
|
actor=InsightsMetadataEntry(
|
|
display_name=actor,
|
|
),
|
|
authentication=InsightsMetadataEntry(
|
|
display_name=authentication,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == expected_event_type
|
|
assert state.attributes["actor"] == actor
|
|
assert state.attributes["authentication"] == authentication
|
|
assert state.attributes["result"] == result
|
|
assert "direction" not in state.attributes
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_access_event_minimal_metadata(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access events work with minimal metadata (no actor or authentication)."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData.model_construct(
|
|
event_type="access.door.unlock",
|
|
result="ACCESS",
|
|
metadata=InsightsMetadata.model_construct(
|
|
door=[
|
|
InsightsMetadataEntry(
|
|
id="door-001",
|
|
display_name="Front Door",
|
|
)
|
|
],
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.attributes["result"] == "ACCESS"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_insights_no_door_id_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test insights event without door_id is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData.model_construct(
|
|
event_type="access.door.unlock",
|
|
result="ACCESS",
|
|
metadata=InsightsMetadata(
|
|
door=[InsightsMetadataEntry(id="", display_name="")],
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("result", "expected_event_type", "expected_result_attr"),
|
|
[
|
|
("ACCESS", "access_granted", "ACCESS"),
|
|
("BLOCKED", "access_denied", "BLOCKED"),
|
|
("TIMEOUT", "access_denied", "TIMEOUT"),
|
|
("", "access_denied", None),
|
|
],
|
|
)
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_access_event_result_mapping(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
result: str,
|
|
expected_event_type: str,
|
|
expected_result_attr: str | None,
|
|
) -> None:
|
|
"""Test result-to-event-type mapping with minimal attributes."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData.model_construct(
|
|
event_type="access.door.unlock",
|
|
result=result,
|
|
metadata=InsightsMetadata(
|
|
door=[
|
|
InsightsMetadataEntry(
|
|
id="door-001",
|
|
display_name="Front Door",
|
|
)
|
|
],
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == expected_event_type
|
|
assert "actor" not in state.attributes
|
|
assert "authentication" not in state.attributes
|
|
assert state.attributes.get("result") == expected_result_attr
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_insights_empty_door_list_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test insights event with empty door list is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData(
|
|
event_type="access.door.unlock",
|
|
result="ACCESS",
|
|
metadata=InsightsMetadata(door=[]),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_insights_multiple_doors(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test insights event with multiple doors dispatches events for each."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData(
|
|
event_type="access.door.unlock",
|
|
result="ACCESS",
|
|
metadata=InsightsMetadata(
|
|
door=[
|
|
InsightsMetadataEntry(id="door-001", display_name="Front Door"),
|
|
InsightsMetadataEntry(id="door-002", display_name="Back Door"),
|
|
],
|
|
actor=InsightsMetadataEntry(display_name="John Doe"),
|
|
authentication=InsightsMetadataEntry(display_name="NFC"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
front_state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert front_state is not None
|
|
assert front_state.attributes["event_type"] == "access_granted"
|
|
assert front_state.attributes["actor"] == "John Doe"
|
|
assert front_state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
back_state = hass.states.get(BACK_DOOR_ACCESS_ENTITY)
|
|
assert back_state is not None
|
|
assert back_state.attributes["event_type"] == "access_granted"
|
|
assert back_state.attributes["actor"] == "John Doe"
|
|
assert back_state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_unload_entry_removes_listeners(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test that events are not processed after config entry is unloaded."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
await hass.config_entries.async_unload(init_integration.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
doorbell_msg = HwDoorbell(
|
|
event="access.hw.door_bell",
|
|
data=HwDoorbellData(
|
|
door_id="door-001",
|
|
door_name="Front Door",
|
|
request_id="req-after-unload",
|
|
),
|
|
)
|
|
|
|
await handlers["access.hw.door_bell"](doorbell_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unavailable"
|
|
|
|
|
|
async def _populate_device_mapping(
|
|
handlers: dict[str, Callable[[WebsocketMessage], Awaitable[None]]],
|
|
) -> None:
|
|
"""Send a V2 location update to populate the device-to-door mapping."""
|
|
location_msg = V2LocationUpdate(
|
|
event="access.data.v2.location.update",
|
|
data=V2LocationUpdateData(
|
|
id="door-001",
|
|
location_type="door",
|
|
name="Front Door",
|
|
device_ids=["hub-device-001", "camera-device-001"],
|
|
),
|
|
)
|
|
await handlers["access.data.v2.location.update"](location_msg)
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_access_granted(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event dispatches access_granted."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
actor=LogActor(display_name="John Doe"),
|
|
event=LogEvent(result="ACCESS"),
|
|
authentication=LogAuthentication(credential_provider="NFC"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.attributes["actor"] == "John Doe"
|
|
assert state.attributes["authentication"] == "NFC"
|
|
assert state.attributes["result"] == "ACCESS"
|
|
assert "direction" not in state.attributes
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_access_denied(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event dispatches access_denied for non-ACCESS result."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
event=LogEvent(result="BLOCKED"),
|
|
authentication=LogAuthentication(credential_provider="PIN_CODE"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_denied"
|
|
assert state.attributes["result"] == "BLOCKED"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_logs_add_unknown_device_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event with unknown device ID is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="unknown-device",
|
|
display_name="Unknown Hub",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_logs_add_without_location_update_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event is ignored when no device mapping exists."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_empty_result_dispatches_access_denied(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event with empty result dispatches access_denied."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
event=LogEvent(result=""),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_denied"
|
|
assert "result" not in state.attributes
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_logs_add_no_device_config_target_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event without device_config target is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
door_id="door-001", # enriched door_id must not bypass missing device_config
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="user",
|
|
id="some-user-id",
|
|
display_name="Some User",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_logs_add_empty_targets_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.logs.add event with empty targets list is ignored."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_stale_device_mapping_cleared(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test stale device mappings are cleared when a door's devices change."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
# Reassign door-001 to only have camera-device-001 (hub-device-001 removed)
|
|
reassign_msg = V2LocationUpdate(
|
|
event="access.data.v2.location.update",
|
|
data=V2LocationUpdateData(
|
|
id="door-001",
|
|
location_type="door",
|
|
name="Front Door",
|
|
device_ids=["camera-device-001"],
|
|
),
|
|
)
|
|
await handlers["access.data.v2.location.update"](reassign_msg)
|
|
|
|
# hub-device-001 should no longer resolve to door-001
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
actor=LogActor(display_name="John Doe"),
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
# camera-device-001 should still resolve to door-001
|
|
camera_log = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="camera-device-001",
|
|
display_name="Camera",
|
|
),
|
|
],
|
|
actor=LogActor(display_name="Jane Doe"),
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](camera_log)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_device_mapping_pruned_on_refresh(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test device-to-door mappings are pruned when a door is removed on refresh."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
# Simulate door-001 being removed from the hub
|
|
mock_client.get_doors.return_value = [
|
|
door for door in mock_client.get_doors.return_value if door.id != "door-001"
|
|
]
|
|
|
|
# Trigger refresh via WebSocket reconnect
|
|
on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"]
|
|
on_connect = mock_client.start_websocket.call_args[1]["on_connect"]
|
|
on_disconnect()
|
|
await hass.async_block_till_done()
|
|
on_connect()
|
|
await hass.async_block_till_done()
|
|
|
|
# hub-device-001 mapping should have been pruned;
|
|
# sending a log event for it must not raise an error
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
],
|
|
actor=LogActor(display_name="John Doe"),
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# door-001 entity was removed when the door disappeared
|
|
assert hass.states.get(FRONT_DOOR_ACCESS_ENTITY) is None
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_remote_view_doorbell_ring_by_device_id(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.remote_view fires doorbell ring when device_id is in the mapping."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="hub-device-001"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "ring"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_remote_view_doorbell_ring_by_door_name_fallback(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test remote_view falls back to door_name when device_id is unmapped."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="unknown-device", door_name="Front Door"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "ring"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_remote_view_unknown_device_and_door_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test remote_view is ignored when device_id and door_name are unknown."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="unknown-device", door_name="Unknown Door"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_remote_view_device_mapping_via_device_update(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test remote_view resolves device_id populated by data.device.update."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
device_update_msg = DeviceUpdate(
|
|
event="access.data.device.update",
|
|
data=DeviceUpdateData(
|
|
unique_id="intercom-device-001",
|
|
door=DeviceUpdateDoor(unique_id="door-001"),
|
|
),
|
|
)
|
|
await handlers["access.data.device.update"](device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="intercom-device-001"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "ring"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_remote_view_device_mapping_via_v2_device_update(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test remote_view resolves device_id from data.v2.device.update."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
v2_device_update_msg = V2DeviceUpdate(
|
|
event="access.data.v2.device.update",
|
|
data=V2DeviceUpdateData(
|
|
id="intercom-v2-001",
|
|
location_states=[V2DeviceLocationState(location_id="door-001")],
|
|
),
|
|
)
|
|
await handlers["access.data.v2.device.update"](v2_device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="intercom-v2-001"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "ring"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_v2_device_update_multiple_location_states_maps_to_first_door(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test device with multiple location_states maps to the first valid door only."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
# Device has two location_states; should be mapped to the first door (door-001).
|
|
v2_device_update_msg = V2DeviceUpdate(
|
|
event="access.data.v2.device.update",
|
|
data=V2DeviceUpdateData(
|
|
id="hub-multi-001",
|
|
location_states=[
|
|
V2DeviceLocationState(location_id="door-001"),
|
|
V2DeviceLocationState(location_id="door-002"),
|
|
],
|
|
),
|
|
)
|
|
await handlers["access.data.v2.device.update"](v2_device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="hub-multi-001"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# Should ring front door (door-001), not back door (door-002)
|
|
front_state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert front_state is not None
|
|
assert front_state.attributes["event_type"] == "ring"
|
|
assert front_state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
back_state = hass.states.get(BACK_DOOR_DOORBELL_ENTITY)
|
|
assert back_state is not None
|
|
assert back_state.state == "unknown"
|
|
|
|
|
|
async def test_device_update_without_door_does_not_map(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.data.device.update without a door does not populate the mapping."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
device_update_msg = DeviceUpdate(
|
|
event="access.data.device.update",
|
|
data=DeviceUpdateData(unique_id="orphan-device"),
|
|
)
|
|
await handlers["access.data.device.update"](device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# Sending a remote_view for that device should not fire a doorbell event
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id="orphan-device"),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_device_update_empty_unique_id_does_not_pollute_mapping(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.data.device.update with empty unique_id does not create mapping."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
device_update_msg = DeviceUpdate(
|
|
event="access.data.device.update",
|
|
data=DeviceUpdateData(
|
|
unique_id="",
|
|
door=DeviceUpdateDoor(unique_id="door-001"),
|
|
),
|
|
)
|
|
await handlers["access.data.device.update"](device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# An empty device_id must not accidentally resolve via the empty-string key
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id=""),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_v2_device_update_empty_id_does_not_pollute_mapping(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test access.data.v2.device.update with empty id does not create mapping."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
v2_device_update_msg = V2DeviceUpdate(
|
|
event="access.data.v2.device.update",
|
|
data=V2DeviceUpdateData(
|
|
id="",
|
|
location_states=[V2DeviceLocationState(location_id="door-001")],
|
|
),
|
|
)
|
|
await handlers["access.data.v2.device.update"](v2_device_update_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
# The empty-string device id must not produce an entry in _device_to_door
|
|
remote_view_msg = RemoteView(
|
|
event="access.remote_view",
|
|
data=RemoteViewData(device_id=""),
|
|
)
|
|
await handlers["access.remote_view"](remote_view_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_uah_door_via_enriched_door_id(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test UAH-DOOR access event resolved via library-enriched door_id."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
# UAH-DOOR: device MAC is not in coordinator's _device_to_door,
|
|
# but the library has enriched msg.door_id via its MAC→door map.
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
door_id="door-001",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="uah-door-mac-aa:bb:cc:dd:ee:ff",
|
|
display_name="UAH Door Reader",
|
|
),
|
|
],
|
|
actor=LogActor(display_name="Jane Doe"),
|
|
event=LogEvent(result="ACCESS"),
|
|
authentication=LogAuthentication(credential_provider="NFC"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.attributes["actor"] == "Jane Doe"
|
|
assert state.attributes["authentication"] == "NFC"
|
|
assert state.attributes["result"] == "ACCESS"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_uah_door_access_denied(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test UAH-DOOR access_denied event resolved via library-enriched door_id."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
door_id="door-001",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="uah-door-mac-aa:bb:cc:dd:ee:ff",
|
|
display_name="UAH Door Reader",
|
|
),
|
|
],
|
|
event=LogEvent(result="BLOCKED"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_denied"
|
|
assert state.attributes["result"] == "BLOCKED"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
async def test_logs_add_uah_door_unknown_door_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test UAH-DOOR event is ignored when door_id is not a known door."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
door_id="door-unknown",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="uah-door-mac-aa:bb:cc:dd:ee:ff",
|
|
display_name="UAH Door Reader",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
async def test_logs_add_no_device_and_no_enriched_door_id_ignored(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test logs.add is ignored when neither device mapping nor door_id resolves."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="unknown-device",
|
|
display_name="Unknown",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_insights_add_direction(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test direction attribute is included in access event when present."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
|
|
insights_msg = InsightsAdd(
|
|
event="access.logs.insights.add",
|
|
data=InsightsAddData.model_construct(
|
|
event_type="access.door.unlock",
|
|
result="ACCESS",
|
|
metadata=InsightsMetadata(
|
|
door=[InsightsMetadataEntry(id="door-001", display_name="Front Door")],
|
|
opened_direction=[InsightsMetadataEntry(display_name="entry")],
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.insights.add"](insights_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.attributes["direction"] == "entry"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
|
|
|
|
|
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
|
async def test_logs_add_direction(
|
|
hass: HomeAssistant,
|
|
init_integration: MockConfigEntry,
|
|
mock_client: MagicMock,
|
|
) -> None:
|
|
"""Test direction attribute is included in event from logs.add."""
|
|
handlers = _get_ws_handlers(mock_client)
|
|
await _populate_device_mapping(handlers)
|
|
|
|
log_msg = LogAdd(
|
|
event="access.logs.add",
|
|
data=LogAddData(
|
|
source=LogSource(
|
|
target=[
|
|
LogTarget(
|
|
type="device_config",
|
|
id="hub-device-001",
|
|
display_name="UA Hub Door",
|
|
),
|
|
LogTarget(
|
|
type="device_config",
|
|
id="door_entry_method",
|
|
display_name="entry",
|
|
),
|
|
],
|
|
event=LogEvent(result="ACCESS"),
|
|
),
|
|
),
|
|
)
|
|
|
|
await handlers["access.logs.add"](log_msg)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "access_granted"
|
|
assert state.attributes["direction"] == "entry"
|
|
assert state.state == "2025-01-01T00:00:00.000+00:00"
|