Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions custom_components/keymaster/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
updated_config[CONF_PARENT_ENTRY_ID] = None
elif updated_config.get(CONF_PARENT_ENTRY_ID) is None:
for entry in hass.config_entries.async_entries(DOMAIN):
if updated_config.get(CONF_PARENT) == entry.data.get(CONF_LOCK_NAME):
if updated_config.get(CONF_PARENT) in (entry.title, entry.data.get(CONF_LOCK_NAME)):
updated_config[CONF_PARENT_ENTRY_ID] = entry.entry_id
break

Expand Down Expand Up @@ -194,8 +194,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
notify_script_name=config_entry.data.get(CONF_NOTIFY_SCRIPT_NAME),
)

Comment thread
tykeal marked this conversation as resolved.
needs_update = config_entry.entry_id in coordinator.kmlocks
try:
await coordinator.add_lock(kmlock=kmlock)
await coordinator.add_lock(kmlock=kmlock, update=needs_update)
except asyncio.exceptions.CancelledError as e:
_LOGGER.error("Timeout on add_lock. %s: %s", e.__class__.__qualname__, e)

Expand Down
10 changes: 7 additions & 3 deletions custom_components/keymaster/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,15 @@ def _kmlocks_to_dict(self, instance: object) -> object:

async def _rebuild_lock_relationships(self) -> None:
for keymaster_config_entry_id, kmlock in self.kmlocks.items():
if kmlock.parent_name is not None:
parent_config_entry_id = kmlock.parent_config_entry_id
if parent_config_entry_id is not None and parent_config_entry_id in self.kmlocks:
parent_lock = self.kmlocks[parent_config_entry_id]
if keymaster_config_entry_id not in parent_lock.child_config_entry_ids:
parent_lock.child_config_entry_ids.append(keymaster_config_entry_id)
elif kmlock.parent_name is not None:
for parent_config_entry_id, parent_lock in self.kmlocks.items():
if kmlock.parent_name == parent_lock.lock_name:
if kmlock.parent_config_entry_id is None:
kmlock.parent_config_entry_id = parent_config_entry_id
kmlock.parent_config_entry_id = parent_config_entry_id
if keymaster_config_entry_id not in parent_lock.child_config_entry_ids:
parent_lock.child_config_entry_ids.append(keymaster_config_entry_id)
break
Expand Down
11 changes: 7 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async def disconnect():
client.connect = AsyncMock(side_effect=connect)
client.listen = AsyncMock(side_effect=listen)
client.disconnect = AsyncMock(side_effect=disconnect)
client.disable_server_logging = MagicMock()
client.disable_server_logging = AsyncMock(return_value=None)
client.driver = Driver(
client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state)
)
Expand Down Expand Up @@ -381,9 +381,12 @@ def mock_async_call_later():
with patch("homeassistant.helpers.event.async_call_later") as mock:

def immediate_call(hass, delay, callback):
# Immediately call the callback with a mock `hass` object
del hass, delay # Parameters not used in immediate callback
return callback(None)
del hass, delay, callback # Parameters not used in the no-op mock

def _unsubscribe() -> None:
return None

return _unsubscribe

mock.side_effect = immediate_call
yield mock
Expand Down
58 changes: 58 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,64 @@ async def test_rebuild_with_valid_parent_child_relationship(self, mock_coordinat
assert "child_id" in parent_lock.child_config_entry_ids
assert child_lock.parent_config_entry_id == "parent_id"

async def test_rebuild_prefers_explicit_parent_id_over_parent_name(self, mock_coordinator):
"""Test that explicit parent_config_entry_id wins over stale parent_name."""
parent_a = Mock(spec=KeymasterLock)
parent_a.keymaster_config_entry_id = "parent_a"
parent_a.lock_name = "Front Door"
parent_a.child_config_entry_ids = []
parent_a.parent_config_entry_id = None
parent_a.parent_name = None

parent_b = Mock(spec=KeymasterLock)
parent_b.keymaster_config_entry_id = "parent_b"
parent_b.lock_name = "Back Door"
parent_b.child_config_entry_ids = []
parent_b.parent_config_entry_id = None
parent_b.parent_name = None

child = Mock(spec=KeymasterLock)
child.keymaster_config_entry_id = "child"
child.lock_name = "Child"
child.child_config_entry_ids = []
child.parent_config_entry_id = "parent_b"
child.parent_name = "Front Door"

mock_coordinator.kmlocks = {
"parent_a": parent_a,
"parent_b": parent_b,
"child": child,
}

await mock_coordinator._rebuild_lock_relationships()

assert child.parent_config_entry_id == "parent_b"
assert "child" not in parent_a.child_config_entry_ids
assert "child" in parent_b.child_config_entry_ids

async def test_rebuild_falls_back_to_name_when_parent_entry_stale(self, mock_coordinator):
"""Test stale parent_config_entry_id falls back to parent_name matching."""
parent = Mock(spec=KeymasterLock)
parent.keymaster_config_entry_id = "parent_id"
parent.lock_name = "Front Door"
parent.child_config_entry_ids = []
parent.parent_config_entry_id = None
parent.parent_name = None

child = Mock(spec=KeymasterLock)
child.keymaster_config_entry_id = "child_id"
child.lock_name = "Child Lock"
child.child_config_entry_ids = []
child.parent_config_entry_id = "deleted_parent_id"
child.parent_name = "Front Door"

mock_coordinator.kmlocks = {"parent_id": parent, "child_id": child}

await mock_coordinator._rebuild_lock_relationships()

assert child.parent_config_entry_id == "parent_id"
assert "child_id" in parent.child_config_entry_ids

async def test_rebuild_with_mismatched_parent(self, mock_coordinator):
"""Test that child with mismatched parent is removed from old parent."""
# Arrange: Parent claims child, but child points to different parent
Expand Down
122 changes: 121 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
"""Test keymaster init."""

import logging
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch

import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.keymaster import async_setup_entry
from custom_components.keymaster.const import (
CONF_ADVANCED_DATE_RANGE,
CONF_ADVANCED_DAY_OF_WEEK,
CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID,
CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID,
CONF_DOOR_SENSOR_ENTITY_ID,
CONF_HIDE_PINS,
CONF_LOCK_ENTITY_ID,
CONF_LOCK_NAME,
CONF_NOTIFY_SCRIPT_NAME,
CONF_PARENT,
CONF_PARENT_ENTRY_ID,
CONF_SLOTS,
CONF_START,
DEFAULT_ADVANCED_DATE_RANGE,
DEFAULT_ADVANCED_DAY_OF_WEEK,
DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
Expand All @@ -35,6 +41,23 @@
KEYMASTER_SENSOR_COUNT = 8


def _build_entry_data(lock_name: str, lock_entity_id: str) -> dict:
"""Build minimal config entry data for async_setup_entry tests."""
return {
CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID: None,
CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID: None,
CONF_LOCK_ENTITY_ID: lock_entity_id,
CONF_LOCK_NAME: lock_name,
CONF_DOOR_SENSOR_ENTITY_ID: None,
CONF_SLOTS: 1,
CONF_START: 1,
CONF_NOTIFY_SCRIPT_NAME: None,
CONF_HIDE_PINS: False,
CONF_ADVANCED_DATE_RANGE: DEFAULT_ADVANCED_DATE_RANGE,
CONF_ADVANCED_DAY_OF_WEEK: DEFAULT_ADVANCED_DAY_OF_WEEK,
}


async def test_setup_entry(
hass,
lock_kwikset_910,
Expand Down Expand Up @@ -154,3 +177,100 @@ async def test_notify_script_name_slugified(hass):
await async_setup_entry(hass, entry)

assert entry.data[CONF_NOTIFY_SCRIPT_NAME] == "keymaster_akuvox_relay_a_manual_notify"


async def test_parent_title_resolves_to_parent_entry_id_during_setup(hass):
"""Test parent title resolution is used during setup."""
parent_data = _build_entry_data("front_door", "lock.front_door")
parent_entry = MockConfigEntry(domain=DOMAIN, title="Front Door", data=parent_data, version=4)
parent_entry.add_to_hass(hass)

child_data = _build_entry_data("garage_door", "lock.garage_door")
child_data[CONF_PARENT] = "Front Door"
child_data[CONF_PARENT_ENTRY_ID] = None
child_entry = MockConfigEntry(domain=DOMAIN, title="Garage Door", data=child_data, version=4)
child_entry.add_to_hass(hass)

hass.data.setdefault(DOMAIN, {})

with (
patch("custom_components.keymaster.async_setup_services", new_callable=AsyncMock),
patch("custom_components.keymaster.KeymasterCoordinator") as mock_coordinator_class,
patch("custom_components.keymaster.dr.async_get") as mock_device_registry_get,
patch(
"custom_components.keymaster.async_generate_lovelace",
new_callable=AsyncMock,
) as mock_generate_lovelace,
patch.object(
hass.config_entries,
"async_forward_entry_setups",
new_callable=AsyncMock,
),
):
mock_coordinator = mock_coordinator_class.return_value
mock_coordinator.initial_setup = AsyncMock()
mock_coordinator.async_refresh = AsyncMock()
mock_coordinator.last_update_success = True
mock_coordinator.kmlocks = {}
mock_coordinator.add_lock = AsyncMock()

mock_device_registry = Mock()
mock_device_registry.async_get_or_create = Mock()
mock_device_registry_get.return_value = mock_device_registry

assert await async_setup_entry(hass, child_entry)

assert child_entry.data[CONF_PARENT_ENTRY_ID] == parent_entry.entry_id

add_lock_await_args = mock_coordinator.add_lock.await_args
assert add_lock_await_args is not None
add_lock_call = add_lock_await_args.kwargs
assert add_lock_call["update"] is False
assert add_lock_call["kmlock"].parent_name == "Front Door"
assert add_lock_call["kmlock"].parent_config_entry_id == parent_entry.entry_id

device_registry_call = mock_device_registry.async_get_or_create.call_args.kwargs
assert device_registry_call["via_device"] == (DOMAIN, parent_entry.entry_id)

lovelace_await_args = mock_generate_lovelace.await_args
assert lovelace_await_args is not None
lovelace_call = lovelace_await_args.kwargs
assert lovelace_call["parent_config_entry_id"] == parent_entry.entry_id


async def test_setup_entry_calls_add_lock_with_update_true_for_existing_lock(hass):
"""Test setup calls add_lock with update=True for an existing lock."""
entry_data = _build_entry_data("front_door", "lock.front_door")
entry = MockConfigEntry(domain=DOMAIN, title="Front Door", data=entry_data, version=4)
entry.add_to_hass(hass)

hass.data.setdefault(DOMAIN, {})

with (
patch("custom_components.keymaster.async_setup_services", new_callable=AsyncMock),
patch("custom_components.keymaster.KeymasterCoordinator") as mock_coordinator_class,
patch("custom_components.keymaster.dr.async_get") as mock_device_registry_get,
patch("custom_components.keymaster.async_generate_lovelace", new_callable=AsyncMock),
patch.object(
hass.config_entries,
"async_forward_entry_setups",
new_callable=AsyncMock,
),
):
mock_coordinator = mock_coordinator_class.return_value
mock_coordinator.initial_setup = AsyncMock()
mock_coordinator.async_refresh = AsyncMock()
mock_coordinator.last_update_success = True
mock_coordinator.add_lock = AsyncMock()
mock_coordinator.kmlocks = {entry.entry_id: Mock()}

mock_device_registry = Mock()
mock_device_registry.async_get_or_create = Mock()
mock_device_registry_get.return_value = mock_device_registry

assert await async_setup_entry(hass, entry)

add_lock_await_args = mock_coordinator.add_lock.await_args
assert add_lock_await_args is not None
add_lock_kwargs = add_lock_await_args.kwargs
assert add_lock_kwargs["update"] is True
Loading