From 5527cbd78ac7bbe25bed204f3e9b83095d0bd807 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= <oyvind@wergeland.org>
Date: Mon, 20 Nov 2023 22:38:16 +0100
Subject: [PATCH] Fix default lock code for lock services (#103463)

* verisure: Support default code from lock entity

* Actually use default lock code

* Typing

* Only pass default code if set

* Avoid passing code as empty string

* Simplified code
---
 homeassistant/components/lock/__init__.py | 38 ++++-----
 tests/components/lock/test_init.py        | 95 +++++++++++++++++++++++
 2 files changed, 111 insertions(+), 22 deletions(-)

diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index 8cbce69dc7c..ed7e2070055 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -87,40 +87,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
     return True
 
 
-async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None:
-    """Lock the lock."""
-    code: str = service_call.data.get(
-        ATTR_CODE, entity._lock_option_default_code  # pylint: disable=protected-access
-    )
+@callback
+def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]:
+    data = remove_entity_service_fields(service_call)
+    code: str = data.pop(ATTR_CODE, "")
+    if not code:
+        code = entity._lock_option_default_code  # pylint: disable=protected-access
     if entity.code_format_cmp and not entity.code_format_cmp.match(code):
         raise ValueError(
             f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}"
         )
-    await entity.async_lock(**remove_entity_service_fields(service_call))
+    if code:
+        data[ATTR_CODE] = code
+    return data
+
+
+async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None:
+    """Lock the lock."""
+    await entity.async_lock(**_add_default_code(entity, service_call))
 
 
 async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None:
     """Unlock the lock."""
-    code: str = service_call.data.get(
-        ATTR_CODE, entity._lock_option_default_code  # pylint: disable=protected-access
-    )
-    if entity.code_format_cmp and not entity.code_format_cmp.match(code):
-        raise ValueError(
-            f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}"
-        )
-    await entity.async_unlock(**remove_entity_service_fields(service_call))
+    await entity.async_unlock(**_add_default_code(entity, service_call))
 
 
 async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None:
     """Open the door latch."""
-    code: str = service_call.data.get(
-        ATTR_CODE, entity._lock_option_default_code  # pylint: disable=protected-access
-    )
-    if entity.code_format_cmp and not entity.code_format_cmp.match(code):
-        raise ValueError(
-            f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}"
-        )
-    await entity.async_open(**remove_entity_service_fields(service_call))
+    await entity.async_open(**_add_default_code(entity, service_call))
 
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py
index 31ad8fc60ac..16f40fda786 100644
--- a/tests/components/lock/test_init.py
+++ b/tests/components/lock/test_init.py
@@ -42,6 +42,8 @@ class MockLockEntity(LockEntity):
     ) -> None:
         """Initialize mock lock entity."""
         self._attr_supported_features = supported_features
+        self.calls_lock = MagicMock()
+        self.calls_unlock = MagicMock()
         self.calls_open = MagicMock()
         if code_format is not None:
             self._attr_code_format = code_format
@@ -49,11 +51,13 @@ class MockLockEntity(LockEntity):
 
     async def async_lock(self, **kwargs: Any) -> None:
         """Lock the lock."""
+        self.calls_lock(kwargs)
         self._attr_is_locking = False
         self._attr_is_locked = True
 
     async def async_unlock(self, **kwargs: Any) -> None:
         """Unlock the lock."""
+        self.calls_unlock(kwargs)
         self._attr_is_unlocking = False
         self._attr_is_locked = False
 
@@ -232,6 +236,50 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None:
     assert not lock.is_locked
 
 
+async def test_lock_with_illegal_code(hass: HomeAssistant) -> None:
+    """Test lock entity with default code that does not match the code format."""
+    lock = MockLockEntity(
+        code_format=r"^\d{4}$",
+        supported_features=LockEntityFeature.OPEN,
+    )
+    lock.hass = hass
+
+    with pytest.raises(ValueError):
+        await _async_open(
+            lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"})
+        )
+    with pytest.raises(ValueError):
+        await _async_lock(
+            lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"})
+        )
+    with pytest.raises(ValueError):
+        await _async_unlock(
+            lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"})
+        )
+
+
+async def test_lock_with_no_code(hass: HomeAssistant) -> None:
+    """Test lock entity with default code that does not match the code format."""
+    lock = MockLockEntity(
+        supported_features=LockEntityFeature.OPEN,
+    )
+    lock.hass = hass
+
+    await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {}))
+    lock.calls_open.assert_called_with({})
+    await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {}))
+    lock.calls_lock.assert_called_with({})
+    await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {}))
+    lock.calls_unlock.assert_called_with({})
+
+    await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""}))
+    lock.calls_open.assert_called_with({})
+    await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""}))
+    lock.calls_lock.assert_called_with({})
+    await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""}))
+    lock.calls_unlock.assert_called_with({})
+
+
 async def test_lock_with_default_code(hass: HomeAssistant) -> None:
     """Test lock entity with default code."""
     lock = MockLockEntity(
@@ -245,5 +293,52 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None:
     assert lock._lock_option_default_code == "1234"
 
     await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {}))
+    lock.calls_open.assert_called_with({ATTR_CODE: "1234"})
     await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {}))
+    lock.calls_lock.assert_called_with({ATTR_CODE: "1234"})
     await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {}))
+    lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"})
+
+    await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""}))
+    lock.calls_open.assert_called_with({ATTR_CODE: "1234"})
+    await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""}))
+    lock.calls_lock.assert_called_with({ATTR_CODE: "1234"})
+    await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""}))
+    lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"})
+
+
+async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None:
+    """Test lock entity with provided code when default code is set."""
+    lock = MockLockEntity(
+        code_format=r"^\d{4}$",
+        supported_features=LockEntityFeature.OPEN,
+        lock_option_default_code="1234",
+    )
+    lock.hass = hass
+
+    await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"}))
+    lock.calls_open.assert_called_with({ATTR_CODE: "4321"})
+    await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"}))
+    lock.calls_lock.assert_called_with({ATTR_CODE: "4321"})
+    await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"}))
+    lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"})
+
+
+async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None:
+    """Test lock entity with default code that does not match the code format."""
+    lock = MockLockEntity(
+        code_format=r"^\d{4}$",
+        supported_features=LockEntityFeature.OPEN,
+        lock_option_default_code="123456",
+    )
+    lock.hass = hass
+
+    assert lock.state_attributes == {"code_format": r"^\d{4}$"}
+    assert lock._lock_option_default_code == "123456"
+
+    with pytest.raises(ValueError):
+        await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {}))
+    with pytest.raises(ValueError):
+        await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {}))
+    with pytest.raises(ValueError):
+        await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {}))