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
41 changes: 40 additions & 1 deletion custom_components/ttlock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from __future__ import annotations

import asyncio
import contextlib
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow

Expand Down Expand Up @@ -149,3 +150,41 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
domain_data.pop(_STORE_KEY, None)

return unload_ok


async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Delete this entry's cloudhook, once no sibling entry still shares it.

Sibling config entries under the same client_id share one webhook_id
(see webhook.py's module docstring), so deleting on every removal would
pull the rug out from under any other entry still using it - checking
entry.data[CONF_WEBHOOK_ID] against every other loaded/configured entry
(already kept in sync by webhook.py's _sync_entry_to_group) avoids
needing this entry's OAuth2 implementation, which may no longer be
resolvable this late in removal.
"""
webhook_id = entry.data.get(CONF_WEBHOOK_ID)
if webhook_id is None:
return

still_shared = any(
candidate.entry_id != entry.entry_id
and candidate.data.get(CONF_WEBHOOK_ID) == webhook_id
for candidate in hass.config_entries.async_entries(DOMAIN)
)
if still_shared:
return

# deferred: cloud pulls in optional heavy dependencies we don't want to
# require at module import time - matches webhook.py's try_generate_cloudhook
from homeassistant.components import cloud # noqa: PLC0415

if not cloud.async_active_subscription(hass):
return

# ValueError alongside CloudNotAvailable: hass_nabucasa's Cloudhooks.async_delete
# raises a bare ValueError if this webhook_id was never actually converted to a
# cloudhook (eg. try_generate_cloudhook returned None despite an active
# subscription) - matches mobile_app's async_remove_entry, which hits the same gap.
with contextlib.suppress(cloud.CloudNotAvailable, ValueError):
await cloud.async_delete_cloudhook(hass, webhook_id)
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ def mock_cloud(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
stub.CloudNotConnected = CloudNotConnected # ty: ignore[unresolved-attribute] - types.ModuleType has no declared attrs, this is a test stub
stub.async_active_subscription = MagicMock(return_value=False) # ty: ignore[unresolved-attribute] - types.ModuleType has no declared attrs, this is a test stub
stub.async_get_or_create_cloudhook = AsyncMock() # ty: ignore[unresolved-attribute] - types.ModuleType has no declared attrs, this is a test stub
stub.async_delete_cloudhook = AsyncMock() # ty: ignore[unresolved-attribute] - types.ModuleType has no declared attrs, this is a test stub

monkeypatch.setitem(sys.modules, "homeassistant.components.cloud", stub)
monkeypatch.setattr(ha_components, "cloud", stub, raising=False)
Expand Down
85 changes: 85 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,88 @@ async def test_no_setup_issue_for_entry_joining_confirmed_group(

mock_create.assert_not_called()
assert entry_b.data[CONF_WEBHOOK_STATUS] is True


async def test_removing_last_entry_deletes_cloudhook(
hass, component_setup, mock_api_responses, mock_cloud
):
"""Removing the sole entry using a webhook_id deletes its cloudhook."""
mock_api_responses("default")
mock_cloud.async_active_subscription.return_value = True
mock_cloud.async_get_or_create_cloudhook.return_value = (
"https://hooks.nabucasa.com/abc"
)

await component_setup()
entry = hass.config_entries.async_entries(DOMAIN)[0]
webhook_id = entry.data[CONF_WEBHOOK_ID]

assert await hass.config_entries.async_remove(entry.entry_id)

mock_cloud.async_delete_cloudhook.assert_awaited_once_with(hass, webhook_id)


async def test_removing_one_of_several_shared_entries_keeps_cloudhook(
hass, mock_api_responses, mock_cloud, multi_account_credential, new_mocked_entry
):
"""Removing one of several entries sharing a webhook_id must not delete it."""
mock_api_responses("default")
mock_cloud.async_active_subscription.return_value = True
mock_cloud.async_get_or_create_cloudhook.return_value = (
"https://hooks.nabucasa.com/abc"
)
await multi_account_credential(hass)

entry_a = new_mocked_entry()
entry_a.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry_a.entry_id)

entry_b = new_mocked_entry()
entry_b.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry_b.entry_id)

await hass.async_block_till_done(wait_background_tasks=True)
assert entry_a.data[CONF_WEBHOOK_ID] == entry_b.data[CONF_WEBHOOK_ID]

assert await hass.config_entries.async_remove(entry_a.entry_id)

mock_cloud.async_delete_cloudhook.assert_not_called()

assert await hass.config_entries.async_remove(entry_b.entry_id)

mock_cloud.async_delete_cloudhook.assert_awaited_once()


async def test_removing_entry_without_cloudhook_is_a_noop(
hass, component_setup, mock_api_responses, mock_cloud
):
"""Removal is a no-op, not an error, for a non-cloud user."""
mock_api_responses("default")

await component_setup()
entry = hass.config_entries.async_entries(DOMAIN)[0]

assert await hass.config_entries.async_remove(entry.entry_id)

mock_cloud.async_delete_cloudhook.assert_not_called()


async def test_removing_entry_whose_cloudhook_was_never_created_is_a_noop(
hass, component_setup, mock_api_responses, mock_cloud
):
"""Removal is a no-op, not an error, for a cloud user whose webhook_id was
never actually converted to a cloudhook (eg. try_generate_cloudhook
returned None despite an active subscription) - hass_nabucasa's
Cloudhooks.async_delete raises a bare ValueError for this, not
cloud.CloudNotAvailable.
"""
mock_api_responses("default")
mock_cloud.async_active_subscription.return_value = True
mock_cloud.async_delete_cloudhook.side_effect = ValueError(
"Hook is not enabled for the cloud."
)

await component_setup()
entry = hass.config_entries.async_entries(DOMAIN)[0]

assert await hass.config_entries.async_remove(entry.entry_id)
Loading